Всем привет! Хочу показать вам, как можно создавать собственный 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 и, конечно же, генерацию кода.
Статьи планирую выпускать раз в две недели. Оставайтесь на связи!