Replacing C # Events with Reactive Extensions Using Code Generation

Hello, my name is Ivan and I am a developer.





NETConf 2020, timed to coincide with the release of .NET 5, was held recently. One of the speakers talked about C # Source Generators . Searching on youtube I found another good video on this topic . I advise you to watch them. They show how, while the developer is writing the code, the code is generated, and InteliSense immediately picks up the generated code, offers the generated methods and properties, and the compiler does not swear at their absence. In my opinion, this is a good opportunity to expand the capabilities of the language and I will try to demonstrate it.





Idea

Does anyone know LINQ ? So for events there is a similar library Reactive Extensions , which allows you to process events in the same way as LINQ .





The problem is that in order to use Reactive Extensions, you need to arrange events in the form of Reactive Extensions, and since all events in standard libraries are written in a standard form, it is not convenient to use Reactive Extensions. There is a crutch that converts standard C # events into Reactive Extensions. It looks like this. Let's say there is a class with some event:





public partial class Example
{
    public event Action<int, string, bool> ActionEvent;
}
      
      



To use this event in the Reactive Extensions style, you need to write a view extension method:





public static IObservable<(int, string, bool)> RxActionEvent(this TestConsoleApp.Example obj)
{
    if (obj == null) throw new ArgumentNullException(nameof(obj));
    return Observable.FromEvent<System.Action<int, string, bool>, (int, string, bool)>(
    conversion => (obj0, obj1, obj2) => conversion((obj0, obj1, obj2)),
    h => obj.ActionEvent += h,
    h => obj.ActionEvent -= h);
}
      
      



And after that, you can take advantage of all the advantages of Reactive Extensions , for example, like this:





var example = new  Example();
example.RxActionEvent().Where(obj => obj.Item1 > 10).Take(1).Subscribe((obj)=> { /* some action  */});
      
      



So, the idea is that this crutch is generated by itself, and the methods can be used from InteliSense during development.





A task

1)  «.» «Rx», , example.RxActionEvent()



, , , Action ActionEvent, .RxActionEvent()



, :





public static IObservable<(System.Int32 Item1Int32, System.String Item2String, System.Boolean Item3Boolean)> RxActionEvent(this TestConsoleApp.Example obj)
{
    if (obj == null) throw new ArgumentNullException(nameof(obj));
    return Observable.FromEvent<System.Action<System.Int32, System.String, System.Boolean>, (System.Int32 Item1Int32, System.String Item2String, System.Boolean 
Item3Boolean)>(
    conversion => (obj0, obj1, obj2) => conversion((obj0, obj1, obj2)),
    h => obj.ActionEvent += h,
    h => obj.ActionEvent -= h);
}
      
      



2) InteliSense .





 

2 .





:





<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.1">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" />
  </ItemGroup>
</Project>
      
      



, netstandard2.0 2 Microsoft.CodeAnalysis.Analyzers Microsoft.CodeAnalysis.CSharp.Workspaces.





:





<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Reactive" Version="5.0.0" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\SourceGenerator\RxSourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
  </ItemGroup>
</Project>
      
      



, :





<ProjectReference Include="..\SourceGenerator\RxSourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
      
      



 

[Generator]



ISourceGenerator:





[Generator]
public class RxGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)  {  }
    public void Execute(GeneratorExecutionContext context)  {  }
}
      
      



M Initialize , Execute .





Initialize ISyntaxReceiver.





, :





  • ->





  • ISyntaxReceiver->





  • ISyntaxReceiver , ->





  • Execute ISyntaxReceiver, .





, :





[Generator]
public class RxGenerator : ISourceGenerator
{
    private const string firstText = @"using System; using System.Reactive.Linq; namespace RxGenerator{}";
    public void Initialize(GeneratorInitializationContext context)
    {
        //  ISyntaxReceiver
        context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
    }
    public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SyntaxReceiver receiver) return;
        //      "RxGenerator.cs"  ,   firstText
        context.AddSource("RxGenerator.cs", firstText);
    }
    class SyntaxReceiver : ISyntaxReceiver
    {
        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
        //     ,     .
        }
    }
}
      
      



VS, using RxGenerator;



VS.





ISyntaxReceiver

OnVisitSyntaxNode MemberAccessExpressionSyntax.





private class SyntaxReceiver : ISyntaxReceiver
{
    public List<MemberAccessExpressionSyntax> GenerateCandidates { get; } =
        new List<MemberAccessExpressionSyntax>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (!(syntaxNode is MemberAccessExpressionSyntax syntax)) return;
        if (syntax.HasTrailingTrivia || syntax.Name.IsMissing) return;
        if (!syntax.Name.ToString().StartsWith("Rx")) return;
        GenerateCandidates.Add(syntax);

    }
}
      
      



:





  • syntax.Name.IsMissing







  • syntax.HasTrailingTrivia



    -





  • !syntax.Name.ToString().StartsWith("Rx")



    "Rx"





, .





     

:





  •  ,    





  •   . , 





    System.Action<System.Int32, System.String, System.Boolean,xSouceGeneratorXUnitTests.SomeEventArgs>







  •     





:





private static IEnumerable<(string ClassType, string EventName, string EventType, List<string> ArgumentTypes)>
    GetExtensionMethodInfo(GeneratorExecutionContext context, SyntaxReceiver receiver)
{
    HashSet<(string ClassType, string EventName)>
        hashSet = new HashSet<(string ClassType, string EventName)>();
    foreach (MemberAccessExpressionSyntax syntax in receiver.GenerateCandidates)
    {
        SemanticModel model = context.Compilation.GetSemanticModel(syntax.SyntaxTree);
        ITypeSymbol? typeSymbol = model.GetSymbolInfo(syntax.Expression).Symbol switch
        {
            IMethodSymbol s => s.ReturnType,
            ILocalSymbol s => s.Type,
            IPropertySymbol s => s.Type,
            IFieldSymbol s => s.Type,
            IParameterSymbol s => s.Type,
            _ => null
        };
        if (typeSymbol == null) continue;

...
      
      



SemanticModel. . ITypeSymbol. ITypeSymbol .





...
        string eventName = syntax.Name.ToString().Substring(2);

        if (!(typeSymbol.GetMembersOfType<IEventSymbol>().FirstOrDefault(m => m.Name == eventName) is { } ev)
        ) continue;

        if (!(ev.Type is INamedTypeSymbol namedTypeSymbol)) continue;
        if (namedTypeSymbol.DelegateInvokeMethod == null) continue;
        if (!hashSet.Add((typeSymbol.ToString(), ev.Name))) continue;

        string fullType = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat);
        List<string> typeArguments = namedTypeSymbol.DelegateInvokeMethod.Parameters
            .Select(m => m.Type.ToDisplayString(SymbolDisplayFormat)).ToList();
        yield return (typeSymbol.ToString(), ev.Name, fullType, typeArguments);
    }
}
      
      



:





string fullType = namedTypeSymbol.ToDisplayString(symbolDisplayFormat);
      
      



SymbolDisplayFormat SymbolDisplayFormat ToDisplayString() . ToDisplayString() :





System.Action<System.Int32, System.String, System.Boolean, RxSouceGeneratorXUnitTests.SomeEventArgs>
      
      







Action<int, string, bool, SomeEventArgs>
      
      



.





:





List<string> typeArguments = namedTypeSymbol.DelegateInvokeMethod.Parameters.Select(m => m.Type.ToDisplayString(SymbolDisplayFormat)).ToList();
      
      



.





StringBuilder , , .





Execute:





Spoiler
public void Execute(GeneratorExecutionContext context)
{
    if (!(context.SyntaxReceiver is SyntaxReceiver receiver)) return;

    if (!(receiver.GenerateCandidates.Any()))
    {
        context.AddSource("RxGenerator.cs", startText);
        return;
    }

    StringBuilder sb = new();
    sb.AppendLine("using System;");
    sb.AppendLine("using System.Reactive.Linq;");
    sb.AppendLine("namespace RxMethodGenerator{");
    sb.AppendLine("    public static class RxGeneratedMethods{");

    foreach ((string classType, string eventName, string eventType, List<string> argumentTypes) in
        GetExtensionMethodInfo(context,
            receiver))
    {
        string tupleTypeStr;
        string conversionStr;

        switch (argumentTypes.Count)
        {
            case 0:
                tupleTypeStr = classType;
                conversionStr = "conversion => () => conversion(obj),";
                break;
            case 1:
                tupleTypeStr = argumentTypes.First();
                conversionStr = "conversion => obj1 => conversion(obj1),";
                break;
            default:
                tupleTypeStr =
                    $"({string.Join(", ", argumentTypes.Select((x, i) => $"{x} Item{i + 1}{x.Split('.').Last()}"))})";
                string objStr = string.Join(", ", argumentTypes.Select((x, i) => $"obj{i}"));
                conversionStr = $"conversion => ({objStr}) => conversion(({objStr})),";
                break;
        }

        sb.AppendLine(@$"        public static IObservable<{tupleTypeStr}> Rx{eventName}(this {classType} obj)");
        sb.AppendLine( @"        {");

        sb.AppendLine(  "            if (obj == null) throw new ArgumentNullException(nameof(obj));");
        sb.AppendLine(@$"            return Observable.FromEvent<{eventType}, {tupleTypeStr}>(");
        sb.AppendLine(@$"            {conversionStr}");
        sb.AppendLine(@$"            h => obj.{eventName} += h,");
        sb.AppendLine(@$"            h => obj.{eventName} -= h);");

        sb.AppendLine(  "        }");
    }
    sb.AppendLine(      "    }");
    sb.AppendLine(      "}");

    context.AddSource("RxGenerator.cs", sb.ToString());
}
      
      







  InteliSense     

«.» InteliSense . . «.» . , MS . .





CompletionProvider InteliSense «.». NuGet, .





.





CompletionProvider , , CompletionProvider:





public override bool ShouldTriggerCompletion(SourceText text, int caretPosition, CompletionTrigger trigger, OptionSet options)
{
    switch (trigger.Kind)
    {
        case CompletionTriggerKind.Insertion:
            int insertedCharacterPosition = caretPosition - 1;
            if (insertedCharacterPosition <= 0) return false;
            char ch = text[insertedCharacterPosition];
            char previousCh = text[insertedCharacterPosition - 1];
            return ch == '.' && !char.IsWhiteSpace(previousCh) && previousCh != '\t' && previousCh != '\r' && previousCh != '\n';
        default:
            return false;
    }
}
      
      



«.» - .





True , InteliSense:





public override async Task ProvideCompletionsAsync(CompletionContext context)
{
    SyntaxNode? syntaxNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
    if (!(syntaxNode?.FindNode(context.CompletionListSpan) is ExpressionStatementSyntax
        expressionStatementSyntax)) return;
    if (!(expressionStatementSyntax.Expression is MemberAccessExpressionSyntax syntax)) return;
    if (!(await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false) is { }
        model)) return;

    ITypeSymbol? typeSymbol = model.GetSymbolInfo(syntax.Expression).Symbol switch
    {
        IMethodSymbol s => s.ReturnType,
        ILocalSymbol s => s.Type,
        IPropertySymbol s => s.Type,
        IFieldSymbol s => s.Type,
        IParameterSymbol s => s.Type,
        _ => null
    };
    if (typeSymbol == null) return;

    foreach (IEventSymbol ev in typeSymbol.GetMembersOfType<IEventSymbol>())
    {
        ...        
        //     InteliSense
        CompletionItem item = CompletionItem.Create($"Rx{ev.Name}");
        context.AddItem(item);
    }
}
      
      



, , .





, InteliSense:





public override Task<CompletionDescription> GetDescriptionAsync(Document document, CompletionItem item, CancellationToken cancellationToken)
{
    return Task.FromResult(CompletionDescription.FromText(" "));
}
      
      



InteliSense , , «.» :





public override async Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item,
    char? commitKey, CancellationToken cancellationToken)
{
    string newText = $".{item.DisplayText}()";
    TextSpan newSpan = new TextSpan(item.Span.Start - 1, 1);

    TextChange textChange = new TextChange(newSpan, newText);
    return await Task.FromResult(CompletionChange.Create(textChange));
}
      
      



!





    

Visual Studio №16.8.3. GitHub Visual Studio. Rider ReSharper 2020.3. ReSharper , 2020.3.





, . WPF , GitHub Roslyn.





CompletionProvider Vsix . NuGet . . using , NuGet.





   

Initialize Debugger.Launch();



VS





public void Initialize(GeneratorInitializationContext context)
{
    #if (DEBUG)
    Debugger.Launch();
    #endif
    context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
      
      



. - VS, .





CompletionProvider VS «Analyzer with code Fix». , Vsix. CompletionProvider , .





 

The generator code fits into 140 lines. In these 140 lines, it turned out to change the syntax of the language, get rid of events by replacing them with Reactive Extensions with a more convenient, in my opinion, approach. I think that the technology of source code generators will greatly change the approach to developing libraries and extensions.





Links

NuGet





Github








All Articles