Привет! Это вторая часть рассказа о том, как мы поддерживали язык Frege в IntelliJ IDEA. Первую часть читайте здесь. Сейчас мы поделимся, как сделали автодополнение, систему сборки, интерпретатор и систему типов. И как все это тестировали.
![](https://habrastorage.org/getpro/habr/upload_files/957/df7/61a/957df761a5c3f2fe4c67992ea3639539.png)
План
Автодополнение
У нас есть два вида дополнения: одни основаны на ссылках, другие на паттернах дерева.
Автодополнение на ссылках
Оно очень похоже на обычную навигацию, отличается лишь небольшим моментом. Когда IDEA говорит, что нужно разрешить ссылку какого-либо элемента, передается специальный флаг incompleteCode. Он равен false, если нужно действительно разрешить ссылку, и true, если хочется получить список возможных автодополнений. Основное различие в том, что при обычной навигации мы сравниваем имя искомого объекта с найденным, а при автодополнении — нет, то есть отдаем все элементы, которые в той или иной степени разумно было бы написать в этом месте. Дальше IDEA сама смотрит, подходит ли найденный элемент по имени, собирает из этого итоговый список и показывает его пользователю, подсвечивая совпадающие куски имени.
![](https://habrastorage.org/getpro/habr/upload_files/87e/81d/139/87e81d13933cb9f27e10b2b94a5e7184.png)
![](https://habrastorage.org/getpro/habr/upload_files/a62/b08/d1c/a62b08d1c97c6b7a683e6d9f444cbfb5.png)
Автодополнение на паттернах дерева
Автодополнение ключевых слов реализуется на основе паттернов: паттерн сопоставляется с PSI деревом, и если это сопоставление завершилось успешно, то предлагается соответствующее ключевое слово. В идеале это позволяет предлагать только те ключевые слова, которые действительно можно написать в данном месте. Простейший пример паттерна выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/6a4/c05/e1f/6a4c05e1febc34e2f18ff14def3f12ee.jpg)
Он успешно сопоставляется в тех местах PSI дерева, где прошлый лист — ключевое слово abstract. В таких местах нужно предложить написать ключевое слово data. Наши паттерны можно посмотреть здесь.
Переименование элементов
Помимо автодополнения навигация также позволяет переименовывать элементы. Конечно же, имеет смысл переименовывать только те элементы, для которых возможно разрешение ссылки. Принцип работы переименования такой (почти все шаги IDE делает за нас):
Разрешить ссылку исходного элемента.
Найти все использования найденного элемента.
Для каждого из найденных использований вызвать переименование.
Все, что нам нужно предоставить, — это сказать, какой элемент получится после переименования. К примеру, была у нас функция f x = x
, мы захотели f
переименовать в g
и нам нужно сказать, какой элемент получится после этого преобразования. Мы не можем просто взять и заменить текст, так как есть структура PSI-дерева, которую нужно поддерживать. Поэтому переименовывать нужно немного умнее. Распространенной практикой для этого является фабрика объектов. Она работает по следующему принципу (пусть для простоты объяснения мы хотим создать название функции g
):
Придумываем корректный текст программы, в котором есть объявление функции
g
.Создаем ненастоящий файл (в том смысле, что он не будет находиться на физическом диске, а только в оперативной памяти) и заполняем его придуманным текстом.
IDEA автоматически запустит все необходимые процедуры (лексинг, парсинг и т.д.)
Забираем полученное PSI-дерево и находим в нем нужный нам элемент.
Вызываем replace на нашем исходном элементе с только что найденным.
![](https://habrastorage.org/getpro/habr/upload_files/7ac/b49/eca/7acb49eca06fdfe4c847d2350f1e8743.gif)
Add import quickfix
В нашем плагине пока очень мало quickfix’ов. Из интересных всего один — add import quickfix. Если у элемента не может разрешиться ссылка, он предлагает пользователю список модулей, из которых можно заимпортировать элемент с таким именем, и если пользователь выберет этот модуль, добавляет его в импорты.
![](https://habrastorage.org/getpro/habr/upload_files/0ec/29c/3a2/0ec29c3a28f2273e1ad5791cfd6b3a51.gif)
Документация
В первую очередь стоит сказать, что документация является полноценным токеном в языке Frege: для неё должно выполняться layout rule, а иногда она даже может использоваться в качестве разделителя — вместо запятой. Почти все варианты написания документации это либо над объявлением элемента, либо справа от него.
![](https://habrastorage.org/getpro/habr/upload_files/716/43b/af4/71643baf480bad5b993a0bf7e469dcec.png)
И так как это полноценный элемент PSI-дерева, то правильным проходом по дереву можно собрать всю документацию, относящуюся к элементу. Затем остаётся лишь поддержать точку расширения documentationProvider
, класс-реализацию наследовать от AbstractDocumentationProvider
и переопределить пару методов. Первый из них getDocumentationElementForLink
, который говорит, как по месту нажатия пользователя определить элемент, из которого нужно брать документацию, простейшая его реализация — разрешить ссылку. Второй из методов generateDoc, который для уже разрешённого элемента должен возвращать его документацию, как раз ту, что мы научились собирать выше.
Сама документация формируется в виде HTML, что позволяет добавить в нее ссылки на другие элементы: например, у нас есть ссылки на родительский класс, на определения и документации всех реализаций функций, у типов данных есть ссылки на документацию всех их функций и т.д. На гифке пример документации для типа данных Int
:
![](https://habrastorage.org/getpro/habr/upload_files/a24/fe6/49b/a24fe649b02df36a649eb0ae23398bdc.gif)
Система сборки
Мы рассказали вам о разборе грамматики языка, научились навигироваться по коду и показали некоторые фишки плагина, но до сих пор не научились компилировать Frege. В этом разделе мы расскажем о нашей системе сборки.
Как и многие другие JVM-языки, Frege компилируется в Java-байткод и запускается на JVM.
Ниже представлена схема, описывающая порядок сборки Frege:
![](https://habrastorage.org/getpro/habr/upload_files/dc3/16d/5ad/dc316d5ad6a5909d935b3733f777b418.png)
Сперва идет компиляция других JVM-языков, вроде Java и Kotlin. Затем полученные .class файлы с байткодом указываются в classpath компилятора Frege. fregec компилирует Frege файлы в байткод, где нужно используя зависимости из других языков. Потом все полученные файлы с байткодом загружаются в JVM и запускаются.
Чтобы не загромождать схему, мы опустили некоторые компоненты вроде JRE и сторонних библиотек.
Это один из основных сценариев использования Frege. Есть и другие варианты, например, сперва собрать Frege в .class файлы, а затем использовать функции Frege внутри Java-кода. Оба сценария предполагались создателями языка.
Безусловно, можно сделать проект с большим количеством JVM-языков, зависящих друг от друга. Frege не очень хорошо справляется с такими проектами, подробнее под спойлером.
Проблемы с мультизычными проектами
Frege позволяет использовать в проекте любые языки, которые умеют компилироваться в JVM-байткод, вроде Java, Kotlin, Groovy, Scala. Однако при компиляции могут возникнуть трудности, если граф зависимостей между языками сложный. Например, можно создать проект с Frege и Java с зависимостями как на схеме ниже:
![Здесь File1.fr зависит от File2.java, а File2.java зависит от File3.fr. Это происходит, например, из-за импорта в Frege методов из Java, а в Java методов из Frege. Здесь File1.fr зависит от File2.java, а File2.java зависит от File3.fr. Это происходит, например, из-за импорта в Frege методов из Java, а в Java методов из Frege.](https://habrastorage.org/getpro/habr/upload_files/d02/bdf/5af/d02bdf5af0cad07198d398446419485f.png)
У такого проекта есть проблема: если компилятор сначала начнет компилировать File1.fr, то ему потребуется зависимость в виде File2.java. Он умеет самостоятельно это понимать, и потому попытается собрать File2.java. Однако то, то File2.java зависит от File3.fr, он понять не сумеет, а потому сборка сломается.
Его можно собрать, если вручную собрать файлы в правильном порядке: сперва File3.fr, потом File1.fr, однако для сложных проектов это может быть проблематично.
Обобщение этого примера — ориентированный ациклический граф. Его можно так или иначе собрать.
А вот следующий пример — граф с циклами — собрать вообще нельзя:
![](https://habrastorage.org/getpro/habr/upload_files/99e/7ce/3d0/99e7ce3d0779d98ba909e9a7b2f3c17d.png)
Frege для компиляции File1.fr использует File2.java, скомпилированный в .class-файл, а Java для компиляции File2.java использует File1.fr, скомпилированный в .class-файл. Получается циклическая зависимость, с которой компилятор на данный момент не умеет справляться.
Как же устроена система сборки в нашем плагине? В целом, достаточно примитивно: в новом пользовательском проекте мы создаем шаблонный build.gradle файл, который умеет собирать и запускать Frege, скачивать компилятор и другие мелочи. Он использует настройки из gradle.properties: там указывается версия компилятора, путь до локальной версии компилятора, и другие опции. Сам gradle.properties также автоматически создается при создании проекта и содержит настройки по умолчанию (последняя версия компилятора). Этот же build.gradle может быть использован пользователем для сборки Java, Kotlin, подтягивания зависимостей и библиотек и всего, что может еще понадобиться.
Отдельно стоит сказать, откуда компилятор появляется на компьютере пользователя. Сейчас Frege распространяется через github. Есть версия релиза и версия внутри релиза. Их можно указать в gradle.properties, и тогда перед сборкой и запуском компилятор автоматически скачается с гитхаба и сохранится в проекте. Альтернативный вариант: можно указать локальную версию компилятора. Это может понадобиться, если компилятор на компьютере есть, а интернета нет, или если вы разработчик и хотите протестировать новую, еще не выпущенную, версию компилятора.
Есть другие способы сборки: maven-плагин, gradle-плагин. К сожалению, они оба устарели и не поддерживаются на современных Maven и Gradle.
Также для удобства рядом с функцией main
появляется кнопка для запуска файла при условии корректной сигнатуры main
-а.
![](https://habrastorage.org/getpro/habr/upload_files/3f2/9cd/e95/3f29cde954170b1c5394fe4965a46f76.gif)
Интерпретатор (REPL)
У Frege, как и у многих уважающих себя функциональных языков, есть интерпретатор. Он позволяет построчно вводить и запускать код в консоли.
У IDEA есть собственные консоли, в которых разработчик плагина может, например, выводить какую-то информацию или как раз настроить интерактивное взаимодействие с интерпретатором. При этом несложно сделать в ней и автоматическую подсветку кода, автодополнение, навигацию — по сути, IDEA делает это автоматически.
Сама поддержка консоли выглядит следующим образом: сперва запускается новый процесс интерпретатора, из stdout которого мы можем читать, а в stdin писать. После этого вывод этого процесса показывается в новом терминале. В терминале есть текстовое окно с выводом процесса, а ниже строчка для ввода. По нажатию Enter срабатывает Action, очищающий строчку для ввода и отправляющий этот текст в stdin интерпретатора. После stdout сам выводится на экран.
![Окно REPL в IDEA Окно REPL в IDEA](https://habrastorage.org/getpro/habr/upload_files/05f/c3e/8f6/05fc3e8f6806bd9c536ed704e0790318.png)
![Исполнение простых команд в REPL Исполнение простых команд в REPL](https://habrastorage.org/getpro/habr/upload_files/3dd/89e/c8b/3dd89ec8b1f78010dd36abc0e0b1f9ab.gif)
Небольшой головной болью является то, что интерпретаторы выводят в начале каждой команды frege> или, например, λ>. И такое же сообщение выводит IDEA в начале строчки для ввода. Получается что-то такое:
![](https://habrastorage.org/getpro/habr/upload_files/0b7/2dd/bf9/0b72ddbf95f897134022aaa4694e4ef0.png)
Чтобы этого избежать, мы (а еще, например, плагин для Scala) просто не выводим последнюю строчку из stdout процесса.
Также пользователь наверняка захочет, чтобы в консоли были доступны функции, которые он написал у себя в файлах. Для этого можно указать, какие части проекта он хочет автоматически загрузить в интерпретатор при запуске:
![](https://habrastorage.org/getpro/habr/upload_files/838/5f2/2c4/8385f22c4f49d3240f3fc019fefa2b04.png)
Тогда перед запуском интерпретатор пройдется по этим частям проекта и выведет в консоль :load <filename>
, что позволит пользоваться содержимом этих файлов в интерпретаторе.
![Запуск REPL с автоматической загрузкой зависимостей Запуск REPL с автоматической загрузкой зависимостей](https://habrastorage.org/getpro/habr/upload_files/aef/bac/94e/aefbac94e77d917409c6775ac874e887.gif)
Еще одной полезной фичей является отправка кода из файлов проекта на запуск в консоли (новой или уже существующей):
![Отправка кода на исполнение в REPL. Это делается по Alt + Enter, но на гифке для наглядности через графический интерфейс Отправка кода на исполнение в REPL. Это делается по Alt + Enter, но на гифке для наглядности через графический интерфейс](https://habrastorage.org/getpro/habr/upload_files/381/a69/046/381a690464670abd81ebef09a8121907.gif)
Для поддержки этой фичи мы использовали intentions — автоматические подсказки от IDEA для действий с кодом. Пользователь выделяет код, нажимает Alt + Enter, и одной из предложенных ему подсказок будет запуск этого кода в интерпретаторе. Можно исполнить код в уже запущенном терминале или выбрать создание нового. Тогда запустится Run Configuration для REPL, если такая существует.
Система типов
Система типов — наша боль. Frege имеет статическую типизацию с автоматическим выводом типов. А это значит, что нам, как авторам плагина, в некоторых местах тоже нужно знать типы выражений. Например,
![](https://habrastorage.org/getpro/habr/upload_files/e52/485/9d7/e524859d77795378349eb56df3fbb71d.png)
Здесь мы понимаем, что существует переменная list и что у ArrayList существует new (это вызов конструктора, который принимает unit-type). Но не можем понять, что list имеет тип ArrayList, поэтому не можем разрешить ссылки методов get и add. Стоит отметить, что мы не помечаем такие элементы красным цветом как неразрешенные, потому что не умеем их разрешать совсем.
С системой типов возникают некоторые трудности. Можно написать ее самим. Это довольно безумный план, но, скорее всего, система типов, написанная таким образом, будет быстро работать. Однако для его выполнения нужно иметь хорошие знания о компиляторах и системах типов, разобраться с системой типов Frege, и реализовать её на Java и Kotlin. От этого плана мы пока что отказались.
Можно использовать вывод типов из существующего компилятора, а точнее из отдельной библиотеки. К сожалению, у нас не получилось полноценно интегрировать ее в IDEA. К тому же, это создало бы проблемы с производительностью: при каждом изменении кода приходилось бы дергать интерпретатор, который начал бы заново анализировать весь проект вместо того, чтобы пользоваться уже готовыми деревьями, которые мы построили.
Несмотря на вышеописанную проблему, мы решили попробовать сделать хоть какую-то систему типов, даже медленную, и использовать для этого REPL. Однако пока эта фича находится в стадии разработки.
Тестирование
Тесты лексера и парсера
Для тестирования лексера и парсера существует три вида тестов. Первые проверяют, что файл успешно парсятся, вторые, напротив, что файл не парсится, а последние парсят файл, строят по нему PSI-дерево и сверяют его с образцовым деревом для этого файла. Сейчас на парсер мы имеем более 70 тестов, кроме того, у нас успешно парсятся все примеры с гитхаба Frege и вся стандартная библиотека Frege.
Тесты для навигации и рефакторингов
Для тестирования навигации у нас есть более 120 тестов: они проверяют все основные случаи, в том числе навигацию из Java и в Java. Для тестов в IDEA есть много специальных сущностей. Например, при тестировании навигации в файле надо указать <caret>
:
![](https://habrastorage.org/getpro/habr/upload_files/3f6/c79/1a0/3f6c791a04fc23b8a493cb3e49b01a35.png)
Тогда IDEA во время тестирования вызовет разрешение ссылки из позиции <caret>
, а мы сможем сверить результат навигации с ожидаемым.
Обертки для тестов
Также у нас написаны удобные обертки для запуска тестов. Например, тест для примера выше выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/a19/c60/451/a19c60451331e5aea08ca2e422a177ca.png)
Во-первых, сначала мы смотрим, что написано после test
: это может быть либо file
, либо dir
. Первое просто запускает тестирование на одном файле, второе создает мини-проект из набора файлов в директории и уже там проверяет ссылки. Дальше указывается путь до тестового файла, то есть файл выше находится в path/to/testData/caseof/WhereUnderBindingAbove.fr
. Учитывая, что файлы могут быть не только .fr
(а .java
, например), оно само перебирает допустимые расширения файлов. Дальше в указанном с помощью <caret>
месте запускается разрешение ссылки, результат передается в написанную лямбду и проверяется, что предикат выполнен.
Также есть похожие тесты для тестирования переименования: создаются два файла TestBefore
и TestAfter
, в первом указывается <caret>
, вызывается переименование и происходит проверка, что содержимое получившегося файла совпадает с содержимым второго файла.
Заключение
На этом наш рассказ о создании языкового плагина для IDEA кончается. Спасибо, что дочитали! Мы будем очень рады услышать от вас вопросы по любой части, но еще больше будем рады, если вы попробуете наш плагин! :)
Ссылки:
Другие проекты наших студентов: