Всем привет! Хочу показать вам, как можно создавать собственный DSL на C#.
Я планирую серию статей и собираюсь довести это дело до конца. Раз в неделю или полторы буду публиковать новую часть.

Вот план серии:

  1. Создаём синтаксис

  2. Пишем парсер

  3. Собираем blender

  4. Добавляем семантику

  5. Диагностика

  6. Интегрируем Language Server Protocol и делаем поддержку в Visual Studio

  7. Генерируем код

Да, вы всё правильно поняли - сейчас мы будем разбираться только с синтаксисом, поэтому настоятельно рекомендую перед чтением ознакомиться с моей предыдущей статьёй:

Красно-зелёные деревья: обзор

Что за DSL?

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

Мы делаем ещё одну UI‑библиотеку - обёртку над Avalonia. Я назвал её Akbura. Это название реки рядом с местом, где я живу. Очень уж хотелось назвать библиотеку в честь места, как когда‑то сделали Avalonia или Roslyn.

По сути, Akbura - это реактообразный Blazor, только для Avalonia и со своим DSL. Это не отдельный язык программирования, а расширение над C#. Поэтому нам придётся парсить сразу два уровня: собственно Akbura и встроенный в неё C#.

Вот как выглядит компонент на Akbura:

// Counter.akbura

state int count = 0;

<Stack w-full h-full items-center>
    <Text FontSize="24">Count: {count}</Text>
    <Button Click={count++}>Increment</Button>
</Stack>

Больше о синтаксисе можете узнать тут.

Урезаем лишнее

На этом этапе мы сознательно упрощаем задачу. У нас не будет полноценного SyntaxTree, пока мы не дойдём до части, связанной с диагностикой. Мы также ограничиваемся только кодировкой UTF‑16 LE: все остальные кодировки просто конвертируются в неё перед разбором.

Кроме того, мы не поддерживаем никаких директив и структурных trivia - пока что они нам не нужны и только усложнят процесс. Все лишнее убираем, чтобы сфокусироваться на главном: определении формы будущего синтаксиса.

Подготовка инфаструктуры

Зелёные ноды

Теперь перейдём к зелёной стороне - той, с которой удобно строить синтаксис (проще говоря, парсить), но не работать с ним напрямую. В Roslyn зелёные ноды используются именно как низкоуровневое, неизменяемое представление дерева.

В оригинальном коде Roslyn организация выглядит так:

XXXSyntax                 - красная нода
InternalSyntax.XXXSyntax  - зелёная нода

Лично мне такой подход не очень нравится: каждый раз писать InternalSyntax. - откровенно в падлу. Поэтому я выбрал альтернативный вариант:

XXXSyntax      - красная нода
GreenXXXSyntax - зелёная нода

Так автокомплит работает приятно и предсказуемо: начинаешь писать Green - и видишь все варианты зелёных нод.

Есть и другой интересный подход, который использует KirillOsenkov:

XXXSyntax       - красная нода
XXXSyntax.Green - зелёная нода

Красиво и элегантно, но лично мою хотелку - удобный автокомплит с GreenXXXSyntax - это всё равно не решает.

Какие зеленные классы нам нужны

Ну так вот, прежде чем работать напрямую с синтаксисом, нужно подготовить инфраструктуру и написать следующие классы:

  • GreenNode — базовый кирпичик.

  • GreenNodeCache — кеширование зелёных нод.

  • GreenSyntaxFactory — аналог SyntaxFactory, но для зелёных нод.

  • GreenSyntaxList — зелёный список; его содержимое может быть заинлайнено. Подробнее — тут.

  • GreenSyntaxVisitor — обычный визитор, обязательно partial.

  • GreenSyntaxRewriter — заменяет одни ноды на другие; наследуется от GreenSyntaxVisitor, также partial.

Токены

  • GreenSyntaxToken — токены, минимальные кирпичики (помимо trivia). Важно: только токены могут содержать trivia — остальные ноды лишь хранят в себе токены.

    • SyntaxTokenWithTrivia — токен с leading и trailing trivia.

    • SyntaxTokenWithValue — токен со значением (нужен для литералов).

    • SyntaxIdentifier — идентификатор. Его Kind фиксирован: IdentifierToken.

    • CSharpRawToken — «сырой» C#-токен для частей, которые нужно будет парсить как C#.

    • MissingTokenWithTrivia — заглушка (например, отсутствующий ;), которая затем превращается в диагностику.

Списки

  • GreenSyntaxList — структура-обёртка над GreenSyntaxList.

  • SeparatedGreenSyntaxList — структура, оборачивающая GreenSyntaxList<TNode>; считает каждый чётный элемент сепаратором (например, запятой).

Тривиа

Какие красные классы нам нужны

Теперь перейдём к красной стороне — тому уровню синтаксического дерева, где у нас уже есть позиции, родители, дети, и вообще вся структурная информация, которой зелёные ноды обладать не могут. Здесь мы оборачиваем зелёные структуры в удобные для навигации и анализа объекты.

Вот список ключевых классов:

  • AkburaSyntax — обёртка над зелёной синтаксической нодой: никаких токенов, никаких trivia, только синтаксические конструкции.

  • SyntaxList — обёртка над GreenSyntaxList, наследуется от AkburaSyntax.

  • SyntaxList — обёртка либо над SyntaxList, либо над AkburaSyntax?. Списки могут инлайниться, поэтому если элементов 0 — там null, если один — возвращается сам элемент, а если два и больше — создаётся полноценный список.

  • SyntaxToken — структурная обёртка над зелёными токенами.

  • SyntaxTokenList — структурная обёртка над GreenSyntaxList<GreenSyntaxToken>.

  • SyntaxTrivia — обёртка над GreenSyntaxTrivia.

  • SyntaxTriviaList — обёртка над GreenSyntaxList<GreenSyntaxTrivia>.

  • SyntaxVisitor — обычный визитор, обязательно partial.

  • SyntaxRewriter — наследуется от SyntaxVisitor, обязательно partial.

  • SyntaxReplacer — наследуется от SyntaxRewriter. Именно он отвечает за замену/удаление leading и trailing trivia. AkburaSyntax находит первый токен или последний токен и заменяет у него соответствующее trivia. Помним: только токены обладают trivia.

  • SyntaxNodeRemover — наследуется от SyntaxRewriter.

Поскольку AkburaSyntax представляет именно синтаксис (а не токены или trivia), нам нужны дополнительные структуры для смешанных контейнеров:

И наконец, в отличие от зелёной стороны, здесь нам доступны позиции, родители, дети — поэтому у нас появляется то, чего зелёные ноды себе позволить не могут:

  • SyntaxNavigator — удобный класс для навигации по красному дереву.

Пишем синтаксис

Написание синтаксиса — задача довольно муторная. Она требует большой монотонности, и, к счастью, в эпоху ИИ это можно хотя бы немного оптимизировать. Спойлер: он тупил, местами очень, но всё равно получилось быстрее, чем если бы я писал всё вручную.

В Roslyn и Razor для этих задач используется генератор. Тем временем KirillOsenkov, судя по всему, писал всё вручную, как настоящий гигачад — по крайней мере, я не нашёл там никакого генератора.

А в чём монотонность?

Для начала давай создадим простую ноду:

/// <summary>
/// C# block enclosed in braces: { ... }.
/// </summary>
node CSharpBlockSyntax {
    OpenBrace  : OpenBraceToken;
    Tokens     : syntaxlist<AkTopLevelMemberSyntax>;
    CloseBrace : CloseBraceToken;
}

Сначала нужно сгенерировать SyntaxKind:

public enum SyntaxKind
{
    //...
    CSharpBlockSyntax = 503,
    //...
}

Далее нужно создать зелёное представление этого же CSharpBlockSyntax:

internal sealed partial class GreenCSharpBlockSyntax : global::Akbura.Language.Syntax.Green.GreenNode
{
    public readonly global::Akbura.Language.Syntax.Green.GreenSyntaxToken OpenBrace;
    public readonly global::Akbura.Language.Syntax.Green.GreenNode? _tokens;
    public readonly global::Akbura.Language.Syntax.Green.GreenSyntaxToken CloseBrace;

    public GreenCSharpBlockSyntax(
        global::Akbura.Language.Syntax.Green.GreenSyntaxToken openBrace,
        global::Akbura.Language.Syntax.Green.GreenNode? tokens,
        global::Akbura.Language.Syntax.Green.GreenSyntaxToken closeBrace,
        ImmutableArray<global::Akbura.Language.Syntax.AkburaDiagnostic>? diagnostics,
        ImmutableArray<global::Akbura.Language.Syntax.AkburaSyntaxAnnotation>? annotations)
        : base((ushort)global::Akbura.Language.Syntax.SyntaxKind.CSharpBlockSyntax, diagnostics, annotations)
    {
        this.OpenBrace = openBrace;
        this._tokens = tokens;
        this.CloseBrace = closeBrace;

        AkburaDebug.Assert(this.OpenBrace != null);
        AkburaDebug.Assert(this.CloseBrace != null);

        AkburaDebug.Assert(this.OpenBrace.Kind == global::Akbura.Language.Syntax.SyntaxKind.OpenBraceToken);
        AkburaDebug.Assert(this.CloseBrace.Kind == global::Akbura.Language.Syntax.SyntaxKind.CloseBraceToken);

        var flags = Flags;
        var fullWidth = FullWidth;

        AdjustWidthAndFlags(OpenBrace, ref fullWidth, ref flags);

        if (_tokens != null)
        {
            AdjustWidthAndFlags(_tokens, ref fullWidth, ref flags);
        }

        AdjustWidthAndFlags(CloseBrace, ref fullWidth, ref flags);

        SlotCount = 3;
        FullWidth = fullWidth;
        Flags = flags;
    }
}

Затем нужно создать WithXXX и Update-методы:

public GreenCSharpBlockSyntax WithOpenBrace(global::Akbura.Language.Syntax.Green.GreenSyntaxToken openBrace)
{
    return UpdateCSharpBlockSyntax(openBrace, this._tokens, this.CloseBrace);
}

public GreenCSharpBlockSyntax WithTokens(global::Akbura.Language.Syntax.Green.GreenSyntaxList<GreenAkTopLevelMemberSyntax> tokens)
{
    return UpdateCSharpBlockSyntax(this.OpenBrace, tokens.Node, this.CloseBrace);
}

public GreenCSharpBlockSyntax WithCloseBrace(global::Akbura.Language.Syntax.Green.GreenSyntaxToken closeBrace)
{
    return UpdateCSharpBlockSyntax(this.OpenBrace, this._tokens, closeBrace);
}

public GreenCSharpBlockSyntax UpdateCSharpBlockSyntax(
    global::Akbura.Language.Syntax.Green.GreenSyntaxToken openBrace,
    global::Akbura.Language.Syntax.Green.GreenNode? tokens,
    global::Akbura.Language.Syntax.Green.GreenSyntaxToken closeBrace)
{
    if (this.OpenBrace == openBrace &&
        this._tokens == tokens &&
        this.CloseBrace == closeBrace)
    {
        return this;
    }

    var newNode = GreenSyntaxFactory.CSharpBlockSyntax(
        openBrace,
        tokens.ToGreenList<GreenAkTopLevelMemberSyntax>(),
        closeBrace);

    var diagnostics = GetDiagnostics();
    if (!diagnostics.IsDefaultOrEmpty)
    {
        newNode = Unsafe.As<GreenCSharpBlockSyntax>(newNode.WithDiagnostics(diagnostics));
    }

    var annotations = GetAnnotations();
    if (!annotations.IsDefaultOrEmpty)
    {
        newNode = Unsafe.As<GreenCSharpBlockSyntax>(newNode.WithAnnotations(annotations));
    }

    return newNode;
}

Потом надо переопределить CreateRed, WithDiagnostics, WithAnnotations и GetSlot:

public override global::Akbura.Language.Syntax.Green.GreenNode? GetSlot(int index)
{
    return index switch
    {
        0 => OpenBrace,
        1 => _tokens,
        2 => CloseBrace,
        _ => null,
    };
}

public override global::Akbura.Language.Syntax.AkburaSyntax CreateRed(global::Akbura.Language.Syntax.AkburaSyntax? parent, int position)
{
    return new global::Akbura.Language.Syntax.CSharpBlockSyntax(this, parent, position);
}

public override global::Akbura.Language.Syntax.Green.GreenNode WithDiagnostics(ImmutableArray<global::Akbura.Language.Syntax.AkburaDiagnostic>? diagnostics)
{
    return new GreenCSharpBlockSyntax(this.OpenBrace, this._tokens, this.CloseBrace, diagnostics, GetAnnotations());
}

public override global::Akbura.Language.Syntax.Green.GreenNode WithAnnotations(ImmutableArray<global::Akbura.Language.Syntax.AkburaSyntaxAnnotation>? annotations)
{
    return new GreenCSharpBlockSyntax(this.OpenBrace, this._tokens, this.CloseBrace, GetDiagnostics(), annotations);
}

Ну и, конечно, Accept для визиторов/реплейсеров/ремуверов:

public override void Accept(GreenSyntaxVisitor greenSyntaxVisitor)
{
    greenSyntaxVisitor.VisitCSharpBlockSyntax(this);
}

public override TResult? Accept<TResult>(GreenSyntaxVisitor<TResult> greenSyntaxVisitor) where TResult : default
{
    return greenSyntaxVisitor.VisitCSharpBlockSyntax(this);
}

public override TResult? Accept<TParameter, TResult>(GreenSyntaxVisitor<TParameter, TResult> greenSyntaxVisitor, TParameter argument) where TResult : default
{
    return greenSyntaxVisitor.VisitCSharpBlockSyntax(this, argument);
}

Так как у нас есть фабрика, её тоже нужно расширить, поэтому её обязательно делаем partial:

internal static partial class GreenSyntaxFactory
{
    public static GreenCSharpBlockSyntax CSharpBlockSyntax(
        global::Akbura.Language.Syntax.Green.GreenSyntaxToken openBrace,
        global::Akbura.Language.Syntax.Green.GreenSyntaxList<GreenAkTopLevelMemberSyntax> tokens,
        global::Akbura.Language.Syntax.Green.GreenSyntaxToken closeBrace)
    {
        AkburaDebug.Assert(openBrace != null);
        AkburaDebug.Assert(closeBrace != null);

        AkburaDebug.Assert(openBrace!.Kind == global::Akbura.Language.Syntax.SyntaxKind.OpenBraceToken);
        AkburaDebug.Assert(closeBrace!.Kind == global::Akbura.Language.Syntax.SyntaxKind.CloseBraceToken);

        var kind = global::Akbura.Language.Syntax.SyntaxKind.CSharpBlockSyntax;
        int hash;
        var cache = Unsafe.As<GreenCSharpBlockSyntax?>(
            GreenNodeCache.TryGetNode(
                (ushort)kind,
                openBrace,
                tokens.Node,
                closeBrace,
                out hash));

        if (cache != null)
        {
            return cache;
        }

        var result = new GreenCSharpBlockSyntax(
            openBrace,
            tokens.Node,
            closeBrace,
            diagnostics: null,
            annotations: null);

        if (hash > 0)
        {
            GreenNodeCache.AddNode(result, hash);
        }

        return result;
    }
}

Не забываем и про визиторы:

internal partial class GreenSyntaxVisitor
{
    public virtual void VisitCSharpBlockSyntax(GreenCSharpBlockSyntax node)
    {
        DefaultVisit(node);
    }
}

internal partial class GreenSyntaxVisitor<TResult>
{
    public virtual TResult? VisitCSharpBlockSyntax(GreenCSharpBlockSyntax node)
    {
        return DefaultVisit(node);
    }
}

internal partial class GreenSyntaxVisitor<TParameter, TResult>
{
    public virtual TResult? VisitCSharpBlockSyntax(GreenCSharpBlockSyntax node, TParameter argument)
    {
        return DefaultVisit(node, argument);
    }
}

Ну и наконец, Rewriter:

internal partial class GreenSyntaxRewriter
{
    public override GreenNode? VisitCSharpBlockSyntax(GreenCSharpBlockSyntax node)
    {
        return node.UpdateCSharpBlockSyntax(
            (GreenSyntaxToken)VisitToken(node.OpenBrace),
            VisitList(node.Tokens).Node,
            (GreenSyntaxToken)VisitToken(node.CloseBrace));
    }
}

И это всё — только для зелёной стороны!

Автоматизация

Для автоматизации я использовал небольшой псевдоязык — Nooken. Его идея проста: я описываю ИИ правила языка Nooken, затем отдаю ему файл с декларациями нод — а он на основе этих данных генерирует все необходимые классы.

Фактически Nooken — это промежуточный слой между «человеческим» описанием синтаксиса и большим количеством однообразного, но строгого кода, который должен быть сгенерирован.

Вот сокращённый промпт, который я использовал:

Ты — генератор кода для синтаксического фреймворка Roslyn-подобного типа **Akbura**.
Вход:

1. Полная грамматика Nooken.
2. Одно объявление узла в {{iteration.rawValue}}.

Задача — сгенерировать **один .g.cs файл**, содержащий:

* Зелёный узел (GREEN)
* Красный узел (RED)
* GreenSyntaxFactory helper
* SyntaxFactory helper
* Методы Visitor и Rewriter (только для конкретных узлов)

Правила:

* Имена: GreenXxxSyntax / XxxSyntax.
* Конструктор GREEN: `: base((ushort)SyntaxKind.<NodeName>, diagnostics, annotations)`.
* Абстрактные узлы: без Visitors/Rewriters и без WithDiagnostics/WithAnnotations; только базовая логика.
* Конкретные узлы: GREEN неизменяемый; RED с публичными свойствами, Update/With методами, ThrowHelper проверками.
* GREEN: корректное AdjustWidthAndFlags, реализация GetSlot, WithDiagnostics, WithAnnotations, CreateRed.
* RED: свойства на основе GetRed, методы Accept.
* Списки: GREEN хранит только GreenNode?; не хранить GreenSyntaxList напрямую. RED не кеширует SyntaxList/SyntaxTokenList.
* TokenList = syntaxlist<token>; пустой список = null.
* Использовать ToGreenList<T>() для нормализации списков.
* Не вводить новые типы.
* Всегда генерировать WithLeadingTrivia / WithTrailingTrivia для RED.
* Посетители генерируются только для конкретных узлов.
* GreenNodeCache использовать только если SlotCount ≤ 3.
* Проверять виды токенов, ThrowHelper при ошибках.
* CreateRed: `new XxxSyntax(this, parent, position)`.
* Вывод — только корректный C# для одного файла.

Если интересно — вот полный вариант промпта в репозитории: SystemNodeGenerationPrompt.md.

В итоге получается, что скучная монотонная работа полностью перекладывается на ИИ: генерация зелёных нод, красных нод, фабрик, посетителей, переписчиков и всей связующей инфраструктуры. А человеку остаётся только описать структуру узла — всё остальное собирается автоматически.

Если интересно вот сам пайтон код:

import re

NODE_SYSTEM_PROMPT = (NOTEBOOK_DIR / "SystemNodeGenerationPrompt.md").read_text(encoding="utf-8")
def generate_node(full_grammar: str, node: str) -> str:
    user_content = f"""Full Nooken grammar:
nooken:
{full_grammar}

Node to generate:
{node}
"""
    response = client.chat.completions.create(
        model="gpt-5.1",
        temperature=0.0,
        messages=[
            {"role": "system", "content": NODE_SYSTEM_PROMPT},
            {"role": "user", "content": user_content},
        ],
    )

    return response.choices[0].message.content

def generate_file_name(node_raw: str) -> str:
    # 1. Regex: extract the word after "node" or "abstract node"
    match = re.search(
        r"(?:abstract\s+node|node)\s+([A-Za-z_][A-Za-z0-9_]*)",
        node_raw
    )

    if not match:
        raise ValueError(f"Cannot parse node name from Nooken text:\n{node_raw}")

    node_name = match.group(1)

    # 2. Always append "Syntax" suffix for generated C# files
    if not node_name.endswith("Syntax"):
        node_name = f"{node_name}Syntax"

    # 3. Return final file name
    return f"{node_name}.g.cs"

node_index = 19

try:
    node_index
    print(f"Using existing node_index = {node_index}")
except NameError:
    node_index = 0
    print(f"Created new node_index = {node_index}")

if(node_index == -1):
    node_index = 0

current_node = None

if(node_index < len(nodes)):
    current_node = nodes[node_index]["rawValue"]
    node_index += 1
else:
    node_index = -1

if(node_index != -1):
    generated_node_cs = generate_node(NOOKEN_GRAMMAR, current_node)
    generated_node_path = NOTEBOOK_DIR / generate_file_name(current_node)
    generated_node_path.write_text(generated_node_cs, encoding="utf-8")
    print(generated_node_path)

Я использовал Jupyter Notebook (.ipynb), и чтобы случайно не засветить API‑ключи, просто не включал ноутбук в репозиторий — это оказалось проще и безопаснее всего.

Финальный тест

После того как ИИ сгенерировал код, осталось убедиться, что всё действительно работает. Очевидные ошибки я исправлял сразу, но некоторые мелочи всё же пролетели мимо глаз, поэтому первая прогонка тестов показала несколько падений. К счастью, все они исправлялись довольно быстро.

Вот пример интеграционного теста, который проверяет генерацию простого Akbura-документа:

[Fact]
public void Build_Syntax_For_ButtonClick()
{
    // state count = 0;

    var stateKeyword = TokenWithTrailingSpace(SyntaxKind.StateKeyword);
    var identifierCount = IdentifierWithTrailingSpace("count");
    var equalsToken = TokenWithTrailingSpace(SyntaxKind.EqualsToken);

    var tokens = TokenList(
        NumericLiteralToken("0", 0)
    );

    var zeroExpression = CSharpExpressionSyntax(tokens);

    var stateDeclaration = StateDeclarationSyntax(
        stateKeyword: stateKeyword,
        type: null,
        name: IdentifierName(identifierCount),
        equalsToken: equalsToken,
        initializer: SimpleStateInitializerSyntax(zeroExpression),
        semicolon: Token(SyntaxKind.SemicolonToken)
    );

    var stateDeclarationText = stateDeclaration.ToFullString();
    const string expectedStateDeclarationText = "state count = 0;";
    Assert.Equal(expectedStateDeclarationText, stateDeclarationText);

    // <Button Click={count++}>

    var lessToken = Token(SyntaxKind.LessThanToken);
    var greaterToken = Token(SyntaxKind.GreaterThanToken);
    var lessSlashToken = Token(SyntaxKind.LessSlashToken);

    var buttonName = IdentifierName("Button");

    var countIncrementTokens = TokenList(
        CSharpRawToken("count++")
    );

    var clickInlineExpression = InlineExpressionSyntax(
        openBrace: Token(SyntaxKind.OpenBraceToken),
        expression: CSharpExpressionSyntax(countIncrementTokens),
        closeBrace: Token(SyntaxKind.CloseBraceToken)
    );

    var clickAttribute = MarkupPlainAttributeSyntax(
        name: IdentifierName("Click"),
        equalsToken: Token(SyntaxKind.EqualsToken),
        value: MarkupDynamicAttributeValueSyntax(
            prefix: null,
            expression: clickInlineExpression
        )
    );

    var startTag = MarkupStartTagSyntax(
        lessToken,
        buttonName.WithTrailingTrivia(new(Space)),
        SingletonList<MarkupAttributeSyntax>(clickAttribute),
        greaterToken.WithTrailingTrivia(
            TriviaList(LineFeed)
        )
    );

    // BODY: {count}

    var bodyExpressionTokens = TokenList(
        Identifier("count")
    );

    var bodyInlineExpression = MarkupInlineExpressionSyntax(
        InlineExpressionSyntax(
            openBrace: Token(SyntaxKind.OpenBraceToken)
                .WithLeadingTrivia(Whitespace("    ")),
            expression: CSharpExpressionSyntax(bodyExpressionTokens),
            closeBrace: Token(SyntaxKind.CloseBraceToken)
        )
    ).WithTrailingTrivia(TriviaList(LineFeed));

    var endTag = MarkupEndTagSyntax(
        lessSlashToken,
        buttonName,
        greaterToken
    );

    var buttonElement = MarkupElementSyntax(
        startTag,
        SingletonList<MarkupContentSyntax>(bodyInlineExpression),
        endTag
    );

    var buttonText = buttonElement.ToFullString();

    const string expectedButtonText =
        "<Button Click={count++}>\n" +
        "    {count}\n" +
        "</Button>";

    Assert.Equal(expectedButtonText, buttonText);

    // Compose full document

    var stateWithBlankLine = stateDeclaration.WithTrailingTrivia(
        TriviaList(LineFeed, LineFeed)
    );

    var markupRoot = MarkupRootSyntax(buttonElement);

    var document = AkburaDocumentSyntax(
        members: List<AkTopLevelMemberSyntax>(
            [
                stateWithBlankLine,
                markupRoot
            ]
        ),
        endOfFile: EndOfFileToken()
    );

    var documentText = document.ToFullString();

    const string expectedDocumentText =
        "state count = 0;\n" +
        "\n" +
        "<Button Click={count++}>\n" +
        "    {count}\n" +
        "</Button>";

    Assert.Equal(expectedDocumentText, documentText);
}

И всё работает. Тесты проходят — а значит, генерация и синтаксическая модель корректны.

Что дальше?

А дальше — самое интересное. В следующей части мы займёмся созданием парсера, отдельной системой кеширования для идентификаторов, а также построим детерминированный конечный автомат (DFA).

Увы и ах, но там ИИ уже не справится: придётся писать всё вручную, потому что парсер — это место, где важна точность, контроль и аккуратная работа с контекстом.

Заключение

Надеюсь, эта статья окажется полезной тем, кто хочет создать собственный DSL. На самом деле уже одной этой части достаточно, чтобы начать проектировать язык: имея полноценное синтаксическое дерево, можно писать парсеры, интерпретаторы и любые другие инструменты. Не всем нужен полный IntelliSense, LSP или сложная инфраструктура.

Однако если вам интересно посмотреть, как буду развивать язык я, — добро пожаловать в следующие части. Мы перейдём от формы к содержанию: разберём парсер, построим blender, добавим семантику, диагностики, интеграцию с LSP и, конечно же, генерацию кода.

Статьи планирую выпускать раз в две недели. Оставайтесь на связи!

Комментарии (0)