
Я написал 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/