В апреле 2020-го года разработчиками платформы .NET 5 был анонсирован новый способ генерации исходного кода на языке программирования C# — с помощью реализации интерфейса ISourceGenerator. Данный способ позволяет разработчикам анализировать пользовательский код и создавать новые исходные файлы на этапе компиляции. При этом, API новых генераторов исходного кода схож с API анализаторов Roslyn. Генерировать код можно как с помощью Roslyn Compiler API, так и методом конкатенации обычных строк.


В данном материале рассмотрим процесс реализации ISourceGenerator для генерации типизированных ссылок на элементы управления AvaloniaUI, объявленные в XAML. В процессе разработки научим генератор компилировать XAML с помощью API компилятора XamlX, используемого в AvaloniaUI, и системы типов XamlX, реализованной поверх API семантической модели Roslyn.


Постановка задачи


С помощью новых генераторов исходного кода может получиться элегантно решить широкий спектр задач, включая генерацию шаблонного кода, который было бы не очень интересно и совсем не продуктивно писать вручную. Например, в приложениях, использующих AvaloniaUI — фреймворк для разработки кроссплатформенных приложений с графическим интерфейсом, о котором недавно вышла статья на Хабре — нередко приходится писать следующий код для получения ссылки на элемент управления, объявленный в XAML:


private TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");

Элемент типа TextBox с именем PasswordTextBox при этом объявлен в XAML следующим образом:


<TextBox x:Name="PasswordTextBox"
         Watermark="Please, enter your password..."
         UseFloatingWatermark="True"
         PasswordChar="*" />

Получать ссылку на элемент управления в XAML может понадобиться в случае необходимости применения анимаций, программного изменения стилей и свойств элемента управления, или использования кроссплатформенных типизированных привязок данных ReactiveUI, таких, как Bind, BindCommand, BindValidation, позволяющих связывать компоненты View и ViewModel без использования синтаксиса {Binding} в XAML-разметке.


public class SignUpView : ReactiveWindow<SignUpViewModel>
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);

        // Привязки данных ReactiveUI и ReactiveUI.Validation.
        // Можно было бы схожим образом использовать расширение разметки Binding,
        // но некоторые разработчики предпочитают описывать биндинги в C#.
        // Почему бы не облегчить им (и многим другим) жизнь?
        //
        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);
        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);
        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);
    }

    // Шаблонный код для типизированного доступа к именованным
    // элементам управления, объявленным в XAML.
    TextBox UserNameTextBox => this.FindControl<TextBox>("UserNameTextBox");
    TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");
    TextBlock CompoundValidation => this.FindControl<TextBlock>("CompoundValidation");
}

Код геттеров свойств, позволяющих получить типизированный доступ к элементам управления из XAML-файла, соответствующего SignUpView, выглядит шаблонным. Было бы неплохо научиться генерировать этот код, чтобы, с одной стороны, избежать многословности, а с другой стороны — чтобы избежать возможных ошибок и опечаток в имени элемента управления, объявленного в XAML, или в имени его типа.


Если мы будем всё время писать код, как в примере выше, вручную, нам придётся самостоятельно следить за изменениями имён и типов в XAML-файле, и в случае ошибки мы узнаем о том, что что-то пошло не так, только после запуска приложения. Если бы мы генерировали ссылки некоторым способом, об ошибках и опечатках мы бы узнавали уже на этапе компиляции, и тратили бы меньше времени на отладку нашего кроссплатформенного приложения (как, впрочем, и на написание таких геттеров).


Пример входных и выходных данных


Мы ожидаем, что на вход наш генератор исходного кода будет получать два файла. Для компонента представления с именем SignUpView, данными файлами будут являться XAML-разметка SignUpView.xaml, и code-behind файл SignUpView.xaml.cs, содержащий логику пользовательского интерфейса. Например, для файла разметки пользовательского интерфейса SignUpView.xaml:


<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="Avalonia.NameGenerator.Sandbox.Views.SignUpView">
    <StackPanel>
        <TextBox x:Name="UserNameTextBox"
                 Watermark="Please, enter user name..."
                 UseFloatingWatermark="True" />
        <TextBlock Name="UserNameValidation"
                   Foreground="Red"
                   FontSize="12" />
    </StackPanel>
</Window>

Содержимое файла SignUpView.xaml.cs будет выглядеть следующим образом:


public partial class SignUpView : Window
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);
        // Мы хотим иметь доступ к типизированным элементам управления вот здесь,
        // чтобы, например, писать код наподобие вот такого:
        UserNameTextBox.Text = "Violet Evergarden";
        UserNameValidation.Text = "An optional validation error message";
    }
}

А сгенерированное содержимое SignUpView.xaml.cs должно будет выглядеть следующим образом:


partial class SignUpView
{
    internal global::Avalonia.Controls.TextBox UserNameTextBox =>
        this.FindControl<global::Avalonia.Controls.TextBox>("UserNameTextBox");
    internal global::Avalonia.Controls.TextBlock UserNameValidation =>
        this.FindControl<global::Avalonia.Controls.TextBlock>("UserNameValidation");
}

Префиксы global:: здесь нужны для избежания коллизий пространств имён. Дополнительно, необходимо полностью указывать имена типов также для избежания коллизий. По аналогии с WPF, мы маркируем генерируемые свойства как internal. В случае использования partial-классов базовый класс можно указывать только в одной из частей partial-класса, поэтому в сгенерированном коде мы опускаем указание базового класса — таким образом пользователи нашего генератора смогут наследоваться от какого угодно наследника Window, будь то ReactiveWindow<TViewModel>, или другой тип окна.


Следует заметить, что при вызове метода FindControl обход дерева элементов производиться не будет — Avalonia хранит именованные ссылки на элементы управления в словарях, называемых INameScope в терминологии Avalonia. При желании, Вы можете изучить исходный код методов FindControl и FindNameScope на GitHub.


Реализуем интерфейс ISourceGenerator


Простейший генератор исходного кода, который ничего не делает, выглядит следующим образом:


[Generator]
public class EmptyGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context) { }
}

В методе Initialize предлагается проинициализировать новый генератор исходного кода, а в методе Execute — выполнить все важные вычисления, и при необходимости добавить сгенерированные файлы исходного кода в контекст выполнения с помощью вызова метода context.AddSource(fileName, sourceText). При этом, файл проекта генератора исходного кода выглядит следующим образом:


<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>preview</LangVersion>
        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
        <IncludeBuildOutput>false</IncludeBuildOutput>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference
            Include="Microsoft.CodeAnalysis.CSharp"
            Version="3.8.0-5.final"
            PrivateAssets="all" />
        <PackageReference
            Include="Microsoft.CodeAnalysis.Analyzers"
            Version="3.3.1"
            PrivateAssets="all" />
    </ItemGroup>
    <ItemGroup>
        <None Include="$(OutputPath)\$(AssemblyName).dll"
              Pack="true"
              PackagePath="analyzers/dotnet/cs"
              Visible="false" />
    </ItemGroup>
</Project>

Давайте, для начала, добавим в сборку проекта, ссылающегося на генератор, некоторый атрибут, с помощью которого пользователи нашего генератора будут помечать классы, для которых необходимо генерировать типизированные ссылки на элементы управления Avalonia, объявленные в XAML. Изменим код нашего генератора следующим образом:


[Generator]
public class NameReferenceGenerator : ISourceGenerator
{
    private const string AttributeName = "GenerateTypedNameReferencesAttribute";
    private const string AttributeFile = "GenerateTypedNameReferencesAttribute.g.cs";
    private const string AttributeCode = @"// <auto-generated />
using System;
[AttributeUsage(AttributeTargets.Class, Inherited=false, AllowMultiple=false)]
internal sealed class GenerateTypedNameReferencesAttribute : Attribute { }
";

    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        // Добавим код атрибута в файл 'GenerateTypedNameReferencesAttribute.cs' 
        // проекта разработчика, который решит воспользоваться нашим генератором.
        context.AddSource(AttributeFile,
            SourceText.From(
                AttributeCode, Encoding.UTF8));
    }
}

Пока ничего сложного — мы объявили исходный код атрибута, имя файла, и имя атрибута как константы, с помощью вызова SourceText.From(code) обернули строку в исходный текст, и затем добавили новый исходный файл в проект с помощью вызова context.AddSource(fileName, sourceText). Теперь в проекте, который ссылается на наш генератор, мы можем помечать интересующие нас классы с помощью атрибута [GenerateTypedNameReferences]. Для классов, помеченных данным атрибутом, мы будем генерировать типизированные ссылки на именованные элементы управления, объявленные в XAML. В случае рассматриваемого примера с SignUpView.xaml, code-behind данного файла разметки должен будет выглядеть вот так:


[GenerateTypedNameReferences]
public partial class SignUpView : Window
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);
        // Мы пока только собираемся генерировать именованные ссылки.
        // Если раскомментировать код ниже, проект не скомпилируется (пока).
        // UserNameTextBox.Text = "Violet Evergarden";
        // UserNameValidation.Text = "An optional validation error message";
    }
}

Нам необходимо научить наш ISourceGenerator следующим вещам:


  1. Находить все классы, помеченные атрибутом [GenerateTypedNameReferences];
  2. Находить соответствующие классам XAML-файлы;
  3. Извлекать полные имена типов элементов интерфейса, объявленных в XAML-файлах;
  4. Вытаскивать из XAML-файлов имена (значения Name или x:Name) элементов управления;
  5. Генерировать partial-класс и заполнять его типизированными ссылками.

Находим классы, маркированные атрибутом


Для реализации такой функциональности API генераторов исходного кода предлагает реализовать и зарегистрировать интерфейс ISyntaxReceiver, который позволит собрать все ссылки на интересующий синтаксис в одном месте. Реализуем ISyntaxReceiver, который будет собирать все ссылки на объявления классов сборки пользователя нашего генератора:


internal class NameReferenceSyntaxReceiver : ISyntaxReceiver
{
    public List<ClassDeclarationSyntax> CandidateClasses { get; } =
        new List<ClassDeclarationSyntax>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
            classDeclarationSyntax.AttributeLists.Count > 0)
            CandidateClasses.Add(classDeclarationSyntax);
    }
}

Зарегистрируем данный класс в методе ISourceGenerator.Initialize(GeneratorInitializationContext context):


context.RegisterForSyntaxNotifications(() => new NameReferenceSyntaxReceiver());

Теперь, во время выполнения нашего генератора, мы можем получить доступ ко всем ClassDeclarationSyntax в сборке, и в цикле найти все классы, маркированные необходимым образом:


// Добавим в CSharpCompilation исходник нашего атрибута.
var options = (CSharpParseOptions)existingCompilation.SyntaxTrees[0].Options;
var compilation = existingCompilation.AddSyntaxTrees(CSharpSyntaxTree
    .ParseText(SourceText.From(AttributeCode, Encoding.UTF8), options));

var attributeSymbol = compilation.GetTypeByMetadataName(AttributeName);
var symbols = new List<INamedTypeSymbol>();
foreach (var candidateClass in nameReferenceSyntaxReceiver.CandidateClasses)
{
    // Извлечём INamedTypeSymbol из нашего класса-кандидата.
    var model = compilation.GetSemanticModel(candidateClass.SyntaxTree);
    var typeSymbol = (INamedTypeSymbol) model.GetDeclaredSymbol(candidateClass);

    // Проверим, маркирован ли класс с помощью нашего атрибута.
    var relevantAttribute = typeSymbol!
        .GetAttributes()
        .FirstOrDefault(attr => attr.AttributeClass!.Equals(
            attributeSymbol, SymbolEqualityComparer.Default));

    if (relevantAttribute == null) {
        continue;
    }

    // Проверим, маркирован ли класс как 'partial'.
    var isPartial = candidateClass
        .Modifiers
        .Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword));

    // Таким образом, список 'symbols' будет содержать только те
    // классы, которые маркированы с помощью ключевого слова 'partial'
    // и атрибута 'GenerateTypedNameReferences'.
    if (isPartial) {
        symbols.Add(typeSymbol);
    }
}

Находим подходящие XAML-файлы


В Avalonia действуют соглашения именования XAML-файлов и code-behind файлов для них. Для файла с разметкой с именем SignUpView.xaml файл code-behind будет называться SignUpView.xaml.cs, а класс внутри него, как правило, называется SignUpView. В нашей реализации генератора типизированных ссылок будем полагаться на данную схему именования. Файлы разметки Avalonia на момент реализации генератора и написания данного материала могли иметь расширения .xaml или .axaml, поэтому код, определяющий имя XAML-файла на основании имени типа будет иметь следующий вид:


var xamlFileName = $"{typeSymbol.Name}.xaml";
var aXamlFileName = $"{typeSymbol.Name}.axaml";
var relevantXamlFile = context
    .AdditionalFiles
    .FirstOrDefault(text =>
         text.Path.EndsWith(xamlFileName) ||
         text.Path.EndsWith(aXamlFileName));

Здесь, typeSymbol имеет тип INamedTypeSymbol и может быть получен в результате обхода списка symbols, который мы сформировали на предыдущем этапе. А ещё здесь есть один нюанс. Чтобы файлы разметки были доступны как AdditionalFiles, пользователю генератора необходимо их дополнительно включить в проект с использованием директивы MSBuild <AdditionalFiles />. Таким образом, пользователь генератора должен отредактировать файл проекта .csproj, и добавить туда вот такой <ItemGroup />:


<ItemGroup>
    <!-- Очень важная директива, без которой генераторы исходного
         кода не смогут выпотрошить файлы разметки! -->
    <AdditionalFiles Include="**\*.xaml" />
</ItemGroup>

Подробное описание <AdditionalFiles /> можно найти в материале New C# Source Generator Samples.


Извлекаем полные имена типов из XAML


Этот этап является самым сложным, но в то же время наиболее интересным. Дело в том, что нельзя просто взять и получить информацию о пространстве имён, в котором находится элемент управления, объявленный в XAML-разметке. А нам, из-за нашего желания избежать коллизий и генерировать вменяемый код, который всегда будет компилироваться и работать, позарез нужно уметь получать полную квалификацию пространства имён, в котором находится тип.


Хорошая новость заключается в том, что фреймворк AvaloniaUI использует новый компилятор XamlX, целиком написанный @kekekeks. Этот компилятор мало того, что не имеет рантайм-зависимостей, умеет находить ошибки в XAML на этапе компиляции, работает намного быстрее загрузчиков XAML из WPF, UWP, XF и других технологий, так ещё и предоставляет нам удобный API для парсинга XAML и разрешения типов. Таким образом, мы можем позволить себе подключить XamlX в проект исходниками (git submodule add ://repo ./path), и написать свой собственный MiniCompiler, который наш генератор исходного кода будет вызывать для компиляции XAML и получения полной информации о типах, даже если они лежат в каких-нибудь сторонних сборках. Реализация XamlX.XamlCompiler в виде нашего маленького MiniCompiler, который мы собираемся натравливать на XAML-файлы, имеет вид:


internal sealed class MiniCompiler : XamlCompiler<object, IXamlEmitResult>
{
    public static MiniCompiler CreateDefault(
        RoslynTypeSystem typeSystem,
        params string[] additionalTypes)
    {
        var mappings = new XamlLanguageTypeMappings(typeSystem);
        foreach (var additionalType in additionalTypes)
            mappings.XmlnsAttributes.Add(typeSystem.GetType(additionalType));
        var configuration = new TransformerConfiguration(
            typeSystem,
            typeSystem.Assemblies[0],
            mappings);
        return new MiniCompiler(configuration);
    }

    private MiniCompiler(TransformerConfiguration configuration)
        : base(configuration,
               new XamlLanguageEmitMappings<object, IXamlEmitResult>(),
               false)
    {
        // Данные трансформеры будут преобразовывать AST XamlX
        // по очереди, делая его с каждым шагом более информативным.
        Transformers.Add(new NameDirectiveTransformer());
        Transformers.Add(new DataTemplateTransformer());
        Transformers.Add(new KnownDirectivesTransformer());
        Transformers.Add(new XamlIntrinsicsTransformer());
        Transformers.Add(new XArgumentsTransformer());
        Transformers.Add(new TypeReferenceResolver());
    }

    protected override XamlEmitContext<object, IXamlEmitResult> InitCodeGen(
        IFileSource file,
        Func<string, IXamlType, IXamlTypeBuilder<object>> createSubType,
        object codeGen, XamlRuntimeContext<object, IXamlEmitResult> context,
        bool needContextLocal) =>
        throw new NotSupportedException();
}

В нашем MiniCompiler мы используем дефолтные трансформеры XamlX, DataTemplateTransformer, и NameDirectiveTransformer, позаимствованный из репозитория Avalonia, который умеет преобразовывать XAML-атрибут x:Name в XAML-атрибут Name для того, чтобы впоследствии обходить полученное AST и вытаскивать имена элементов управления было проще. Такой NameDirectiveTransformer выглядит следующим образом:


internal class NameDirectiveTransformer : IXamlAstTransformer
{
    public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node)
    {
        // Нас интересуют только объекты.
        if (node is XamlAstObjectNode objectNode)
        {
            for (var index = 0; index < objectNode.Children.Count; index++)
            {
                // Если мы встретили x:Name, заменяем его на Name и 
                // продолжаем обходить потомков XamlAstObjectNode дальше.
                var child = objectNode.Children[index];
                if (child is XamlAstXmlDirective directive &&
                    directive.Namespace == XamlNamespaces.Xaml2006 &&
                    directive.Name == "Name")
                    objectNode.Children[index] =
                        new XamlAstXamlPropertyValueNode(
                            directive,
                            new XamlAstNamePropertyReference(
                                directive, objectNode.Type, "Name", objectNode.Type),
                            directive.Values);
            }
        }
        return node;
    }
}

DataTemplateTransformer, в свою очередь, будет избавляться от элементов XAML, объявленных внутри шаблонов <DataTemplate />. Данные шаблоны используются в AvaloniaUI для настройки, например, внешнего вида элементов списков — и поэтому попытка достать элемент с атрибутом x:Name извне такого шаблона ни к чему не приведёт. DataTemplateTransformer будет иметь вид:


internal class DataTemplateTransformer : IXamlAstTransformer
{
    public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node)
    {
        if (node is XamlAstObjectNode objectNode &&
            objectNode.Type is XamlAstXmlTypeReference typeReference &&
            (typeReference.Name == "DataTemplate" ||
             typeReference.Name == "ControlTemplate"))
            objectNode.Children.Clear(); // Очищаем содержимое шаблона.
        return node;
    }
}

Фабрика MiniCompiler.CreateDefault принимает первым аргументом любопытный тип RoslynTypeSystem, который вы не найдёте в исходниках XamlX. Данный тип реализует интерфейс IXamlTypeSystem, а это значит, что всё самое сложное только начинается. Чтобы наш маленький компилятор заработал внутри нашего генератора исходного кода, нам необходимо реализовать систему типов XamlX поверх API семантической модели компилятора Roslyn. Для реализации новой IXamlTypeSystem пришлось реализовывать много-много интерфейсов (IXamlType для классов, IXamlAssembly для сборок, IXamlMethod для методов, IXamlProperty для свойств и др). Реализация IXamlAssembly, например, выглядит вот так:


public class RoslynAssembly : IXamlAssembly
{
    private readonly IAssemblySymbol _symbol;

    public RoslynAssembly(IAssemblySymbol symbol) => _symbol = symbol;

    public bool Equals(IXamlAssembly other) =>
        other is RoslynAssembly roslynAssembly &&
        SymbolEqualityComparer.Default.Equals(_symbol, roslynAssembly._symbol);

    public string Name => _symbol.Name;

    public IReadOnlyList<IXamlCustomAttribute> CustomAttributes =>
        _symbol.GetAttributes()
            .Select(data => new RoslynAttribute(data, this))
            .ToList();

    public IXamlType FindType(string fullName)
    {
        var type = _symbol.GetTypeByMetadataName(fullName);
        return type is null ? null : new RoslynType(type, this);
    }
}

После реализации всех необходимых интерфейсов мы наконец сможем распарсить XAML инструментами XamlX, создать инстанс нашей реализации RoslynTypeSystem, передав ей в конструктор CSharpCompilation, которую мы уже извлекли из контекста генерации на предыдущем этапе, и трансформировать полученное в результате парсинга AST в AST с включённой информацией о пространствах имён и типах:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());
MiniCompiler.CreateDefault(
    // 'compilation' имеет тип 'CSharpCompilation'
    new RoslynTypeSystem(compilation),
    "Avalonia.Metadata.XmlnsDefinitionAttribute")
    .Transform(parsed);

Готово! Осталось извлечь все именованные объекты из дерева — и дело в шляпе.


Находим именованные объекты XAML


На предыдущем этапе мы уже рассмотрели трансформер AST XamlX, реализующий IXamlAstTransformer, а теперь давайте рассмотрим и напишем посетителя узлов этого AST, реализующий интерфейс IXamlAstVisitor. Наш посетитель будет выглядеть следующим образом:


internal sealed class NameReceiver : IXamlAstVisitor
{
    private readonly List<(string TypeName, string Name)> _items =
        new List<(string TypeName, string Name)>();

    public IReadOnlyList<(string TypeName, string Name)> Controls => _items;

    public IXamlAstNode Visit(IXamlAstNode node)
    {
        if (node is XamlAstObjectNode objectNode)
        {
            // Извлекаем тип AST-узла. Данный тип нам вывел XamlX в
            // процессе взаимодействия с нашей RoslynTypeSystem.
            //
            var clrType = objectNode.Type.GetClrType();
            foreach (var child in objectNode.Children)
            {
                // Если мы в результате обхода потомков встретили свойство,
                // которое называется 'Name', и при этом внутри 'Name' лежит строка,
                // то добавляем в список элементов '_items' имя и CLR-тип элемента AST.
                //
                if (child is XamlAstXamlPropertyValueNode propertyValueNode &&
                    propertyValueNode.Property is XamlAstNamePropertyReference named &&
                    named.Name == "Name" &&
                    propertyValueNode.Values.Count > 0 &&
                    propertyValueNode.Values[0] is XamlAstTextNode text)
                {
                    var nsType = $@"{clrType.Namespace}.{clrType.Name}";
                    var typeNamePair = (nsType, text.Text);
                    if (!_items.Contains(typeNamePair))
                        _items.Add(typeNamePair);
                }
            }

            return node;
        }

        return node;
    }

    public void Push(IXamlAstNode node) { }

    public void Pop() { }
}

Процесс парсинга XAML и извлечения типов и имён XAML-элементов теперь выглядит так:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());
MiniCompiler.CreateDefault(
    // 'compilation' имеет тип 'CSharpCompilation'
    new RoslynTypeSystem(compilation),
    "Avalonia.Metadata.XmlnsDefinitionAttribute")
    .Transform(parsed);

var visitor = new NameReceiver();
parsed.Root.Visit(visitor);
parsed.Root.VisitChildren(visitor);

// Теперь у нас есть и типы, и имена элементов.
var controls = visitor.Controls;

Генерируем типизированные ссылки


Наконец, можно перейти к заключительному этапу разработки нашего генератора исходного кода. У нас есть всё, что было нужно для полного счастья — и типы, и пространства имён, и имена элементов. А это значит, что нам необходимо сгенерировать partial-класс, сложив туда ссылки на все найденные именованные элементы пользовательского интерфейса, объявленные в XAML. Метод, генерирующий такой partial-класс, будет иметь вид:


private static string GenerateSourceCode(
    List<(string TypeName, string Name)> controls,
    INamedTypeSymbol classSymbol,
    AdditionalText xamlFile)
{
    var className = classSymbol.Name;
    var nameSpace = classSymbol.ContainingNamespace
        .ToDisplayString(SymbolDisplayFormat);
    var namedControls = controls
        .Select(info => "        " +
                       $"internal global::{info.TypeName} {info.Name} => " +
                       $"this.FindControl<global::{info.TypeName}>(\"{info.Name}\");");
    return $@"// <auto-generated />
using Avalonia.Controls;
namespace {nameSpace}
{{
    partial class {className}
    {{
{string.Join("\n", namedControls)}   
    }}
}}
";
}

Добавим полученный код в контекст выполнения GeneratorExecutionContext:


var sourceCode = GenerateSourceCode(controls, symbol, relevantXamlFile);
context.AddSource($"{symbol.Name}.g.cs", SourceText.From(sourceCode, Encoding.UTF8));

Готово!


Результат


Инструментарий Visual Studio понимает, что при изменении XAML-файла, включённого в проект как <AdditionalFile />, необходимо вызвать генератор исходного кода ещё раз, и обновить сгенерированные исходники. Таким образом, при редактировании XAML-файлов, ссылки на новые элементы управления, добавляемые в XAML в процессе разработки, будут автоматически становиться доступными из C#-файла с расширением .xaml.cs.


ezgif-1-f52e7303c26f


Интеграция генераторов исходного кода с JetBrains Rider и ReSharper доступна в последних EAP, что позволяет утверждать, что реализованная технология является кроссплатформенной, и будет работать на Windows, Linux, и macOS. В дальнейшем мы собираемся заинтегрировать получившийся генератор в Avalonia, чтобы в новых версиях фреймворка генерация типизированных ссылок стала доступна из коробки. А вот так выглядит обновлённый пример кода из самого начала статьи, с биндингами и ReactiveUI.Validation:



Исходный код и инструкции по установке генератора доступны на GitHub.


Ссылки