Всем привет!
Интегрируем Language Server Protocol и делаем поддержку в Visual Studio
Генерируем код
В прошлой части мы закончили с диагностикой. К этому моменту Akbura уже умеет разбирать код, строить semantic model и находить ошибки.
Теперь пора добавить нормальную поддержку редакторов.
Для Visual Studio Code я сделал Language Server Protocol, а для Visual Studio отдельную интеграцию через MEF и Visual Studio SDK.
По пути пришлось разобраться с синхронизацией документов, версиями текста, immutable snapshots, MSBuild, file watchers и выполнением editor requests.
В этой части разберём, как устроены обе интеграции и какие проблемы появились по дороге.
Зачем Akbura понадобился LSP
Компиляторная инфраструктура Akbura уже включала parser, semantic model и diagnostics.

Редактор создаёт новую версию документа после каждого изменения. Для этих версий могут одновременно выполняться parsing, completion, hover, navigation, semantic tokens, formatting и diagnostics.

Языковая логика Akbura написана на C#, а клиент Visual Studio Code на TypeScript. LSP позволяет оставить анализ языка в одном .NET-процессе и подключить редактор через стандартные JSON-RPC-сообщения. Протокол задаёт lifecycle, синхронизацию документов, cancellation, completion, diagnostics, semantic tokens и workspace edits.

Клиент запускает сервер и передаёт editor events. Сервер использует Akbura.Workspaces и возвращает результаты языковых функций.
Lifecycle и capabilities
Сеанс начинается с initialize. Затем клиент отправляет initialized, уведомления документов и запросы языковых функций. Завершение выполняется через shutdown и exit.

completion является request. didChange и publishDiagnostics являются notifications.
Akbura Language Server представляет собой .NET-приложение, которое работает через stdio и StreamJsonRpc. Внутри него находятся workspace, immutable snapshot, registry обработчиков, очередь запросов, загрузчик проектов и diagnostics publisher. Сервер поддерживает синхронизацию документов, diagnostics, semantic tokens, completion, hover, navigation, symbols, rename, code actions, formatting и наблюдение за проектными файлами.
Во время initialize клиент и сервер обмениваются capabilities. Akbura учитывает snippets, resolve-операции, pull diagnostics, workspace edits, file watchers и semantic token refresh.

Основные сложности LSP
Синхронизация документов
didOpen передаёт полный текст и версию. didChange передаёт следующие версии документа.

Сообщения обрабатываются асинхронно. Изменение с версией не новее текущей отбрасывается:
if (requestedVersion <= current.Version) { return Task.FromResult( new AkburaLspHandlerResult<object?>(null)); }
Эта проверка защищает completion, diagnostics, rename и symbols от устаревшего текста.
При incremental synchronization клиент передаёт диапазон и новый текст.
замени диапазон от (12, 8) до (12, 13) на строку "Button"

Изменения применяются последовательно к текущему SourceText. Затем сервер передаёт TextChangeRange инкрементальному парсеру.

LSP-позиции задаются через Line и Character. В стандартном режиме Character измеряется в UTF-16 code units. Компиляторная часть использует абсолютные offsets и TextSpan. Utf16PositionConverter выполняет преобразования для конкретной версии SourceText и проверяет границы документа.

Immutable snapshot и выполнение запросов
Каждый handler получает цельный snapshot состояния. Операции, которые изменяют состояние, проходят через последовательную очередь.

Read-only запросы могут выполняться параллельно на одном snapshot.

Готовое состояние публикуется атомарно. Уже запущенные запросы продолжают использовать предыдущий snapshot. Новые запросы получают новый. Read-only handler не может публиковать состояние. Такая модель сохраняет отзывчивость сервера во время references, formatting и загрузки solution.
Syntax, semantics и MSBuild
Синтаксический анализ запускается сразу и не требует загруженного проекта. Он обеспечивает classification, syntactic diagnostics и outlining. Семантический этап добавляет completion, definition, references и semantic diagnostics.

AkburaTextBufferContext заменяет pending request новым изменением и отменяет устаревший parse. Пока новая semantic model готовится, навигация может использовать предыдущую.
Для семантики сервер ищет .sln, .slnx или .csproj, загружает проект через MSBuild и связывает открытый .akbura-документ с нужным проектом. При отсутствии проекта работает syntax-only mode.
Project watchers следят за файлами Akbura, проектами, Directory.Build.*, Directory.Packages.props и global.json. Уведомления, созданные активной загрузкой, игнорируются.

Несколько близких file events объединяются через debounce на 350 ms.

File watchers регистрирует сервер. Клиент не дублирует их.
Незавершённый код
Во время набора документ часто содержит незавершённые конструкции. Error-tolerant parser сохраняет classification, completion, folding, symbols, diagnostics и formatting. Incremental parser сокращает объём работы после каждого изменения.
Semantic tokens
TextMate grammar выполняет лексическую подсветку. Семантические роли передаются через LSP semantic tokens. Клиент и сервер согласуют legend с индексами типов.

Сервер возвращает компактный числовой массив с относительным кодированием. Akbura поддерживает full, range, delta и refresh после загрузки project snapshot. Delta-результат привязан к версии документа.
Проект, который помог больше всего
Во время написания поддержки Visual Studio я изучал Roslyn, исходники расширений Microsoft и множество небольших примеров VSSDK.
Но, пожалуй, самым полезным внешним референсом оказался проект KirillOsenkov/XmlParser.
Это Roslyn-inspired full-fidelity XML parser:
сохраняющий каждый символ исходного текста;
error-tolerant;
использующий immutable green/red tree;
поддерживающий базовый incremental parsing;
не имеющий тяжёлых зависимостей.
Кроме самого парсера в репозитории есть небольшой Visual Studio XML language service: classification, outlining, commenting, smart indent, tagging и parser service.
Почему этот проект оказался настолько полезным?
Roslyn содержит ответы почти на все вопросы, но найти нужный ответ в его исходниках бывает сложно. Между точкой входа и реальной логикой могут находиться десятки abstractions, feature flags, layers и compatibility adapters.
XmlParser достаточно маленький, чтобы его можно было прочитать целиком.
Например, его ParserService хранит syntax tree отдельно для каждого ITextSnapshot, используя ConditionalWeakTable, и запускает parsing асинхронно:

Это хорошо показывает фундаментальную идею Visual Studio Editor API: анализ принадлежит не просто файлу, а конкретному immutable snapshot text buffer.
Конечно, синтаксис XML и Akbura различается. Но архитектурные идеи оказались очень близкими:
immutable syntax trees;
привязка результата к snapshot;
безопасный reuse;
публикация editor tags;
отсутствие ожидания parser на UI thread.
Исходники XmlParser помогли понять не столько «как разбирать XML», сколько как подружить собственный парсер с жизненным циклом Visual Studio Editor.
Почему исходники Avalonia меня не спасли
Изначально я надеялся, что лучшим референсом станет расширение Avalonia для Visual Studio.
Логика была простой:
Avalonia использует XAML Akbura тоже описывает UI Значит, большая часть проблем уже решена
Я ожидал найти там полноценный самостоятельный XAML language service: собственный parser pipeline, classification layer, snapshot synchronization и semantic services.
Но в открытой архивной версии AvaloniaUI/AvaloniaVS базовая интеграция редактора устроена иначе: расширение напрямую назначает текстовому буферу встроенный XML Language Service Visual Studio через IVsTextLines.SetLanguageServiceID.
В исходниках это видно по полю:
private readonly Guid _xmlLanguageServiceGuid = new Guid("f6819a78-a205-47b5-be1c-675b3c7f0b8e");
А затем этот GUID передаётся буферу:
// Set up the language service - this will activate intellisense and syntax highlighting _textLines.SetLanguageServiceID( ref Unsafe.AsRef(_xmlLanguageServiceGuid));
Сам GUID не является идентификатором собственного сервиса Avalonia: он соответствует XML language service Visual Studio. Иными словами, Avalonia не реализует полноценный XML-языковой сервис с нуля, а подключается к уже существующей инфраструктуре Visual Studio и использует её для базовой поддержки XAML. Поверх этой инфраструктуры расширение добавляет собственную completion-логику и XAML-специфичное поведение.
То есть фундаментальную XML-инфраструктуру предоставляет сама Visual Studio, а Avalonia добавляет поверх неё свою XAML-специфичную логику completion, paste handling и designer integration.
У Avalonia действительно есть собственный completion engine:
CompletionEngine = new CompletionEngine();
но редакторская основа всё равно опирается на уже существующий XML content type Visual Studio.
Это не критика Avalonia. Для XAML такой подход абсолютно разумен: зачем заново реализовывать XML editor, если Visual Studio уже умеет:
XML classification;
matching tags;
basic indentation;
XML navigation;
работу с text buffer.
Но Akbura != XML.
В одном документе у нас могут находиться:
директивы Akbura state declarations C# expressions markup raw strings style selectors AKCSS values
Притвориться XML-файлом не получится.
Поэтому пришлось создавать собственный content type:
[Name("Akbura")] [BaseDefinition(StandardContentTypeNames.Code)]
и отдельно связывать с ним .akbura и .akcss.
После этого нужно было самостоятельно реализовывать:
classifier;
completion source;
completion commit manager;
diagnostics tagger;
Error List data source;
Quick Info;
navigation;
outlining;
indentation;
suggested actions;
workspace synchronization.
Почему Visual Studio не использует тот же LSP-клиент
Здесь возникает логичный вопрос:
Если LSP нужен для переносимости, почему Visual Studio extension не запускает тот же Akbura Language Server?
Такой вариант возможен. Но в текущей архитектуре Visual Studio интегрирована напрямую через MEF и Visual Studio SDK.
Получилась следующая схема:

Главное здесь — языковая логика не дублируется.
Обе интеграции используют общий Akbura.Workspaces, а различается только editor adapter.
Visual Studio напрямую предоставляет очень богатый API:
ITextBuffer;ITextSnapshot;tracking spans;
native completion;
Quick Info;
Error List;
suggested actions;
image monikers;
text classifications.
LSP покрывает большую часть стандартных языковых функций, но не все специфичные возможности Visual Studio удобно выражаются через протокол.
Поэтому для Visual Studio сейчас используется нативная интеграция, а для Visual Studio Code — отдельный LSP process.
Visual Studio extension собирается под net472 и использует Visual Studio SDK, editor text APIs и Roslyn editor services.
# Два internal API Roslyn, которые я очень хотел бы видеть публичными
Во время этой работы несколько раз возникало очень странное ощущение: нужная функция в Roslyn уже существует, работает внутри Visual Studio и даже имеет почти идеальный для моей задачи API. Открываешь исходники, радуешься — а потом замечаешь перед классом или методом слово internal.
Особенно больно это было в двух местах.
Live C# как настоящий SourceGeneratedDocument
Для DSL вроде Akbura идеальная схема выглядит примерно так:

Именно поэтому мне было особенно интересно устройство Razor/Blazor tooling.
В современной интеграции Razor с Roslyn сгенерированный C# действительно представлен не каким-то отдельным самодельным объектом, а обычным SourceGeneratedDocument. Это хорошо видно, например, в RemoteDocumentSnapshot.GetGeneratedDocumentAsync: snapshot Razor-документа хранит и возвращает именно SourceGeneratedDocument.
Ещё нагляднее это видно в Razor-specific расширениях Roslyn: они получают документы, созданные RazorSourceGenerator, через обычный механизм source generators и отдельно проверяют identity генератора. См. TryGetSourceGeneratedDocumentsForRazorDocumentAsync и IsRazorSourceGeneratedDocument.
Но особенно интересным для меня оказался внутренний primitive самого Roslyn — Solution.WithFrozenSourceGeneratedDocument:
internal Document WithFrozenSourceGeneratedDocument( SourceGeneratedDocumentIdentity documentIdentity, DateTime generationDateTime, SourceText text)
Комментарий над методом практически дословно описывает то, чего мне хотелось: вернуть новый Solution, который для конкретного generated file будет всегда отдавать определённый текст.
Сам Roslyn использует этот механизм, когда открыт source-generated document и нужно, чтобы текущий текст редактора корректно совпадал с состоянием workspace. Это видно в TextExtensions.GetOpenDocumentInCurrentContextWithChanges.
То есть внутри Roslyn уже существует концепция:

Вот такой поддерживаемый публичный API очень пригодился бы Akbura.
После каждого изменения .akbura можно было бы сгенерировать актуальный C# в памяти и передать его Roslyn как виртуальный generated document. Тогда C#-часть IDE могла бы сразу использовать его для completion, навигации, диагностик и остальных стандартных Roslyn features — без записи временного .cs на диск и без ожидания полноценной сборки.
Здесь есть важная оговорка: я не утверждаю, что Razor просто вызывает WithFrozenSourceGeneratedDocument на каждое нажатие клавиши. У современного Razor гораздо более специальная интеграция с Roslyn, source generator и cohosting. Но сам Roslyn уже содержит очень близкий низкоуровневый механизм работы с актуальным состоянием SourceGeneratedDocument.
И вот он, к сожалению, internal.
Публичный Project.GetSourceGeneratedDocumentsAsync позволяет читать результаты зарегистрированных source generators. А вот поддерживаемого публичного API, которым обычное расширение могло бы подложить собственный актуальный generated text в Solution и заставить Roslyn воспринимать его как настоящий source-generated document, мне как раз и не хватало.
Metadata as Source — ещё одна почти готовая функция
Вторая такая история появилась при реализации Go to Definition.
Пока символ объявлен в исходниках проекта, всё просто:

Но Button, StyledProperty, ICommand и огромное количество других типов приходят из MetadataReference.
У такого символа может вообще не быть исходного .cs внутри текущего solution.
При этом обычный C# в Visual Studio спокойно позволяет нажать Ctrl+Click и открыть красивое представление типа из metadata. Иногда это восстановленные сигнатуры, иногда может использоваться decompiler или исходники.
Разумеется, когда я полез в Roslyn, готовый сервис там уже оказался.
Это internal-интерфейс IMetadataAsSourceFileService, а интересующий метод называется GetGeneratedFileAsync:
Task<MetadataAsSourceFile> GetGeneratedFileAsync( Workspace sourceWorkspace, Project sourceProject, ISymbol symbol, bool signaturesOnly, MetadataAsSourceOptions options, CancellationToken cancellationToken);
Причём это не просто красивый printer для ISymbol. В самом контракте Roslyn прямо предусмотрено два режима: только сигнатуры или возможность использовать decompiler / другой механизм получения представления исходников.
То есть это буквально инфраструктура, которая нужна для нормального перехода к определению символа из metadata.
Проблема уже знакомая:
internal interface IMetadataAsSourceFileService
Поэтому для Akbura пришлось сделать упрощённый Metadata as Source самостоятельно.
Получилась MetadataSourceDefinition.TryCreate.
В упрощённом виде алгоритм такой:

Я беру ISymbol, через публичный SyntaxGenerator восстанавливаю декларацию, оборачиваю её в containing types и namespace, вычисляю точный span имени нужного символа и сохраняю получившееся представление в стабильный temp-cache.
Это, конечно, не полноценный декомпилятор Roslyn.
Моя реализация не пытается восстановить тела методов, не вытаскивает оригинальные исходники через PDB/SourceLink и не повторяет весь настоящий Metadata as Source pipeline Visual Studio. В основном она создаёт читаемое C#-представление сигнатур.
Но для задачи:

этого оказалось достаточно.
И снова немного обидно: внутри Roslyn уже есть гораздо более мощная реализация ровно этой задачи, но поддерживаемого публичного доступа к ней нет.
Microsoft.CodeAnalysis.CSharp.Workspaces и Microsoft.CodeAnalysis.CSharp.Features — вообще отдельная любовь
После двух предыдущих абзацев может показаться, что я только ругаюсь на Roslyn.
На самом деле всё наоборот.
Microsoft.CodeAnalysis.CSharp.Workspaces и Microsoft.CodeAnalysis.CSharp.Features — одни из самых полезных библиотек, которые встретились мне во время разработки IDE-поддержки.
Если очень грубо разделить Roslyn по уровням, получается так:

Конечно, граница между assemblies не настолько идеальна, и часть общих API живёт в базовых Workspaces/Features-пакетах. Но как мысленная модель это очень хорошо показывает разницу между «у меня есть C# compiler» и «у меня уже начинает появляться почти настоящая C# IDE».
Достаточно открыть исходники CSharp.Workspaces: там находятся C#-реализации classification, formatting, code generation, find symbols, diagnostics, organize imports и множество language services.
А CSharp.Features идёт ещё выше: completion, Add Import, Code Fixes, refactorings, Call Hierarchy, Change Signature, brace completion/matching и куча других вещей, которые пользователь обычно воспринимает просто как «Visual Studio умеет».
В Akbura.Workspaces в итоге подключены обе библиотеки:
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" />
Они позволили не писать с нуля огромный кусок инфраструктуры вокруг C#.
И, наверное, самый забавный эффект знакомства с этими пакетами выглядит так:

Тонкий клиент Visual Studio Code
В отличие от Visual Studio extension, клиент Visual Studio Code получился относительно небольшим.
Он:
находит упакованный
akbura-lsp.dll;проверяет наличие .NET 10;
-
запускает сервер:
dotnet akbura-lsp.dll --stdio создаёт
LanguageClient;указывает document selectors для
.akburaи.akcss;передаёт workspace folder и настройки;
перезапускает сервер при изменении configuration;
предоставляет команды выбора solution или project.
Основная языковая логика остаётся в C#.
TypeScript-клиент не знает, как устроены:
parser;
binder;
semantic model;
diagnostics;
completion;
MSBuild workspace.
Он знает только, как запустить процесс и подключить его к Visual Studio Code через vscode-languageclient.
Это, пожалуй, главный практический выигрыш LSP.
Публикация расширений и немного Azure
После того как оба расширения заработали локально, оставалось опубликовать их:
Akbura Vs Code Extension Akbura Visual Studio Extension
И здесь выяснилось, что одинаковое расширение файла .vsix не означает одинаковый процесс публикации.
Visual Studio Code
Для автоматической публикации Visual Studio Code Microsoft рекомендует passwordless-схему с workload identity federation:

То есть для публикации небольшого VS Code extension всё равно приходится познакомиться с Azure:
создать managed identity;
настроить federated credential;
связать GitHub Environment;
добавить identity в Marketplace publisher;
передать Azure client, tenant и subscription IDs.
Первоначальная настройка заметно сложнее обычного PAT, зато в GitHub не хранится долгоживущий токен Marketplace. GitHub выдаёт короткоживущий OIDC token только конкретному release job. Microsoft сейчас рекомендует этот путь для автоматизированной публикации VS Code extensions.
Подробный гайд здесь делать не буду — это отдельная статья почти такого же размера.
Visual Studio
Для Visual Studio используется другой инструмент:
VsixPublisher.exe
и отдельный publish manifest.
В нашем случае авторизация пока выполняется через Marketplace PAT, который хранится в защищённом GitHub Environment.
Самым забавным моментом стало то, что VsixPublisher.exe успел успешно загрузить расширение и вывести:
Uploaded 'Akbura Visual Studio Extension' to the marketplace.
а затем упал внутри собственной телеметрии из-за конфликта версии System.Memory.
Marketplace расширение принял, но GitHub Actions стал красным из-за ненулевого exit code. Судя по обсуждению в репозитории Microsoft, это уже встречалось и у других авторов расширений: публикация фактически проходит, а падение после загрузки создаёт ложный failure.
В итоге пришлось различать:
реальная ошибка публикации
и:
Marketplace подтвердил upload, но publisher сломался после него
Таковы радости интеграции с инструментами, которые сами являются частью большой IDE.
Что получилось
Сейчас Akbura имеет две отдельные IDE-интеграции.
Akbura Vs Code Extension
Visual Studio Code получает:
syntax highlighting;
semantic highlighting;
diagnostics;
completion;
hover;
definition;
references;
rename;
symbols;
code actions;
signature help;
formatting;
folding;
автоматические парные конструкции;
работу с
.akburaи.akcss;загрузку
.sln,.slnxи.csproj.
Akbura Visual Studio Extension
Visual Studio получает нативную интеграцию:
собственный content type;
classification;
completion;
Quick Info;
diagnostics и Error List;
navigation;
outlining;
indentation;
suggested actions;
иконки файлов;
синхронизацию с Visual Studio workspace.
В обоих случаях основой остаётся одна компиляторная инфраструктура:
Akbura.Workspaces Parser Incremental Parser Semantic Model Diagnostics Roslyn integration MSBuild integration
Итог
Отдельно хочу ещё раз отметить KirillOsenkov/XmlParser. Его компактный full-fidelity parser и простой Visual Studio language service дали гораздо больше практических ответов, чем многие крупные production-репозитории.
А исходники Avalonia преподнесли другой полезный урок: иногда проекту не нужно реализовывать собственный language service, потому что подходящий уже предоставляет IDE. Avalonia могла опереться на XML-инфраструктуру Visual Studio. Akbura такой роскоши не имела, поэтому большую часть редакторского слоя пришлось строить самостоятельно. Да даже если бы имела, я бы все ровно бы написал велосипед.
В следующей части мы наконец вернёмся к генерации кода.
И постараемся превратить Akbura.Furioso из полезного экспериментального нейрослопа в нормальный, предсказуемый и тестируемый code generator.