Всем привет! Хочу показать вам, как можно создавать собственный DSL на C#.
Я планирую серию статей и собираюсь довести это дело до конца. Раз в неделю или полторы буду публиковать новую часть.
Вот план серии:
Создаём синтаксис
Пишем парсер
Собираем blender
Добавляем семантику
Диагностика
Интегрируем Language Server Protocol и делаем поддержку в Visual Studio
Генерируем код
Да, вы всё правильно поняли - сейчас мы будем разбираться только с синтаксисом, поэтому настоятельно рекомендую перед чтением ознакомиться с моей предыдущей статьёй:
Что за 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>; считает каждый чётный элемент сепаратором (например, запятой).
Тривиа
GreenSyntaxTrivia - вот и тривиа
Какие красные классы нам нужны
Теперь перейдём к красной стороне — тому уровню синтаксического дерева, где у нас уже есть позиции, родители, дети, и вообще вся структурная информация, которой зелёные ноды обладать не могут. Здесь мы оборачиваем зелёные структуры в удобные для навигации и анализа объекты.
Вот список ключевых классов:
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), нам нужны дополнительные структуры для смешанных контейнеров:
SyntaxNodeOrToken — думаю, по названию всё понятно.
SyntaxNodeOrTokenList — список
SyntaxNodeOrToken.
И наконец, в отличие от зелёной стороны, здесь нам доступны позиции, родители, дети — поэтому у нас появляется то, чего зелёные ноды себе позволить не могут:
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 и, конечно же, генерацию кода.
Статьи планирую выпускать раз в две недели. Оставайтесь на связи!
