The Jerusalem PostNetanyahu received three warnings from Egypt about Gaza including in days before October 7 - reportESPNFollow live: Rams hold lead over scoreless Broncos in first halfESPN DeportesCosta Rica fracasa ante Haití y pone en riesgo Copa Oro y Liga APunchS’Court judgment: Parties insist candidates safeCNN TürkAKARYAKIT FİYATLARI ZAM GELDİ Mİ? 28 Eylül Benzin, Motorin, LPG'ye Zam Var Mı Yok Mu? Akaryakıta Ne Zaman Zam Gelecek? İstanbul, Ankara, İzmir Akaryakıt FiyatlarıInquirerMarcos greets longtime family lensman Tata Willy on 89th birthday한겨레세종대 탄수화물소재연구소, 제13회 정기 학술심포지엄 개최 “AI 기반 소재 설계부터 효소 공학·기능성 탄수화물 개발까지 융합 연구 성과 공유”BillboardLISA Pays Tribute to Thailand With High-Octane ‘SaWaDiKa’ Performance at VMAs 2026South China Morning PostChickens in Malaysia are producing fewer eggs. Is haze a leading cause?UOLRavens dominam Maracanã no terceiro jogo da NFL no Brasil경향신문입주 시작한 ‘모아주택 1호’ 찾은 오세훈 “2031년까지 4만가구 공급”Daily MailBBC faces backlash over story claiming viral bare fingernails trend is 'racist and classist'
The Daily Newsstand · Free, Always
Monday, September 28, 2026

Как я перенёс сборку лексера в compile-time

Translate

Я написал source generator для лексера HydraScript. Он собирал регулярное выражение из описаний токенов. После этого мне оставалось скопировать регулярку и руками вставить её в другой файл.

Даже тест на забытое копирование пришлось завести. Довольно много усилий, чтобы поддерживать одну строку в актуальном состоянии.

Проблема была в том, что результат моего генератора требовался другому генератору — тому, который стоит за атрибутом [GeneratedRegex] в .NET. Хотелось описать токен один раз и поручить остальное сборке. Для этого пришлось разобраться, в каком порядке Roslyn вообще выполняет генераторы.

Как устроен лексический анализ HydraScript

Мой лексер нарезает исходный код на токены с помощью одного регулярного выражения. Определения лежат в TokenTypes.Stream:

public record struct Dto(
    string Tag,
    string Pattern,
    int Priority,
    bool CanIgnore = false);

С большинством полей вопросов не возникает. А вот за Priority прячется возможность сломать язык довольно безобидным изменением. Возьмём такую программу:

let x = 12.5
x += 1
>>> x >= 13

Начало 12.5 подходит как под число с дробью, так и последовательность “целое-точка-целое”. Если раньше сработает соответствующая альтернатива, лексер заберёт 12 и оставит точку следующему токену. По той же причине += нужно сохранить целиком, а оператору вывода >>> дать шанс до того, как правило сравнения заберёт первый >.

Числовые литералы описаны так:

yield return new(
    Tag: "IntegerLiteral",
    Pattern: "[0-9]+",
    Priority: 3);

yield return new(
    Tag: "FloatLiteral",
    Pattern: "[0-9]+[.][0-9]+",
    Priority: 2);

Дробное число стоит позже в файле, но раньше в регулярке. Я храню этот порядок рядом с определениями, потому что это правило языка: паттерн [0-9]+ сам не догадается, что 12.5 нужно оставить в покое.

Здесь всё держится на порядке альтернатив регулярного выражения. Отдельного алгоритма maximal munch, который сравнивает всех кандидатов и выбирает самое длинное совпадение, нет. Добавляя пересекающиеся написания, нужно назначить им подходящие приоритеты.

Большое регулярное выражение

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

Основная часть сборки регулярного выражения находится в PatternGenerator и выглядит так:

var tokenTypes = Provider.TokenTypesStream
    .OrderBy(x => x.Priority)
    .Concat([new TokenTypes.Dto("ERROR", @"\S+", int.MaxValue)]);

var pattern = string.Join(
    "|",
    tokenTypes.Select(t => $"(?<{t.Tag}>{t.Pattern})"));

Для двух числовых правил получается:

(?<FloatLiteral>[0-9]+[.][0-9]+)|(?<IntegerLiteral>[0-9]+)

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

У разных альтернатив могут совпадать теги. И =, и += — присваивания, хотя приоритеты у них разные. Общий тег Assign этому не мешает: в значении токена остаётся конкретное написание из исходника.

Пока у меня получилась строка. Чтобы передать её в атрибут, нужна C#-константа, поэтому генератор выдаёт файл PatternContainer.g.cs с PatternContainer.Value внутри.

Одному генератору нужен результат другого

Константа используется в GeneratedRegexContainer:

using System.Text.RegularExpressions;
using HydraScript.Domain.FrontEnd.Lexer;

namespace HydraScript.Infrastructure;

public sealed partial class GeneratedRegexContainer : IGeneratedRegexContainer
{
    [GeneratedRegex(
        PatternContainer.Value,
        options: RegexOptions.Compiled | RegexOptions.ExplicitCapture)]
    public static partial Regex Regex { get; }
}

Вот на этом месте и возникло ручное копирование. Генераторы исходного кода не образуют очередь, в которой результат первого становится входом второго. Если выдать константу через обычный RegisterSourceOutput, другой генератор в том же запуске её не увидит.

Но есть post-initialization output. Эти исходники Roslyn добавляет в компиляцию до запуска обычных преобразований генераторов. Мою регулярку можно выдать в этой фазе:

context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
    "PatternContainer.g.cs",
    SourceText.From(code, Encoding.UTF8)));

Теперь генератор регулярных выражений может получить значение PatternContainer.Value, когда анализирует атрибут, и сгенерировать реализацию partial-свойства. Разницу между двумя фазами выдачи исходников можно посмотреть в документации Roslyn.

До того как я собрал этот Roslyn плагин код был ещё хуже. Тогда определения токенов ещё хранились в JSON и собирались в лексер через runtime. В статье я показываю их более позднее представление на C#, но способ связать генераторы остался тем же.

У ранней фазы есть ограничение: ей недоступна компиляция проекта-потребителя. Если бы паттерн зависел от классов или атрибутов, найденных в коде приложения, перенести эту работу в callback не получилось бы. Определения токенов должны быть доступны самому генератору.

Заодно замечу одну деталь в атрибуте: RegexOptions.Compiled остался в исходнике, так как даёт небольшой прирост производительности. Подробнее этот кейс разбирал Стивен Тоуб на github. У меня в hydrascript получилась такая фактура:

Method

Mean

Error

StdDev

Allocated

Compiled

275.9 us

5.27 us

12.21 us

-

Generated

158.8 us

1.72 us

1.52 us

-

GeneratedCompiled

155.5 us

2.31 us

2.05 us

-

Проект собирается, бенчмарки — нет

Сначала генератор получал определения токенов через ProjectReference на HydraScript.Domain.Constants. HydraScript собирался и работал. А потом я запустил BenchmarkDotNet, и сгенерированный им проект упал при сборке ещё до первого замера: CS8032, не удалось создать экземпляр PatternGenerator. Я пришёл с этой проблемой в репозиторий BenchmarkDotNet, потому что при обычной сборке тот же генератор работал.

Тим Касселл воспроизвёл ошибку вообще без BenchmarkDotNet:

dotnet build /p:UseSharedCompilation=false

Именно с этим параметром BenchmarkDotNet собирал свой сгенерированный проект. Отключения shared compilation оказалось достаточно, чтобы проявилась проблема в подключении зависимостей моего генератора.

Тим предложил исправление в проекте генератора: убрать ссылку на проект с константами, которую я пометил OutputItemType="Analyzer", и подключить его исходники через Compile. Поэтому теперь определения компилируются прямо в сборку генератора:

<PropertyGroup>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
    <Compile Include="..\..\Domain\HydraScript.Domain.Constants\*.cs"
             LinkBase="_Constants"/>
</ItemGroup>

Интерпретатор и генератор получают свою скомпилированную копию определений, а редактирую я их в одном месте. Загружать сборку интерпретатора или искать что-то в его синтаксическом дереве генератору не требуется. После изменения правила достаточно пересобрать генератор, чтобы оно попало в новую регулярку.

У такого подключения исходников есть цена: они должны собираться и под netstandard2.0, на который нацелен генератор. Для набора описаний токенов это приемлемое ограничение.

Infrastructure подключает проект как analyzer:

<ProjectReference
    Include="..\HydraScript.Infrastructure.LexerRegexGenerator\HydraScript.Infrastructure.LexerRegexGenerator.csproj"
    OutputItemType="Analyzer"
    ReferenceOutputAssembly="false"
    PrivateAssets="all" />

Собственный генератор реализует IIncrementalGenerator, хотя pipeline с syntax provider здесь не нужен. Он готовит исходник из определений, уже скомпилированных в его сборку, и регистрирует post-initialization output. Отслеживать изменения синтаксических узлов приложения ему незачем.

Лексеру незачем знать о генераторе

Исходный код HydraScript спроектирован для поддержки Чистой Архитектуры. Лексер находится в Domain, а контейнер сгенерированной регулярки — в Infrastructure. Разворачивать зависимость слоёв ради передачи регулярки мне не хочется.

Вот где пригодился статический абстрактный член интерфейса. Domain объявляет контракт:

using System.Text.RegularExpressions;

namespace HydraScript.Domain.FrontEnd.Lexer;

public interface IGeneratedRegexContainer
{
    public static abstract Regex Regex { get; }
}

А потребляется он через дженерики внутри Structure<TContainer> и его ограничение TContainer : IGeneratedRegexContainer. Регулярка извлекается через статический дженерик вызов TContainer.Regex. Конкретный тип подставляется в DI composition root:

services.AddSingleton<IStructure, Structure<GeneratedRegexContainer>>();
services.AddSingleton<ILexer, RegexLexer>();

Генерация кода остаётся делом Infrastructure. Domain достаточно самой регулярки, а ограничение обобщённого типа даёт доступ к статическому свойству без ссылки на класс, который его реализует.

Целиком схема выглядит так:

Сам скрипт по-прежнему проходит через RegexLexer.GetTokens во время выполнения. Поиск совпадений, пропуск комментариев, вычисление координат и создание токенов происходят тогда же. Как и построение frozen-таблицы метаданных токенов.

Исследование работы лексера

Давайте сохраним пример из начала статьи в файл lexer.js.

Тогда если хочется получить артефакт работы лексера HydraScript то можно выполнить прогон скрипта с флагом --dump:

hydrascript lexer.js --dump

Программа напечатает true. В появившемся lexer.tokens пересекающиеся написания окажутся разделены так, как нам нужно:

FloatLiteral (1, 9)-(1, 13): 12.5
Assign (2, 3)-(2, 5): +=
Output (3, 1)-(3, 4): >>>
Operator (3, 7)-(3, 9): >=

--dump заодно сохраняет рядом со скриптом дерево синтаксиса и промежуточные инструкции, если захочется отследить фазы работы интерпретатора.

Хотел я от всей этой затеи довольно скромного результата: поменять правило токена и не держать в голове, что нужно куда-то вставить строку. Чтобы убрать последний ручной шаг, пришлось разобраться, когда сгенерированный исходник становится виден компилятору. И теперь вы знаете, как автоматизировать свою работу через компилятор, не прибегая к ИИ агентам!

Ещё я веду Telegram канал StepOne, куда выкладываю много интересного контента о программировании на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT‑индустрии!

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.