В этой статье я не буду описывать как создать плагин для IntelliJ с поддержкой %lang_name% с нуля. Кроме официальной документации и туториала от JetBrains в сети есть множество статей и примеров. На Хабре тоже есть пара весьма подробных статей от @pyltsinm. Фокус будет на разработке плагина с использованием ANTLR и моём опыте в этом нелёгком деле.
Примером в этой статье будет WebCalm - плагин для IntelliJ с поддержкой JavaScript и CSS, который я сделал с помощью ANTLR.
Почему ANTLR
Для тех кто не знает, ANTLR - это, грубо говоря, генератор парсеров. На входе - грамматика в форме РБНФ, на выходе - лексер и парсер описанного языка. Для ANTLR имеется множество готовых грамматик для различных формальных языков. Код его открыт, а сам он бесплатен и распространяется под лицензией BSD-3.
Для поддержки пользовательского языка в IntelliJ существует Grammar-Kit - набор инструментов для задания грамматики и генерации по ней необходимых для IntelliJ классов для представления языка. Для лексера используется JFlex, а парсер описывается с помощью специального *.bnf
файла, который представляет собой грамматику в РБНФ нотации с дополнительными директивами. Проблема в том, что готовой Grammar-Kit грамматики для %lang_name% вы, скорее всего, не найдёте, а написание её с нуля займёт немало времени.
Если для языка, поддержку которого вы хотите добавить, уже имеется готовая 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 выглядит так:
Описываем лексер и парсер во
*.flex
и*.bnf
файлах соответственно.Генерируем лексер, парсер и, что самое главное, PSI-классы соответствующие правилам заданной грамматики. Вручную из IDE с помощью Grammar-Kit плагина для IntelliJ или из Gradle с помощью gradle-grammar-kit-plugin.
Добавляем необходимую обвязку, чтобы всё это завелось.
Использование 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 и написать грамматику для него.
Полезные ссылки
Оффициальная документация и туториал от JetBrains по написанию языковых плагинов.
https://github.com/antlr/grammars-v4/ - набор грамматик для ANTLR.
https://github.com/antlr/antlr4-intellij-adaptor/ - ANTLR-адаптер для IntelliJ.
https://github.com/ris58h/WebCalm/ - мой плагин для поддержки JavaScript и CSS основанный на инструментах ANTLR.