Добавляем поддержку пользовательского языка в IntelliJ с помощью ANTLR

—

от автора

В этой статье я не буду описывать как создать плагин для IntelliJ с поддержкой %lang_name% с нуля. Кроме официальной документации и туториала от JetBrains в сети есть множество статей и примеров. На Хабре тоже есть пара весьма подробных статей от @pyltsinm. Фокус будет на разработке плагина с использованием ANTLR и моём опыте в этом нелёгком деле.

Примером в этой статье будет WebCalm — плагин для IntelliJ с поддержкой JavaScript и CSS, который я сделал с помощью ANTLR.

Почему ANTLR

Для поддержки пользовательского языка в IntelliJ существует Grammar-Kit — набор инструментов для задания грамматики и генерации по ней необходимых для IntelliJ классов для представления языка. Для лексера используется JFlex, а парсер описывается с помощью специального *.bnf файла, который представляет собой грамматику в РБНФ нотации с дополнительными директивами.

Для тех кто не знает, ANTLR — это, грубо говоря, генератор парсеров. На входе — грамматика в форме РБНФ, на выходе — лексер и парсер описанного языка. Для ANTLR имеется множество готовых грамматик для различных формальных языков. Код его открыт, а сам он бесплатен и распространяется под лицензией BSD-3.

Если для языка, поддержку которого вы хотите добавить, уже имеется готовая ANTLR-грамматика, а вы не горите желанием переписывать её под Grammar-Kit, то логично попытаться её каким-то образом переиспользовать. В этом нам поможет antlr4-intellij-adaptor — адаптер ANTLR для IntelliJ, как можно понять из названия. Это официальный проект от создателей ANTLR содержащий набор классов адаптеров для использования лексеров и парсеров сгенерированных ANTLR в IntelliJ.

База

Думаю, не будет лишним кратко пробежаться по основам представления языка в InetlliJ.

Начинается всё, конечно, с лексического анализа (токенизации). За это отвечает класс Lexer. Он выдаёт последовательность токенов. Каждый токен имеет свой тип — IElementType. Причём лексера уже достаточно чтобы добавить базовую подсветку для вашего языка — за это отвечает SyntaxHighlighter.

За парсинг отвечает PsiParser. На выходе он даёт AST — дерево состоящее из ASTNode. Каждый узел также имеет свой тип (IElementType). Но плагины и сама IntelliJ зачастую (но далеко не всегда) не используют AST напрямую — всё это добро скрывается за Program Structure Interface или сокращённо PSI. PSI — классы скрывают в себе логику для работы с конкретным элементом языка — идентификатором, выражением, функцией и т.д.

Типичное добавление пользовательского языка в IntelliJ выглядит так:

  1. Описываем лексер и парсер во *.flex и *.bnf файлах соответственно.

  2. Генерируем лексер, парсер и, что самое главное, PSI классы соответствующие правилам заданной грамматики. Вручную из IDE с помощью Grammar-Kit плагина для IntelliJ или из Gradle с помощью gradle-grammar-kit-plugin.

  3. Добавляем необходимую обвязку чтоб всё это завелось.

Использование ANTLR

Теперь разберём как использовать ANTLR-грамматику в IntelliJ.

Грамматика

Начинаем, конечно, с грамматики. Ищем нужную нам среди официальных. В моём случае это javascript и css3 для JavaScript и CSS соответственно. Также для некоторых грамматик могут понадобиться базовые классы. Для JavaScript это JavaScriptLexerBase и JavaScriptParserBase.

Возможно, грамматики для вашего языка не оказалось и вы решили написать её с нуля. В таком случае я бы рекомендовал подумать нужна ли она где-то ещё кроме IntelliJ. Если нет, то лучше написать грамматику для Grammar-Kit, т.к. этот инструмент заточен для IntelliJ и даёт некоторые преимущества (о них позже).

Подключение ANTLR в Gradle

Кусочек build.gradle.kts из WebCalm:

// ...  plugins {     // ...     id("antlr") }  // ...  val antlrVersion = "4.12.0"  dependencies {     implementation("org.antlr:antlr4-intellij-adaptor:0.1") {         constraints {             implementation("org.antlr", "antlr4-runtime", antlrVersion) {                 because("Old runtime leads to 'Could not deserialize ATN' error.")             }         }     }     antlr("org.antlr", "antlr4", antlrVersion) {         // Not required for 'generateGrammarSource' task.         exclude("com.ibm.icu", "icu4j")         exclude("org.abego.treelayout", "org.abego.treelayout.core")     } } //TODO: a hack to exclude antlr4 dependencies from the resulting distribution zip. See https://github.com/gradle/gradle/issues/820 configurations[JavaPlugin.API_CONFIGURATION_NAME].let { apiConfiguration ->     apiConfiguration.setExtendsFrom(apiConfiguration.extendsFrom.filter { it.name != "antlr" }) }  // ...

Сперва в plugins подключаем antlr плагин. В dependencies настраиваем версии зависимостей (ANLTR и адаптера) и исключаем ненужные зависимости. Напоследок втыкаем хак, чтобы обойти проблему с добавлением всего antlr в финальный артефакт.

Теперь у нас есть generateGrammarSource Gradle-задача для генерации лексера и парсера (для ANTLR, но не для IntelliJ). По-умолчанию, ANTLR-плагин ожидает грамматики в src/main/antlr, но это можно настроить.

Лексер

После генерации лексера из ANTLR-грамматики мы получаем класс лексера унаследованный от org.antlr.v4.runtime.Lexer. Чтобы превратить его в com.intellij.lexer.Lexer, который использует IntelliJ, существует org.antlr.intellij.adaptor.lexer.ANTLRLexerAdaptor. Вроде всё просто, но если код на вашем языке встроен в код на другом (например, JavaScript и CSS встроенные в HTML), то, скорее всего, вы столкнётесь с проблемой java.lang.IndexOutOfBoundsException. Что ж, пишем костыль:

open class FixedANTLRLexerAdaptor(language: Language, lexer: Lexer) : ANTLRLexerAdaptor(language, lexer) {     // TODO: it's a hack to prevent IndexOutOfBounds exception. See https://github.com/antlr/antlr4-intellij-adaptor/issues/31     override fun toLexerState(state: Int): ANTLRLexerState {         if (state == 0) {             return initialState         }         return super.toLexerState(state)     } }

Чтобы токены можно было различать при работе с ними, каждый из них имеет соответствующий IElementType. Для их создания необходимо вызвать org.antlr.intellij.adaptor.lexer.PSIElementTypeFactory#defineLanguageIElementTypes с соответствующими аргументами. Для получения — org.antlr.intellij.adaptor.lexer.PSIElementTypeFactory#getTokenIElementTypes. Пример из WebCalm:

object JavaScriptTypes {     init {         PSIElementTypeFactory.defineLanguageIElementTypes(             JavaScriptLanguage, JavaScriptLexer.tokenNames, JavaScriptParser.ruleNames         )     }      val FILE = IFileElementType(JavaScriptLanguage)      private val TOKENS = PSIElementTypeFactory.getTokenIElementTypes(JavaScriptLanguage)     val UNEXPECTED_CHARACTER = TOKENS[JavaScriptLexer.UnexpectedCharacter]!!     val WS = TOKENS[JavaScriptLexer.WhiteSpaces]!!     val EOL = TOKENS[JavaScriptLexer.LineTerminator]!!     val LINE_COMMENT = TOKENS[JavaScriptLexer.SingleLineComment]!!     val MULTILINE_COMMENT = TOKENS[JavaScriptLexer.MultiLineComment]!!     val STRING = TOKENS[JavaScriptLexer.StringLiteral]!!     // ... }

Grammar-Kit же генерирует всё это автоматически — это его преимущество перед ANTLR-адаптером.

Парсер

Для парсера тоже есть адаптер — org.antlr.intellij.adaptor.parser.ANTLRParserAdaptor. Он отвечает за построение AST, с которым работает IntelliJ, с помощью парсера сгенерированного ANTLR. Внутри адаптер превращает org.antlr.v4.runtime.tree.ParseTree, которое выдаёт парсер сгенерированный ANTLR, в com.intellij.lang.ASTNode. Делается это с помощью org.antlr.intellij.adaptor.parser.ANTLRParseTreeToPSIConverter. Это та точка расширения, где мы можем скорректировать абстрактное синтаксическое дерево.

Модификация AST

В ANTLR-грамматике могут быть правила, состоящие из одного нетерминального символа или выбора из нетерминальных символов. Такие правила удлиняют ветки дерева, но не несут практической пользы:

declaration     : variableStatement     | classDeclaration     | functionDeclaration     ;

Конечно, вы можете удалить такие правила, но это может сказаться на читаемости грамматики. Поэтому мы можем выкинуть такие правила на этапе конвертации в AST с помощью самописного конвертера:

class CustomParseTreeToPsiConverter(language: Language, parser: Parser, builder: PsiBuilder) : ANTLRParseTreeToPSIConverter(language, parser, builder) {     private var rulesToDrop: Set<Int> = emptySet()     // ...      override fun exitEveryRule(ctx: ParserRuleContext) {         ProgressIndicatorProvider.checkCanceled()         val ruleIndex = ctx.ruleIndex         if (rulesToDrop.contains(ruleIndex)) {             val marker = markers.pop()             marker.drop()         } else {             // ...         }     }      // ... }

Ещё в ANTLR-грамматике есть возможность помечать альтернативы выбора с помощью меток:

anonymousFunction     : Async? Function_ '*'? '(' formalParameterList? ')' functionBody    # AnonymousFunctionDecl     | Async? arrowFunctionParameters '=>' arrowFunctionBody              # ArrowFunction     ;

AnonymousFunctionDecl и ArrowFunction как раз являются такими метками. Они не создают новое правило, но определить правило с таким же именем вы уже не сможете. Проблема в том, что при обходе дерева полученного из ANTLR, нет доступа к этим меткам. Что ж, нам не помешает лишний костыль:

private fun label(ctx: ParserRuleContext): String? {     val javaClass = ctx.javaClass     val isLabeled = javaClass.superclass.canonicalName != "org.antlr.v4.runtime.ParserRuleContext"     return if (isLabeled) {         javaClass.simpleName.removeSuffix("Context")     } else null }

В коде выше извлекается название метки из имени класса контекста текущего правила.

Добавляем метки к нашему конвертеру:

class CustomParseTreeToPsiConverter(language: Language, parser: Parser, builder: PsiBuilder) : ANTLRParseTreeToPSIConverter(language, parser, builder) {     private var rulesToDrop: Set<Int> = emptySet()     private var labeledRules: Map<String, IElementType> = emptyMap()      override fun exitEveryRule(ctx: ParserRuleContext) {         ProgressIndicatorProvider.checkCanceled()         val ruleIndex = ctx.ruleIndex         if (rulesToDrop.contains(ruleIndex)) {             val marker = markers.pop()             marker.drop()         } else {             val marker = markers.pop()             val label = label(ctx)             val ruleIElementType = labeledRules[label] ?: getRuleElementTypes()[ctx.ruleIndex]             marker.done(ruleIElementType)         }     }      fun withRulesToDrop(rulesToDrop: Set<Int>): CustomParseTreeToPsiConverter {         this.rulesToDrop = rulesToDrop         return this     }      fun withLabeledRules(labeledRuleElements: Map<String, IElementType>): CustomParseTreeToPsiConverter {         this.labeledRules = labeledRuleElements         return this     }      private fun label(ctx: ParserRuleContext): String? {         val javaClass = ctx.javaClass         val isLabeled = javaClass.superclass.canonicalName != "org.antlr.v4.runtime.ParserRuleContext"         return if (isLabeled) {             javaClass.simpleName.removeSuffix("Context")         } else null     } }

Пример его реального использования вы можете посмотреть в JavaScriptParser.

PSI-элементы

И так, у нас есть парсер, который даёт на выходе com.intellij.lang.ASTNode. Следующий шаг — превратить это в структуру из PSI-элементов. За это в IntelliJ отвечает com.intellij.lang.ParserDefinition#createElement. Этот метод, обычно делегируется какой-нибудь фабрике элементов. В случае Grammar-Kit, она будет сгенерирована автоматически, но в нашем случае её придётся написать ручками. Пример можно посмотреть в JavaScriptTypes$Factory:

object JavaScriptTypes {     init {         PSIElementTypeFactory.defineLanguageIElementTypes(             JavaScriptLanguage, JavaScriptLexer.tokenNames, JavaScriptParser.ruleNames         )     }      // ...      object Factory {         // ...          private val RULES = PSIElementTypeFactory.getRuleIElementTypes(JavaScriptLanguage)         private val IDENTIFIER = RULES[JavaScriptParser.RULE_identifier]         private val IDENTIFIER_NAME = RULES[JavaScriptParser.RULE_identifierName]         private val LITERAL = RULES[JavaScriptParser.RULE_literal]         private val FUNCTION_DECLARATION = RULES[JavaScriptParser.RULE_functionDeclaration]         // ...          fun createElement(node: ASTNode): PsiElement {             return when (node.elementType) {                 IDENTIFIER -> JavaScriptIdentifier(node)                 IDENTIFIER_NAME -> JavaScriptIdentifierName(node)                 LITERAL -> JavaScriptLiteral(node)                 FUNCTION_DECLARATION -> JavaScriptFunctionDeclaration(node)                 // ...                 else -> ASTWrapperPsiElement(node)             }         }          // ...     } }

В коде выше по IElementType узла дерева создаётся соответствующий PSI-элемент. Классы PSI-элементов тоже придётся написать ручками, в отличие от Grammar-Kit, который их сгенерирует сам.

Остальные фишки

Лексер и парсер готовы (спасибо ANTLR за это) и теперь вы можете приступать к добавлению остальных фишек: подсветка кода, свёртка кода, поиск использований, переименование и т.п. Большинство из этого описано в официальной документации и туториале. Не стесняйтесь подсмотреть что-то в WebCalm или других плагинах на GitHub.

Плюсы и минусы

Плюсы:

  • Переиспользование существующей грамматики.

  • Высокая скорость создания прототипа.

Минусы:

  • Грамматики не идеальны. Я создал более 15 issue на GitHub для JavaScript и CSS грамматик. Благо, ребята из ANTLR достаточно шустро принимают правки.

  • ANTLR-адаптер всё ещё имеет версию 0.1, и в нём встречаются проблемы, которые приходится обходить хаками. Всё же, инструмент весьма нишевый.

  • Приходится много чего писать ручками, в то время как Grammar-Kit генерирует почти всё необходимое из файла описания грамматики. Есть даже запрос на использования ANTLR грамматик в Grammar-Kit, но не думаю что он будет удовлетворён.

  • В ANTLR отсутствуют некоторые фишки Grammar-Kit. Например, я так и не нашёл способ сделать восстановление после ошибок. В Grammar-Kit это делается чуть ли не автоматически.

Выводы

Если у вас есть готовая ANTLR-грамматика для языка, поддержку которого вы хотите добавить в IntelliJ, то вы можете весьма быстро получить работающий прототип с помощью ANTLR-адаптера.

Если такой грамматики нет, и она не пригодится где-то ещё, то лучше использовать Grammar-Kit и написать грамматику для него.

Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/748622/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *