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

—

от автора

Я написал 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.5x += 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.5Assign (2, 3)-(2, 5): +=Output (3, 1)-(3, 4): >>>Operator (3, 7)-(3, 9): >=

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1087280/