Прошёл примерно месяц с предыдущей статьи, но работа над Akbura продолжается. В прошлый раз мы написали инкрементальный синтаксический анализатор. Теперь пора ответить на вопрос, ради которого строилось синтаксическое дерево: что написанный код означает?
-
Добавляем семантику
-
Диагностика
-
Интегрируем Language Server Protocol и делаем поддержку в Visual Studio
-
Генерируем код
Зачем нужна семантика
Синтаксический анализатор может разобрать такой файл:

После разбора известны границы StateDeclarationSyntax, атрибута разметки и инструкции C#. Но остаются более важные вопросы:
-
какой тип у count?
-
что такое viewModel?
-
существует ли Width у Button?
-
можно ли преобразовать int в double?
-
какая перегрузка Console.WriteLine выбрана?
-
что означает Refresh.Execute?
-
какое свойство получает содержимое Button?
Отвечать на них во время разбора нельзя. Синтаксическое дерево должно строиться даже для незаконченного кода, а синтаксический анализатор не должен знать о сборках, типах, доступности и выборе перегрузок.
Поэтому обработка разделена на этапы:

Компиляция и декларации
Одного файла для семантики недостаточно. Компонент может находиться в другом .akbura, стиль во внешнем .akcss, а тип C# в подключённой сборке. Всё это объединяет AkburaCompilation.
Внутри неё хранится обычная CSharpCompilation, а поверх неё:
-
деревья .akbura
-
деревья .akcss
-
корневое пространство имён
-
каталог проекта
-
таблица деклараций
Компиляция является неизменяемым снапшот. После изменения файла создаётся новая AkburaCompilation, а старая продолжает обслуживать уже начатые запросы. Это особенно важно для среды разработки, где несколько потоков могут работать с разными версиями документа.
Следующий слой называется декларациями. Декларация является дешёвым описанием структуры проекта, но ещё не полноценным символом. Она сообщает, какие компоненты, состояния, команды, стили и утилиты существуют и как они вложены друг в друга.
Базовый класс маленький:
internal abstract class Declaration{ public string Name { get; } public ImmutableArray<Declaration> Children { get; } public abstract DeclarationKind Kind { get; }}
В базовой декларации нет прямой ссылки на синтаксис. Конкретное место в исходнике представляет SingleDeclaration, а объединённые сведения из нескольких файлов представляет Merged...Declaration. Это соответствует основной идее деклараций Roslyn.
DeclarationTreeBuilder строит дерево деклараций, а DeclarationTable объединяет корни файлов. Обычные инструкции C# туда не входят: локальная переменная важна для связывания тела метода, но не является сущностью проекта.
Символы
Декларация говорит, что в исходнике написано state int count. Символ объясняет, что это объявление означает.
В Akbura есть собственные символы для компонентов, состояний, параметров, зависимостей, команд, свойств разметки, элементов шаблона, модулей AKCSS, стилей и утилит. Они нужны потому, что многие сущности Akbura ещё не существуют в C# до генерации кода.
Например:

Для Roslyn такой команды пока нет. Но семантическая модель уже должна понимать Refresh.Execute(userId), Refresh.IsExecuting, параметры и тип результата. Поэтому используется ICommandSymbol, который при необходимости содержит ссылки на настоящие типы C#.
Поиск возвращает AkburaSymbolInfo, а не просто ISymbol?. Помимо успешного символа в нём могут находиться символы-кандидаты и причина неоднозначности. Это позволяет среде разработки показать полезный результат даже при ошибке.
Binder и встроенный C#
Главную работу выполняет Binder. Он знает текущую область видимости, ищет имена, классифицирует преобразования и создаёт связанные узлы.
Объекты Binder образуют цепочку:

BlockBinder знает локальные переменные, MarkupBinder знает текущий элемент и тип данных шаблона, ComponentBinder знает state, param, inject и command. Если имя не найдено, поиск продолжается через Next.
Самая сложная задача здесь связана с C#. В Akbura можно написать:
var numbers = Enumerable .Range(1, 12) .Where(x => x % 2 == 0) .Select(x => x * UserId) .ToArray();
Нужно понять Enumerable, выбранные методы расширения, параметр x и UserId. Реализовывать собственное связывание C# нельзя: пришлось бы повторить выбор перегрузок, вывод обобщённых типов, лямбда-выражения и преобразования.
Поэтому CSharpProbeBinder создаёт небольшой вспомогательный фрагмент C#. Для выражения count + UserId он по смыслу выглядит так:
private sealed class __AkburaProbe{ object Run() { int count = default; int UserId = default; return count + UserId; }}
Дерево этого фрагмента временно добавляется в CSharpCompilation. После этого Roslyn вычисляет тип, символ, постоянное значение, преобразование, диагностики и IOperation. Результат сохраняется в CSharpBindingResult, а символы вспомогательных переменных сопоставляются обратно с символами Akbura.
BoundTree и преобразования
Синтаксическое дерево отвечает на вопрос «что написано», а BoundTree отвечает на вопрос «с чем это связано».
Например, выражение count имеет тип int, но свойство Button.Width ожидает double. Поэтому связывание выполняется с ожидаемым типом:

Конкретные узлы BoundStateInitializer, BoundMarkupPropertySetter, BoundAkcssIf и другие хранят уже найденные символы, типы, преобразования и диагностики. Генератор кода не должен снова искать свойство или спрашивать Roslyn, можно ли преобразовать int в double.
Дерево операций создаётся отдельно:

Для выражений C# фабрика переносит дерево IOperation Roslyn в CSharpOperation. Благодаря Parent, Children, типам и целевым символам обычный OperationWalker может пройти от операции разметки до ссылки на конкретный IStateSymbol.
SyntaxTreeSemanticModel остаётся фасадом. Работа делится между ComponentMemberSemanticModel, InitializerMemberSemanticModel, ExecutableMemberSemanticModel, MarkupMemberSemanticModel и AkcssMemberSemanticModel. В Roslyn поле с такими моделями имеет похожую оговорку.
Семантика разметки
Короткий элемент разметки скрывает много работы:

Нужно найти тип Button, свойство Width, присоединённое свойство Grid.Column, преобразовать значения и определить свойство содержимого.
IPropertySymbol хранит выбранный способ чтения и записи. Для обычного свойства это может быть CLR-свойство или AvaloniaProperty, для Attached Property дополнительно сохраняются поле, статические Get... и Set..., а также тип объекта, к которому его разрешено применять.
Содержимое тоже связывается по типу. У Button оно может стать строкой в Content. Для TextBlock действует отдельное правило:

Хотя [Content] указывает на Inlines, текст и встроенные выражения записываются в TextProperty как интерполированная строка.
Расширения разметки имеют собственный синтаксис:

Binder выбирает тип и конструктор расширения, связывает аргументы, находит ProvideValue и проверяет преобразование результата к целевому свойству.
Для Compiled Binding недостаточно сохранить строку User.Profile.FullName. Каждый элемент пути заранее связывается с конкретным свойством и типом. Индексатор History[0] также хранит выбранный индексатор, преобразование аргумента и постоянное значение 0.
Тип данных шаблона можно вывести автоматически. Avalonia помечает свойства шаблонов атрибутом InheritDataTypeFromItemsAttribute. Например, ItemsControl.ItemTemplate наследует тип элемента от ItemsSource.

Если Vm.Items имеет тип IEnumerable<UserItem>, то внутри шаблона FullName ищется у UserItem. Через x.ItemName="item" тот же тип можно получить как локальный символ C# и писать item.FullName без расширения разметки.

Семантика AKCSS
AKCSS проходит через те же основные слои: декларации, символы, Binder, BoundTree и операции.
Один элемент может получить несколько подходящих вариантов стиля:
.awesomeBg { Background: White; }Button.awesomeBg { Background: Red; Padding: 10; }
Для <Button class="awesomeBg" /> применяются оба источника. Семантическая модель возвращает набор подходящих символов, а не один случайно выбранный стиль.
Утилиты могут иметь типизированные параметры:
@utilities { .w-(double value) { Width: value; }}
Параметр value попадает во вспомогательный код C#, а присваивание связывается с типом целевого свойства. Аналогично работают параметры-перечисления, @if, @apply и @intercept.
Кеши, проверки и генерация кода
Семантические запросы выполняются лениво. SemanticBindingCache хранит символы, связанные узлы, операции и диагностики текущего снапшота. Доступ защищён ReaderWriterLockSlim, поэтому параллельные чтения не мешают друг другу.
Между снапшотами нельзя безусловно переносить связанные узлы только потому, что совпал зелёный узел. Окружающая область видимости или тип C# могли измениться. Поэтому полезнее измерять задержку первого запроса после правки, а не тождественность переиспользованных объектов.
Полнота семантики проверяется на проекте, похожем на настоящий. Для каждой значимой конструкции тесты явно определяют, нужны ли ей декларация, символ, связанный узел и операция. Ошибочные пути привязки, недоступные свойства, неоднозначные расширения и невозможные преобразования должны приводить к диагностике, а не к падению генератора.
После этого генератор кода получает готовые сведения:
-
целевой тип и свойство
-
способ чтения и записи
-
преобразование значения
-
присоединённые Get… и Set…
-
конструктор и ProvideValue расширения
-
связанные элементы пути Binding
-
применимые стили и утилиты AKCSS
Итог
Теперь Akbura понимает декларации и символы языка, связывает встроенный C# через Roslyn, строит BoundTree и дерево операций, проверяет разметку, расширения, привязки и AKCSS. Этого уже достаточно, чтобы перейти к генерации кода.
Но следующая часть продолжит тему семантики и будет называться «Диагностика». Нужно научиться выдавать больше полезных сообщений, а затем расширить модель за пределы одной сборки, чтобы находить компоненты и стили Akbura в других сборках и пакетах NuGet.
ссылка на оригинал статьи https://habr.com/ru/articles/1058512/