Здравствуйте, меня зовут Иван и я разработчик.

Недавно прошла приуроченная к выходу .NET 5 конференция .NETConf 2020. На которой один из докладчиков рассказывал про C# Source Generators. Поискав на youtube нашел еще неплохое видео по этой теме. Советую их посмотреть. В них показывается как во время написания кода разработчиком, генерируется код, а InteliSense тут же подхватывает сгенерированный код, предлагает сгенерированные методы и свойства, а компилятор не ругается на их отсутствие. На мой взгляд, это хорошая возможность для расширения возможностей языка и я попробую это продемонстрировать.

Идея

Все же знают LINQ? Так вот для событий есть аналогичная библиотека Reactive Extensions, которая позволяет в том же виде, что и LINQ обрабатывать события.

Проблема в том, что чтобы пользоваться Reactive Extensions надо и события оформить в виде Reactive Extensions, а так как все события, в стандартных библиотеках, написаны в стандартном виде то и Reactive Extensions использовать не удобно. Есть костыль, который преобразует стандартные события C# в вид Reactive Extensions. Выглядит он так. Допустим есть класс с каким-то событием:

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

Чтобы этим событием можно было пользоваться в стиле Reactive Extensions необходимо написать метод расширения вида:

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);
}

И после этого можно воспользоваться всеми плюсами Reactive Extensions, например, вот так:

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

Так вот, идея состоит в том, чтобы костыль этот генерировался сам, а методами можно было пользоваться из InteliSense при разработке.

Задача

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 работает если добавить в проект строчку:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net5.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
...
    <IncludePackageReferencesDuringMarkupCompilation>true</IncludePackageReferencesDuringMarkupCompilation>
...
  </PropertyGroup>

Для 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 как расширение, в котором можно нормально отлаживать.

Краткий вывод

Код генератора уместился в 140 строк. За эти 140 строк получилось изменить синтаксис языка, избавится от событий заменив их на Reactive Extensions с более удобным, на мой взгляд, подходом. Я думаю, что технология генераторов исходного кода сильно изменит подход к разработке библиотек и расширений.

Ссылки

NuGet

GitHub