В этой статье я не буду описывать как создать плагин для 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 и написать грамматику для него.

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

Комментарии (0)