Зачем нужен протокол языкового сервера (LSP)?

LSP (протокол языкового сервера) сегодня весьма популярен. Есть стандартное объяснение этого феномена. Возможно, ранее вам уже попадалась эта картинка, у нас также являющаяся заглавной.

Считаю, что такое стандартное объяснение популярности LSP – неверное. Ниже предложу вам альтернативную трактовку.

Стандартное объяснение

Стандартное объяснение строится так:

Существует M редакторов и N языков. Если вы хотите поддерживать конкретный язык в интересующем вас редакторе, то для этого вам понадобится выделенный плагин. То есть, M * N будет работать, что наглядно показано на картинке выше. В данном случае LSP сужает эту картинку, приводя к тонкому общему знаменателю M + N – как показано на картинке ниже.

Почему это объяснение ошибочное?

Проблему, возникающую с этим объяснением, также удобнее всего проиллюстрировать. Если коротко, на картинках выше не соблюден масштаб. На следующей картинке точнее показано, как, например, взаимодействует комбинация rust-analyzer + VS Code:

(Большой) круг слева – это  rust-analyzer (языковой сервер). Расположенный справа круг примерно такого же размера — это VS Code (редактор). Маленький кружок в центре – это склеивающий их код, включающий, в том числе, реализации  LSP.

Этот код крошечный, как в абсолютном, так и в относительном выражении. Базы кода как за языковым сервером, так и за редактором – гигантские.

Если бы стандартная теория была верна, это бы означало, что до LSP мы жили в мире, где некоторые языки имели бы превосходную поддержку на уровне IDE в некоторых редакторах. Например, IntelliJ отлично показывала бы себя в Java, Emacs в C++, Vim в C#, т.д. Я помню, что в те времена было несколько иначе. Чтобы получить достойную поддержку IDE, требовалось либо пользоваться языком, поддерживаемым JetBrains (IntelliJ или ReSharper), либо…

Был всего один редактор, дававший осмысленную семантическую поддержку IDE.

Альтернативная теория

Я бы сказал, что истинная причина такой плохой поддержки IDE в те древние времена иная. Дело в том, что уровень M * N был не слишком большим, а слишком маленьким, так как N равнялось нулю, а M было всего лишь немного выше нуля.

Начну с N — количества языковых серверов, так как на этой стороне я относительно хорошо ориентируюсь. До LSP было просто не так много рабочих решений, работавших по принципу языкового сервера. В основном потому, что написать языковой сервер сложно. Сервер по сути своей слишком сложен, это так. Известно, как сложны компиляторы, а языковой сервер – это компилятор  и маленькая тележка.

Во-первых, как и компилятор, языковой сервер должен полностью понимать язык. Ему необходимо отличать корректные программы от некорректных. Однако, тогда как в случае с некорректными программами пакетному компилятору разрешено выдать сообщение об ошибке и немедленно выйти, языковой сервер обязан проанализировать любую некорректную программу настолько хорошо, насколько в его силах. Работа с неполными и некорректными программами – первое осложнение при работе с языковым сервером, если сравнивать его с компилятором.

Во-вторых, тогда как пакетный компилятор является чистой функцией, преобразующей исходный код в машинный, языковому серверу приходится работать с базой кода, в которую пользователь постоянно вносит изменения. Это компилятор с еще одним измерением – временным, а эволюция состояния с течением времени является одной из сложнейших проблем в программировании.

В-третьих, пакетный компилятор оптимизирован под максимальную производительность, тогда как языковой сервер стремится минимизировать задержки (но и аспект производительности при этом не забрасывается). Добавление требования «уменьшить задержку» не означает, что нужно оптимизировать еще сильнее. Напротив, это означает, что нужно перевернуть всю архитектуру с ног на голову, только чтобы привести задержку к приемлемому значению.

И все это приводит нас к плотному клубку взаимосвязанных проблем, касающихся побочной (ненужной) сложности, характерной для языковых серверов. Вполне понятно, как написать пакетный компилятор. Это общеизвестно. Притом, что не каждый читал книгу с драконом (я совершенно не смог разобраться в главах о синтаксическом разборе), каждому известно, что все ответы – в этой книге. Поэтому большинство существующих компиляторов выглядят как типичный компилятор. А когда авторы компиляторов начинают задумываться о поддержке на уровне IDE, первая их мысль: «ну, IDE это в своем роде компилятор, а компилятор у нас есть, значит, задача решена – разве не так»? Это очень не так, поскольку по внутренней организации IDE очень отличается от компилятора, но до недавнего времени этот факт не был общеизвестен.

Языковые серверы – контрпример к правилу «никогда не переписывай». Большинство респектабельных языковых серверов переписаны с пакетных компиляторов или являются их альтернативными реализациями.

Как IntelliJ, так и Eclipse написали собственные компиляторы, а не стали переиспользовать javac внутри IDE. Чтобы предоставить адекватную поддержку на уровне IDE для C#, Microsoft переписала свой пакетный компилятор, сделанный на C++, и превратила его в интерактивный самоподдерживающийся аналог (проект Roslyn). Dart, хотя и сделан с нуля, и является относительно современным языком, получил три реализации (хост-компилятор с опережающим (AOT) подходом, хост-компилятор для IDE (dart-analyzer), а также динамический (JIT) компилятор, работающий на устройстве). В Rust опробовали оба варианта — поступательную эволюцию в случае с rustc (RLS) и реализацию с нуля (rust-analyzer), причем, rust-analyzer решительно победил.

Мне известны два исключения из этой картины: C++ и OCaml. Любопытно, что в обоих требуются как предварительные объявления (forward declarations), так и заголовочные файлы. Совпадение? Не думаю. Подробнее об этом см. в посте Three Architectures for a Responsive IDE.

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

Я не так уверен, что же происходит на стороне редактора. Поэтому не хочу утверждать, что не бывает редакторов, которые могли бы выступать в качестве полноценной IDE.

IDE воспринимается как куча семантических фич. Самая замечательная из них – это, конечно же, автозавершение. Если вы хотите написать собственный вариант автозавершения для VS Code, то ваш код должен реализовывать интерфейс CompletionItemProvider:

interface CompletionItemProvider {     provideCompletionItems(         document: TextDocument,         position: Position,     ): CompletionItem[] }

Таким образом, в VS Code автозавершение кода (как и десятки других фич, касающихся работы с IDE) на уровне редактора являются сущностями первого класса, у них единообразный пользовательский UI и API разработчика.

Сравните с Emacs и Vim. У них просто нет нормального завершения, которое могло бы послужить исходной фичей для расширения редактора. Они просто предоставляют низкоуровневый курсор и API для манипуляции с экраном, а потом людям приходится поверх всего этого реализовывать фреймворки автозавершения, конкурирующие между собой!

А это только завершение кода. Что насчет информации о параметрах, вложенных подсказок, навигации по хлебным крошкам, расширенного поиска, помощи, символьного поиска, функции «найти» (достаточно?) J  ?

Кратко резюмируя вышесказанное – проблема с годной поддержкой IDE лежит не в плоскости N * M, а связана с неверно откалиброванным равновесием этого двустороннего рынка.

Разработчики языков не спешили создавать языковые серверы, потому что это сложно, а спрос на них малый (= нет конкуренции со стороны других языков). Если даже создать языковой сервер, то найдется и с десяток текстовых редакторов, абсолютно не приспособленных для работы в качестве носителя для умного сервера.

Со стороны редактора не было особого стимула добавлять высокоуровневые API, нужные для IDE, поскольку не было потенциальных провайдеров таких API.

Почему LSP – отличная штука

Вот почему, на мой взгляд, LSP так хорош!

Не думаю, что это серьезная техническая инновация (очевидно, что нам хочется иметь отдельный редактор, не связанный с языком и языково-специфичный сервер). Полагаю, это весьма плохая (т.н., «довольно хорошая») техническая реализация (вы уже настроились почитать пост «Почему LSP отстой?»). Но эта штука позволила нам покинуть мир, где было нормально не иметь языковой IDE, и никто даже не думал о языковых серверах – в мир, где язык без действенного автозавершения и определения goto выглядит непрофессионально.

Примечательно, что проблема уравновешивания рынка была решена Microsoft, являющейся вендором как языков (C# и TypeScript), так и редакторов (VS Code и Visual Studio), которая, кстати, проиграла нишу IDE конкуренту (JetBrains). Притом, что я могу поругаться на конкретные технические детали LSP, я абсолютно восхищаюсь их стратегической прозорливостью в данной конкретной области. Они:

  • Построили редактор на основе веб-технологий.

  • Разглядели в вебдеве большую нишу, в которой JetBrains буксует (поддерживать JS в IDE почти невозможно).

  • Написали язык (!!!!), на котором стало возможно предоставить поддержку IDE для вебдева.

  • Построили платформу IDE с очень дальновидной архитектурой.

  • Запустили LSP, чтобы даром повысить ценность своей платформы в других предметных областях  (переведя весь мир в значительно более уравновешенную ситуацию с IDE – что всем пошло на пользу).

  • А теперь, имея Code Spaces, превращаются в ведущего игрока на поле «исходно удаленная разработка», а вдруг мы действительно прекратим редактировать, собирать, и выполнять наш код на локальных машинах.

Но, если честно, до сих пор надеюсь, что победа останется за JetBrains, ведь их Kotlin задуман как универсальный язык для любых платформ :-). Притом, что Microsoft выжимает максимум из доминирующих сегодня технологий «чем хуже, тем лучше» (TypeScript и Electron), JetBrains пытаются исправлять ситуацию, двигаясь снизу вверх (Kotlin и Compose).

Подробнее о M * N

Теперь я просто подытожу, что все дело, в сущности, не в M * N 🙂

Во-первых, тезис M * N игнорирует тот факт, что это чрезвычайно параллельная задача. Проектировщикам языка не требуется писать плагины для всех редакторов, равно как не требуется добавлять в редакторах специальную поддержку для всех языков. Нет, язык должен реализовывать сервер, который общается по некоторому протоколу, редактор должен реализовывать API, работающие независимо от языка и обеспечивающие автозавершение. В таком случае, если и язык, и редактор не слишком экзотические, то человек, заинтересованный как в первом, так и во втором, просто напишет немного склеивающего кода в качестве связки между ними! Плагин от rust-analyzer для VS Code состоит из 3.2k строк кода, плагин neovim из 2.3k строк и плагин Emacs из 1.2k строк. Все три разработаны независимо, разными людьми. В этом и заключается магия децентрализованной опенсорсной разработки в наивысшем выражении! Если бы плагины поддерживали специализированный протокол, а не LSP (при условии, что внутри редактора поддерживается высокоуровневый API для IDE), то, полагаю, на все это потребовалось бы добавить, может, еще 2k строк кода, что нормально для бюджета хобби-проекта, выполняемого в свободное время.

Во-вторых, для оптимизации вида M * N следовало бы ожидать, чтобы реализация протокола генерировалась из какой-нибудь машинно-читаемой реализации. Но до самого последнего релиза источником истины для спецификации LSP служил неофициальный документ, оформленный в виде разметки. На каждом языке и клиенте изобретается собственный способ извлекать из него протокол, многие (в том числе, rust-analyzer) предполагали просто синхронизировать изменения вручную, с серьезным объемом дублирования.

В-третьих, если M * N является проблемой, то ожидаемо, чтобы на каждый редактор существовало всего по одной реализации LSP. В реальности же существуют две конкурирующие реализации для Emacs (lsp-mode и eglot) и, поверьте, я не прикалываюсь, на момент написания этой статьи, в мануале по rust-analyzer содержится инструкция по интеграции 6 (шестью) различными LSP-клиентами для  vim. В тон первому пункту, все это опенсорс! Общий объем работы практически не имеет значения, а что в данном случае действительно важно – это суммарная работа по координации, которую нужно проделать, чтобы добиться результата.

В-четвертых, Microsoft сама не пытается извлекать выгоду из M + N. Нет никакой универсальной реализации LSP в VS Code. Напротив, для каждого языка требуется выделенный плагин с физически независимыми реализациями LSP.

Что делать

Всем

Пожалуйста, требуйте более качественной поддержки IDE! Думаю, сегодня мы уже переступили порог, после которого наступила всеобщая доступность базовой поддержки IDE, но с ее помощью почти ничего не сделать кроме элементарных вещей. В идеальном мире должна была бы существовать возможность проверить все мельчайшие семантические детали о выражении, на которое наведен курсор, при помощи все того же простого API, при помощи которого сегодня любой желающий может проверить содержимое буфера в редакторе.

Авторам текстовых редакторов

Уделяйте внимание архитектуре VS Code. Притом, что степень удобства работы с electron вызывает вопросы, в их внутренней архитектуре заложено много мудрости. Ориентировать API редакторов нужно вокруг высокоуровневых возможностей, не зависящих от конкретного представления. Базовая функциональность IDE должна расцениваться как точка расширения первого класса, не следует делать так, чтобы автору каждого плагина приходилось ее переизобретать. В частности, добавление assist/code action/ в качестве возможности первого класса на уровне UX – уже норма. Это первая по важности инновация в области пользовательского опыта, применяемая в IDE, и на настоящий момент она уже довольно старая. Просто нелепо, что она еще не стала стандартным интерфейсом, обязательным во всех редакторах.

Но не делайте LSP как таковой сущностью первого класса. Как ни удивительно на первый взгляд, VS Code ничего не знает о LSP. Она просто предоставляет набор точек расширения, нисколько не заботясь о том, как они реализуются. Тогда реализация LSP – это просто библиотека, используемая плагинами, специфичными для конкретных языков. Например, расширения под Rust и C++ для VS Code не разделяют во время выполнения одну и ту же реализацию LSP, в памяти одновременно хранится два разных экземпляра библиотеки LSP!

Кроме того, попытайтесь обуздать силу опенсорса. Не требуйте централизации всех реализаций LSP! Дайте возможность разным группам независимо разработать для вашего редактора превосходную поддержку Go и отличную поддержку Rust. Одна из возможных моделей представлена в VS Code, там есть маркетплейс и распределенные независимые плагины. Но, вероятно, должна быть возможность организовать всю работу в рамках единого разделяемого репозитория/дерева исходников, поскольку за каждый из языков может отвечать своя независимая команда поддержки.

Авторам языковых серверов

Вы славно работаете! Качество поддержки IDE стремительно улучшается для всех языков, хотя, у меня такое ощущение, что мы только в начале долгого пути. В частности, нужно не забывать о том, что LSP – это один из интерфейсов для семантической информации о языке, но не единственный такой интерфейс. В наличии может появиться что-нибудь получше. Поэтому попробуйте воспринимать LSP как формат сериализации, а не внутреннюю модель данных. Также пытайтесь больше писать о том, как реализовывать языковые серверы – на мой взгляд, в доступе по-прежнему не хватает информации на эту тему.

Вот и все!


ссылка на оригинал статьи https://habr.com/ru/company/piter/blog/667882/

Добавить комментарий

Ваш адрес email не будет опубликован.