Здравствуйте, меня зовут Иван и я разработчик.
Недавно прошла приуроченная к выходу .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 с более удобным, на мой взгляд, подходом. Я думаю, что технология генераторов исходного кода сильно изменит подход к разработке библиотек и расширений.
