Всем привет! Хочу показать вам, как можно создавать собственный 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 и, конечно же, генерацию кода.
Статьи планирую выпускать раз в две недели. Оставайтесь на связи!
DoctorKrolic
Ну что ж, раз вы взялись за рослиновскую модель синтакса, то в следующий части обязательно ждём реализацию инкрементального парсера. Это тот, который может взять предыдущее дерево + новые текстовые изменения и эффективно сконструировать новое дерево, переиспользуя большую часть узлов из старого. Иначе модель краснозелёного дерева теряет всякий смысл
Popou Автор
Вроде по плану это третья часть как раз таки, blender. Если ничего не путаю
Popou Автор
И да, спасибо за ваш конструктивный коментарий.