Здравствуйте, меня зовут Иван и я разработчик.
Недавно прошла приуроченная к выходу .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 не работает, этот баг описан на 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 как расширение, в котором можно нормально отлаживать.
Краткий вывод
Код генератора уместился в 140 строк. За эти 140 строк получилось изменить синтаксис языка, избавится от событий заменив их на Reactive Extensions с более удобным, на мой взгляд, подходом. Я думаю, что технология генераторов исходного кода сильно изменит подход к разработке библиотек и расширений.
Junecat
Очень хороший подход! Учитывая, что статья — это своего рода дебют автора на хабре, это просто прекрасно!
Ivan_Zalutskii Автор
Спасибо.