Всем привет!

  1. Создаём синтаксис

  2. Пишем парсер

  3. Собираем блендер

  4. Добавляем семантику

  5. Диагностика

  6. Интегрируем Language Server Protocol и делаем поддержку в Visual Studio

  7. Генерируем код

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

В этой части разберём последний участок пути: как Akbura превращается в C#, почему генератор называется BlackSilence, как он использует старые результаты и обновляет интерфейс во время Hot Reload.

Почему я выбрал генерацию C#

У Akbura получилась такая цепочка:

После semantic model можно сразу переходить к IL. В случае Akbura при этом нужно учитывать встроенный C#: expressions, локальные переменные, методы, лямбды и async/await.

Если генерировать IL самостоятельно, придётся либо отдельно компилировать эти фрагменты, либо воспроизводить существенную часть работы C# compiler. Особенно весело станет на замыканиях и async state machines. Свой язык уже есть, второй компилятор мне пока не нужен.

Поэтому Akbura выдаёт обычный C#. Локальные переменные сохраняют свою область видимости, локальные функции и лямбды получают C#-семантику замыканий. Roslyn занимается типами, lowering и итоговым IL. Задача генератора здесь состоит в правильном размещении пользовательского кода в render scope.

Это ещё и удобный промежуточный результат. Generated source можно открыть и понять, какое присваивание пропало или почему получился неправильный binding. Через #line пользовательские фрагменты связываются с исходным .akbura, чтобы ошибки и отладка не ограничивались огромным .g.cs.

У WPF markup compiler создаёт BAML и вспомогательный код partial-класса, включая InitializeComponent. При загрузке используется скомпилированное представление разметки. Описание сборки WPF.

У стандартного XamlIl pipeline Avalonia разметка компилируется в IL через XamlX; build task работает со сборкой через Cecil. XamlCompilerTaskExecutor.

Для Akbura C# backend удобен прежде всего поддержкой встроенного C# и привычными средствами отладки. Его стоимость складывается из объёма generated source и затрат на компиляцию. Скорость разных backend-ов нужно сравнивать на конкретных сценариях.

Razor, Roslyn и знакомое слово internal

По общей идее Akbura здесь ближе к Razor: смешанный документ превращается в C#, который дальше обрабатывает Roslyn.

Исходники Razor сейчас находятся в репозитории Roslyn. Для подключения своего DSL доступен публичный механизм source generators, а доступность вспомогательных API зависит от конкретного типа или метода.

Например, Razor CodeWriter объявлен как public. Его CodeWriterExtensions имеют доступность internal. Есть и внутренние workspace primitives вроде Solution.WithFrozenSourceGeneratedDocument, о котором я писал в прошлой части.

Для использования таких внутренних решений приходится изучать исходники и адаптировать нужные части под свой pipeline. Именно так появились CodeWriter и его расширения в Akbura; ссылки на оригинал и MIT license сохранены в файлах.

CodeWriter: сначала кусочки, потом SourceText

Для генерации текста часто используют StringBuilder. В Razor я нашёл другой подход: сохранять представления над фрагментами текста до сборки итогового результата.

Мой CodeWriter хранит страницы с элементами ReadOnlyMemory<char>:

LinkedList<ReadOnlyMemory<char>[]> _pages;

Вызов Write("return ") сохраняет представление над строкой. Для записи части строки используется slice. При повторной записи одной строки writer хранит несколько представлений над тем же набором символов.

Страницы арендуются через ArrayPool<ReadOnlyMemory<char>>. Минимальный запрашиваемый размер составляет 1000 фрагментов текста. Пул может вернуть массив большего размера, поэтому граница страницы определяется реальной длиной массива.

В конце GetText() создаёт reader над страницами и передаёт его в SourceText.From. Так фрагменты поступают в SourceText напрямую, без предварительной сборки общей строки через StringBuilder.ToString().

Аллокации остаются: страницы, служебные объекты и материализация итогового SourceText. Такой подход сокращает промежуточное копирование и количество временных строк.

У writer-а есть ещё несколько обязанностей: отступы, переносы строк и координаты generated source. Он учитывает даже \r и \n, записанные двумя отдельными вызовами. Иначе source mapping очень быстро начнёт показывать не туда.

После работы writer нужно освободить:

using var writer = new CodeWriter();
writer.WriteLine("return null;");
var source = writer.GetText();

Dispose возвращает страницы с clearArray: true: ссылки на исходные строки не должны оставаться в пуле и удерживать их в памяти.

Как writer превращает числа в текст

В generated code постоянно встречаются номера элементов, индексы и размеры массивов. Вызов id.ToString() для их записи может создавать строку при каждом форматировании.

В CodeWriterExtensions используется таблица представлений чисел от 0 до 999. Это ещё одно решение, адаптированное из Razor.

При инициализации создаются строки для чисел от 100 до 999 с InvariantCulture. Остальные значения получают slices этих строк: например, 42 берётся из "142", однозначные числа получаются из двухзначных представлений. Для нуля используется заранее подготовленная строка нулей.

Маленькое число записывается одним обращением к таблице. Большое разбивается на группы по три цифры:

Первая группа идёт без дополнения. Следующие дополняются слева нулями до трёх символов. Полностью нулевая группа записывается как "000".

Минус пишется отдельно. Перед отрицанием значение расширяется до long, чтобы корректно обработать int.MinValue:

var remaining = isNegative ? -(long)value : value;

После инициализации таблицы WriteIntegerLiteral(int) не создаёт новую строку форматирования для каждого значения:

writer.Write("__element").WriteIntegerLiteral(id);

Метод рассчитан на int. В interpolated string handler generic fallback может вызвать ToString(), поэтому для записи таких индексов WriteIntegerLiteral используется явно.

Инкрементальная генерация в BlackSilence

Сначала генератор был экспериментальным и назывался Furioso. Теперь он остался архивной реализацией, а основной pipeline работает через AkburaBlackSilenceGenerator.

Традиция названий из Project Moon сохранилась. Сам pipeline за это время тоже заметно изменился.

BlackSilence реализует IIncrementalGenerator. В Initialize строится граф: AdditionalTextsProvider даёт .akbura и .akcss, дальше идут текст, syntax tree, версия документа, окружение C# и generation request. Результаты разделяются на компоненты, внешние и inline AKCSS-модули, а также общие исходники проекта.

Roslyn умеет сохранять значения узлов графа между запусками generator driver. Эффективность reuse зависит от структуры графа. Например, объединение всех файлов через Collect с новой Compilation может снова запустить обработку всего проекта. Поэтому важно определить зависимости стадий и правила сравнения их входов.

Поэтому в BlackSilence есть собственные comparers, dependency analysis и предыдущий project snapshot. При доказанной совместимости берутся готовые результаты чистых документов. Semantic model не создаётся для неизменённого файла только ради того, чтобы положить его обратно в кэш.

Изменённые документы сначала связываются в стабильном порядке: иначе параллельная первая инициализация может несколько раз построить одни и те же зависимости. После этого writers могут работать параллельно. Для одного-двух dirty documents отдельная очередь ThreadPool вообще не создаётся. BlackSilenceDocumentBatch.

При проверке совместимости учитываются изменения зависимостей: overload, тип свойства или AKCSS utility могли поменяться в другом файле. Поэтому C#-окружение входит в условия reuse даже для документов с прежним текстом.

Повторное использование старой compilation допустимо, когда comparer признал окружения эквивалентными. Кэши живут в процессе; после его перезапуска результаты строятся заново.

Diagnostics тоже имеют отдельный путь публикации. Изменение политики их отображения не должно без необходимости инвалидировать генерацию исходников, а удалённый документ не должен оставлять старые ошибки.

Где здесь incremental parser

Нужно разделить две вещи: пропуск неизменившихся стадий генератора и переиспользование частей синтаксического дерева.

Сам parser находится в языковом слое. BlackSilence только даёт ему старое дерево через IncrementalParseCache.

Если текст совпал через ContentEquals, возвращается прежнее дерево. При изменении текста вызывается WithChangedText. Дальше работают знакомые по третьей части TextChangeRange и Blender: безопасные участки старого дерева смешиваются со свежими токенами.

Если в предыдущем корне есть diagnostics или skipped text, BlackSilence полностью разбирает этот документ. Recovery мог оставить контекст за пределами изменённого участка. Полный parse в таком случае помогает получить корректное дерево после исправления текста.

Степень reuse зависит от диапазонов изменения. Если новый SourceText сообщает замену всего файла, parser обрабатывает соответствующий диапазон целиком. Для точечного переразбора нужны точные TextChangeRange.

Кэш устроен намеренно просто: 32 слота для обычных деревьев и 4 для больших. Граница большого файла составляет 128 * 1024 единиц UTF-16.

Ключ включает вид документа, путь и logical name. FNV-1a hash выбирает слот, затем сравнивается весь ключ. Содержимое файла в hash не входит: lookup должен находить предыдущую версию этого файла после изменения текста.

При коллизии запись вытесняется, и следующий запрос делает полный parse. Если файл пересёк границу размера, lookup проверит и другую группу слотов.

Чтение и публикация используют Volatile. Для удаления старой записи применяется Interlocked.CompareExchange. Деревья не мутируются и не возвращаются в object pool, пока ими могут пользоваться другие стадии.

Получился небольшой lossy cache с ограниченным числом удерживаемых деревьев. Его размер в байтах зависит от содержимого этих деревьев.

FirstUpdate и Update

Теперь вернёмся из compiler process в приложение.

У компонента есть две разные задачи: создать интерфейс и обновлять его после изменения state или параметров. Для простого компонента в прямом режиме это разделено между FirstUpdate и Update.

FirstUpdate создаёт контролы, собирает связи между ними и применяет начальные операции: константные значения, подписки, bindings. Update выполняет render-код и обновляет значения, зависящие от текущего состояния.

Упрощённая иллюстрация работы двух методов:

private TextBlock _text = null!;

protected override Control FirstUpdate()
{
    _text = new TextBlock { TextWrapping = TextWrapping.Wrap };
    return _text;
}

protected override Control Update()
{
    _text.Text = count.ToString();
    return _text;
}

При первом успешном render выполняются обе стадии. Следующие изменения count вызывают Update, который записывает значение в уже созданный TextBlock. Обновление запрашивается через invalidation.

Для $if модель сложнее: создание ветки зависит от локального render scope. Такие компоненты используют single-pass render, и runtime не вызывает FirstUpdate отдельным предварительным проходом. Иначе можно было бы дважды выполнить пользовательский код или потерять его локальные переменные.

В Debug добавляется структурный runtime для Hot Reload. В Release простой компонент получает прямой код; $if и $foreach сохраняют необходимую структурную поддержку. В обоих режимах используется общий generation plan. ComponentLifecycleWriter, режимы генерации.

Как работает Hot Reload

Здесь идею я подсмотрел у Blazor.

У него HotReloadManager зарегистрирован через MetadataUpdateHandlerAttribute и сообщает о применении delta. Renderer реагирует на это, очищает связанные кэши и запускает обновление компонентов через dispatcher.

В Akbura я использовал этот принцип: после обновления кода средствами .NET runtime приводит живой интерфейс Avalonia в соответствие с новой версией компонента.

BlackSilence генерирует DEBUG-handler с ClearCache и UpdateApplication. Такие точки расширения предусмотрены .NET Hot Reload. Сам ClearCache в текущем generated handler пустой; подготовка Akbura выполняется в пути обновления приложения.

Handler определяет затронутые компоненты, учитывая связанные AKCSS-модули. Runtime находит живые экземпляры через registry со слабыми ссылками. Компонент не должен удерживаться в памяти только потому, что когда-то участвовал в Hot Reload.

Перед обновлением согласуются generated descriptors и кэши, подготавливаются hooks и подписки, затем запрашивается render. Для временно отсоединённого компонента сохраняется revision: он догонит актуальную версию при следующем подключении. AkburaHotReloadRuntime.

Здесь нужно учитывать операции инициализации. Например, после замены Text="Hello" на Text="Hello Hot Reload" существующий контрол всё ещё хранит старую константу. Для обновления текста нужно повторить соответствующее начальное присваивание.

Поэтому при новой render revision начальные значения могут повторно применяться к существующим элементам. Это происходит при смене определения или создании нового элемента. Обычные изменения state обходятся без повторной инициализации констант.

Для структурных правок есть AkburaRenderState. Он сопоставляет старые и новые элементы внутри родителя и content slot: по явному ключу, синтаксической идентичности, затем совместимому типу. Подходящие экземпляры сохраняются, отсутствующие создаются, удалённые операции и подписки освобождаются.

Так сохраняется состояние совместимых контролов при локальных правках интерфейса. Смена типа или перестановка элементов может потребовать создания новых экземпляров.

Есть ограничения самого .NET Edit and Continue и ограничения Akbura. Неподдерживаемая правка требует restart. За применение delta и запуск Hot Reload, в том числе при сохранении файла, отвечает host: IDE или соответствующий инструмент.

Итог

В начале серии у нас был синтаксис. Теперь есть вся цепочка: parser, incremental reuse, semantic model, diagnostics, редакторы, генерация C# и runtime, который умеет обновлять уже созданный интерфейс.

Для каждого сохранённого результата нужно определить границу совместимости: это касается куска текста, syntax node, semantic result и живого контрола. От этих правил зависит корректность следующего обновления.

Roslyn компилирует C#, Blender переиспользует синтаксис, BlackSilence планирует генерацию, а runtime обновляет UI. Когда эти обязанности не смешиваются, свой DSL становится немного меньше похож на набор удачных случайностей.

На этом основную серию можно закончить. Сам Akbura продолжает развиваться: язык написать оказалось недостаточно, теперь интересно посмотреть, что на нём вообще можно собрать. Кто бы мог подумать.

Например, Building a Responsive UI with Akbura.

Комментарии (0)