Пишем свой плагин для IDEA для поддержки нового языка (часть 1)

в 6:30, , рубрики: idea, java, jetbrains, kotlin, monkey, plugin

Я начал писать статьи задолго до сегодняшних событий, и не уверен, что статьи еще могут быть актуальны (так как не уверен уже, что будет завтра), но не охота, чтобы усилия пропали совсем зря.

Дисклаймер: я не являюсь разработчиком из JetBrains, поэтому в статье и в коде могут быть и скорее всего есть неточности и ошибки.

Краткое оглавление

Часть 1

Часть 2

  • Форматирование

  • Structure view

  • Кэши, индексы, stub и goto

  • Ссылки (Reference)

  • Find Usages

  • Rename и другие рефакторинги

  • Маркеры (Markers)

  • Автодополнение

  • Тестирование

Введение

Я работаю бекэнд-разработчиком и иногда устаю от перекладывания json из одного формата в другой (к сожалению, эта часть работы, хоть и не самая любимая). Как и любому программисту мне нравится копаться в коде, смотреть что как устроено и, возможно, использовать некоторые приемы в своей работе, а также мне нравится автоматизировать свою работу - в этом мне часто помогает IDEA.

Большая часть кода IDEA и особенно платформенная ее часть - open-source. Программисты из JetBrains активно помогают с pull request в основную ветку (спасибо @tagir_valeev за помощь с парой инспекций). Один из главных плюсов IDEA (кроме богатого функционала): относительно легкое расширение плагинами. Примеры инспекции и плагина для транзакций Spring были рассмотрены в предыдущих статьях. Но для того чтобы понять, как работает основа IDEA, самое простое, как мне кажется, - это написать языковой плагин.

На habr уже была серия замечательных статей по создания своего плагина, есть также официальная документация. В этой серии статей я постарался не сильно повторять то, что уже было написано, а скорее углубленно расказать о интересных деталях и особенностях реализации.

Эти статьи могут быть интересны тем, кто хочет попробовать расширить IDEA под себя или добавить какой-нибудь интересный функционал, или просто тем, кому, как и мне, нравится копаться внутри.

В качестве "подопытного кролика", для которого будет написан плагин, взят максимально простой язык Monkey, создание интепретатора и компилятора для которого описано в книгах по Golang. Так как у меня не было целью охватить все, то сам плагин охватывает некоторое его ограниченное подмножество. Сам интерпретатор можно найти тут.

Пример рассчета числа Фибоначчи на Monkey:

let fibonacci = fn(x){
                    if (x == 0){
                        0;
                    }
                    else{
                        if (x == 1){
                            return 1;
                        }
                        else{
                            fibonacci(x - 1) + fibonacci(x - 2);
                        };
                    };
};

Сам плагин будет писаться на Java и Kotlin (JVM), так как они являются основными языками разработки для платформы IDEA.

Предусловия

Самый просто способ создать любой плагин из IDEA - из шаблона. Он активно развивается и включает в себя практически весь функционал, который нам нужен для разработки.

Для создания языкового плагина также понадобится DevKit. Он нужен для удобной настройки расширений (будут расмотрено ниже).

Плагин DevKit поставляется с IDEA, но выключен по умолчанию
Плагин DevKit поставляется с IDEA, но выключен по умолчанию

Для генерации лексера и парсера понадобится плагин Grammar-Kit.

Примеры языковых плагинов:

  • Java (в основном коде IDEA)

  • go-plugin (до того, как он стал платным и стал Golang. С ним есть некоторые сложности, так как многие зависимости устарели, но как пример хорош)

  • Haskell

  • Erlang

  • Frege (его создание здорово описано его авторами в статьях)

  • Monkey plugin (в рамках данной статьи)

Сразу отмечу, что Java plugin является самым развитым из всех, но при этом он сильно отличается от остальных, так как был первым. Например, go-plugin (здесь и далее для простоты он будет называться так, хотя репозиторий называется go-lang-idea-plugin, а пакет - goide) использует Grammar-Kit для создания парсера и лексера, парсер и лексер Java плагина же написаны полностью вручную.

Маленькая ремарка о IDEA

Изучая исходный код IDEA, понимаешь, сколько в него вложено труда и знаний. Например, в IDEA для того, чтобы вычислять данные в debug окне, написан небольшой интерпретатор (спасибо Тагиру Валееву за твит про это)

Например, есть поддержка возможностей, которых нет в Java
Например, есть поддержка возможностей, которых нет в Java

или еще один интерпретатор используется поточным анализом для выявления ошибок (его работа очень сильно напоминает работу самого Java интепретатора).

Создание основы языкового плагина

Первые этапы создания языкового плагина хорошо описаны в документации.

  1. Требуется объявить новый язык (пример из go-plugin, frege, monkey)

    Пример из Monkey
    import com.intellij.lang.Language
    
    class MonkeyLanguage : Language("Monkey") {
        companion object {
            @JvmStatic
            val INSTANCE = MonkeyLanguage()
        }
    }
    
  1. Объявить иконку (пример из go-plugin, frege, monkey)

  2. Объявить новый тип файла и связать все вместе (пример из go-plugin, frege, monkey)

    Пример из Monkey
    import com.intellij.openapi.fileTypes.LanguageFileType
    import javax.swing.Icon
    
    class MonkeyFileType : LanguageFileType(MonkeyLanguage.INSTANCE) {
        override fun getName(): String {
            return "Monkey File"
        }
    
        override fun getDescription(): String {
            return "Monkey language file"
        }
    
        override fun getDefaultExtension(): String {
            return "monkey"
        }
    
        override fun getIcon(): Icon {
            return MonkeyIcons.FILE
        }
    
        companion object {
            @JvmStatic
            val INSTANCE = MonkeyFileType()
        }
    }

    После этого надо подключить новый тип файла через точку расширения (extension point). Все возможности, которые предоставляют плагины, подключаются через одну или несколько точек расширений. Они прописываются в файле plugin.xml (пример для go-plugin, frege). Другие примеры использования точек расширений будут приведены ниже или можно посмотреть в документации.

Пример подключения для Monkey (resources/META-INF/plugin.xml)
<extensions defaultExtensionNs="com.intellij">
  <fileType name="Monkey File"
            implementationClass="com.github.pyltsin.monkeyplugin.MonkeyFileType"
            fieldName="INSTANCE"
            language="Monkey"
            extensions="monkey"/>
</extensions>

Создание PSI-дерева

К сожалению, в рамках одной статьи невозможно описать всю теорию, которая требуется для разбора кода. Фундаментальные знания по этой теме можно получить в "книге с драконом"

Обложка "книги с драконом"
Обложка "книги с драконом"

Процесс работы компилятора с кодом состоит из следующих шагов:

рисунок взят из книги "Компиляторы: принципы, технологии и инструментарии"
рисунок взят из книги "Компиляторы: принципы, технологии и инструментарии"

Для успешной работы любой IDE требуется реализовать первые 3 анализатора:

  • Лексический анализатор (читает поток символов и группирует их в значащие последовательности, из которых строит токены)

  • Синтаксический анализатор (получает поток токенов и строит из них синтаксическое дерево - AST)

  • Семантический анализатор (использует дерево для проверки исходного кода программы на корректность языка).

Пример работы первых 3 анализаторов:

Рисунок взят из книги "Компиляторы: принципы, технологии и инструментарии"
Рисунок взят из книги "Компиляторы: принципы, технологии и инструментарии"

В IDEA вместо AST дерева используется аналог - PSI-дерево (Program structure Interface).

Процесс создания PSI-дерева хорошо показан на иллюстрации из документации:

Процесс создания PSI-дерева
Процесс создания PSI-дерева

Для того чтобы его увидеть, можно воспользоваться PSI Viewer (Tools->View PSI Structure)

Пишем свой плагин для IDEA для поддержки нового языка (часть 1) - 7

В IDEA для имплементации PSI-дерева используется в основном абстрактный класс TreeElement

Часть кода TreeElement
public abstract class TreeElement extends ElementBase implements ASTNode, Cloneable {
  private TreeElement myNextSibling;
  private TreeElement myPrevSibling;
  private CompositeElement myParent;
...
}

В IDEA для создания лексера и парсера можно использовать плагин GrammarKit.

Лексер

Интересный кейс по созданию лексера описан в статье про Frege.

Самый простой способ создания лексера для IDEA - использование JFlex. Плагин GrammarKit содержит уже реализацию и позволяет генерить лексер или из .bnf файла (про него будет ниже) или из .flex файла (при этом больше возможностей для настройки). Пример для языка Monkey можно посмотреть здесь, более сложный для Frege - здесь.

Чтобы сгенерить сам Lexer, нужно или настроить Gradle плагин, или воспользоваться контестным меню в .flex файле - "Run JFlex Generator".

После этого нужно объявить класс, реализующий com.intellij.lexer.Lexer. Для сгенерированного JFlex лексера уже существует адаптер - com.intellij.lexer.FlexAdapter

Парсер

В IDEA для создания парсера в основном используется кодогенерация плагином GrammarKit. К сожалению, документации по генерации парсера не так много и в основном она представлена в Tutorial и HOWTO.

Грамматика языка описывается в виде BNF. Единственное отличие, что используется ::= как "является".

Пример грамматики для выражений

Взят отсюда

{
  generate=[psi="no"]
  classHeader="//header.txt"
  parserClass="org.intellij.grammar.expression.ExpressionParser"
  extends(".*expr")=expr
  elementTypeFactory="org.intellij.grammar.expression.ExpressionParserDefinition.createType"
  tokenTypeFactory="org.intellij.grammar.expression.ExpressionParserDefinition.createTokenType"
  elementTypeHolderClass="org.intellij.grammar.expression.ExpressionTypes"
  parserUtilClass="org.intellij.grammar.parser.GeneratedParserUtilBase"

  tokens=[
    space='regexp:s+'
    comment='regexp://.*'
    number='regexp:d+(.d*)?'
    id='regexp:p{Alpha}w*'
    string="regexp:('([^'\]|\.)*'|"([^"\]|\.)*")"

    syntax='regexp:;|.|+|-|**|*|==|=|/|,|(|)|^|!=|!|>=|<=|>|<'
  ]
}
root ::= element *
private element ::= expr ';'?  {recoverWhile=element_recover}
private element_recover ::= !('(' | '+' | '-' | '!' | 'multiply' | id | number)

// left recursion and empty PSI children define expression root
expr ::= assign_expr
  | conditional_group
  | add_group
  | boolean_group
  | mul_group
  | unary_group
  | exp_expr
  | factorial_expr
  | call_expr
  | qualification_expr
  | primary_group
  {extraRoot=true}
private boolean_group ::= xor_expr | between_expr | is_not_expr

private conditional_group ::= elvis_expr | conditional_expr
private unary_group ::= unary_plus_expr | unary_min_expr | unary_not_expr
private mul_group ::= mul_expr | div_expr
private add_group ::= plus_expr | minus_expr
private primary_group ::= special_expr | simple_ref_expr | literal_expr | paren_expr

// expressions: auto-operator detection or parens
fake ref_expr ::= expr? '.' identifier
simple_ref_expr ::= identifier {extends=ref_expr elementType=ref_expr}
qualification_expr ::= expr '.' identifier {extends=ref_expr elementType=ref_expr}
call_expr ::= ref_expr arg_list
arg_list ::= '(' [ !')' expr  (',' expr) * ] ')' {pin(".*")=1}
literal_expr ::= number
identifier ::= id
unary_min_expr ::= '-' expr
unary_plus_expr ::= '+' expr
unary_not_expr ::= '!' expr
xor_expr ::= expr '^' expr
assign_expr ::= expr '=' expr { rightAssociative=true }
conditional_expr ::= expr ('<' | '>' | '<=' | '>=' | '==' | '!=') expr
div_expr ::= expr '/' expr
mul_expr ::= expr '*' expr
minus_expr ::= expr '-' expr
plus_expr ::= expr '+' expr
exp_expr ::= expr ('**' expr) + // N-ary variant
factorial_expr ::= expr '!'
paren_expr ::= '(' expr ')'
elvis_expr ::= expr '?' expr ':' expr
is_not_expr ::= expr IS NOT expr
between_expr ::= expr BETWEEN add_group AND add_group {
  methods=[testExpr="expr[0]"]
}

// test specific expressions
external special_expr ::= meta_special_expr
meta_special_expr ::= 'multiply' '(' simple_ref_expr ',' mul_expr ')' {elementType="special_expr" pin=2}

Как видно, bnf файл состоит из 2 частей: первая часть описывает метаинформацию (и описание токенов, если не используется flex файлы), вторая часть описывает саму грамматику.

Рассмотрим некоторую часть метаинформации:

parserClass - название и расположение генерируемого класса парсера

parserUtilClass - ссылка на класс, содержащий набор вспомогательных методов для парсера (как правило, класс com.intellij.lang.parser.GeneratedParserUtilBase или его наследник)

extends = <какой-то класс> - ссылка на базовый класс, от которого будут наследоваться все PSI-элементы (узлы дерева). Обычно com.intellij.extapi.psi.ASTWrapperPsiElement или его наследники.

extends(<regexp для узлов дерева>) = <psi-element> (например: extends(".*expr")=expr) - все psi-элементы будут наследоваться от указанного psi-элемента.

psiClassPrefix, psiImplClassSuffix - соответственно префикс классов и интерфейсов (обычно по имени языка) и суффикс для реализации интерфейсов (как правило - Impl)

psiPackage и psiImplPackage - соответственно пакет для интерфейсов и их реализаций.

implements- аналогично extends, но для интерфейсов

elementTypeHolderClass - генерируемое хранилище всех типов элементов

elementTypeClass - класс типов элеметов (не генерируется, наследник com.intellij.psi.tree.IElementType)

elementTypeFactory - создание фабрики для генерации типов элементов (используется для работы со Stub - о них ниже)

psiImplUtilClass - класс с набором статических методов, которые используются как имплементация требуемых методов для psi-элементов. Предположим, у нас есть такие строчки (из go-plugin)

ImportSpec ::= [ '.' | identifier ] ImportString {
  stubClass="com.goide.stubs.GoImportSpecStub"
  methods=[getAlias getLocalPackageName shouldGoDeeper isForSideEffects isDot getPath getName isCImport]
}

Для ImportSpec должен быть сгенерирован метод getAlias. Для этого в psiImplUtilClass должен быть объявлен соответствующий метод

public static String getAlias(@NotNull GoImportSpec importSpec)

а в самом классе будет просто вызов этого метода

  public String getAlias() {
    return GoPsiImplUtil.getAlias(this);
  }

Теперь перейдем к самим bnf правилам. Для каждого правила могут быть использованы модификаторы (например, private, fake и так далее). Их описание приведено здесь. Так например private в

private boolean_group ::= xor_expr | between_expr | is_not_expr

говорит о том, что PSI-элемент для boolean_group сгенерирован не будет.

Если не получается правильно описать грамматику в bnf файле, то есть возможность описать это в коде, используя внешние правила.

Одна из важных частей грамматики - правила работы с ошибками. Для этого используется два ключевых слова: pin, recoverWhile.

pin - указывает номер токена, как только мы доходим до которого, парсер начинает ожидать только текущее объявление. Например, объявление структуры в Golang

StructType ::= struct '{' Fields? '}' {pin=1}

recoverWhile - указывает, какие токены можно потреблять после завершения сопоставления со всеми правилами. Рекомендации по применению этого атрибута описаны здесь.

Также следует обратить внимание на рекомендации для парсинга выражений с учетом приоритета.

Как мне кажется, создание правильного и удобного описания грамматики для будущей работы - одна из самых сложных частей реализации плагина для языка. Чтобы начать, можно ориентироваться на примеры: go-plugin, Frege, Monkey (для Monkey с целью упрощения реализовано только подмножество этого языка).

После создания bnf файла и генерации из него парсера требуется определить класс файла (наследник от com.intellij.extapi.psi.PsiFileBase) (пример go-plugin, Frege, Monkey) и класс определения парсера (наследник от com.intellij.lang.ParserDefinition)(пример go-plugin, Frege, Monkey), и после этого подключить его через точку расширения.

<lang.parserDefinition language="Monkey"
implementationClass="com.github.pyltsin.monkeyplugin.parser.MonkeyParserDefinition"/>

Аннотаторы

В предыдущих частях мы посмотрели как создаются и работают лексер и парсер, которые отвечают, соответственно, за лексический и синтаксический анализ. Теперь перейдем к третьей части - семантический анализ. Изучая код IDEA и плагинов к ней, я нашел два способа его реализации (исключая инспекции).

Первый способ применен в плагине для языка Java. Рассмотрим следующий невалидный код:

Пишем свой плагин для IDEA для поддержки нового языка (часть 1) - 8

IDEA, конечно, его подсветила и сказала "Operator '-' cannot be applied to 'java.lang.String', 'java.lang.String'". Это работает благодаря следующей точки расширения:

<highlightVisitor implementation=
"com.intellij.codeInsight.daemon.impl.analysis.HighlightVisitorImpl"/>

Сам класс должен реализовывать интерфейс com.intellij.codeInsight.daemon.impl.HighlightVisitor

public interface HighlightVisitor {

  boolean suitableForFile(@NotNull PsiFile file);

  void visit(@NotNull PsiElement element);

  boolean analyze(@NotNull PsiFile file, 
                  boolean updateWholeFile, 
                  @NotNull HighlightInfoHolder holder, 
                  @NotNull Runnable action);
}

Метод analyze - используется для настройки, запуска подсветки (action.run()) и очистки ресурсов.

Метод visit выполняется при вызове action.run() и выполняет сам анализ.

//Реализация из HighlightVisitorImpl
	@Override
  public void visit(@NotNull PsiElement element) {
  // некоторый код
      element.accept(this);
  // некоторый код
  }

//Пример для класса ClsJavaModuleImpl, реализация accept
  @Override
  public void accept(@NotNull PsiElementVisitor visitor) {
    if (visitor instanceof JavaElementVisitor) {
      ((JavaElementVisitor)visitor).visitModule(this);
    }
    else {
      visitor.visitElement(this);
    }
  }

Как видно, здесь используется паттерн visitor. Сам класс HighlightVisitorImpl также расширяет JavaElementVisitor.

public abstract class JavaElementVisitor extends PsiElementVisitor {
  public void visitAnonymousClass(PsiAnonymousClass aClass) {
    visitClass(aClass);
  }

  public void visitArrayAccessExpression(PsiArrayAccessExpression expression) {
    visitExpression(expression);
  }

  public void visitArrayInitializerExpression(PsiArrayInitializerExpression expression) {
    visitExpression(expression);
  }
  //и еще много-много методов для каждого типа PSI-элемента

Второй способ применен в плагине go-plugin и Frege. В плагине Monkey я использовал тоже его. Он заключается в использовании точки расширения annotator

Подключение:

<annotator language="Monkey"
implementationClass="com.github.pyltsin.monkeyplugin.annotator.MonkeyWarningAnnotator"/>

Класс должен реализовывать интерфейс:

public interface Annotator {
  void annotate(@NotNull PsiElement element, 
                @NotNull AnnotationHolder holder);
}

Само сообщение об ошибке регистрируется следующим образом:

holder.newAnnotation(HighlightSeverity.ERROR, errorMsg)
.range(element)
.create()

Примеры для Frege, go-plugin, Monkey.

Для языка Monkey на данный момент реализовал 2 проверки - невозможность разрешить ссылки (resolve references - о них ниже) и простая проверка типов элементов (через DSL).

Подсветка скобок

В этой части мы рассмотрим еще пару точек расширений.

Первая точка расширения: lang.braceMatcher. Пример подключения:

<lang.braceMatcher language="Monkey"
implementationClass="com.github.pyltsin.monkeyplugin.editor.MonkeyBraceMatcher"/>

Эта точка расширения включает подсветку пары скобок и добавление закрывающей скобки

При наведении на скобку подсвечивается ее пара
При наведении на скобку подсвечивается ее пара

Класс должен реализовывать интерфейс com.intellij.lang.PairedBraceMatcher

Код интерфейса com.intellij.lang.PairedBraceMatcher
public interface PairedBraceMatcher {
  /**
   * Returns the array of definitions for brace pairs that need to be matched when
   * editing code in the language.
   *
   * @return the array of brace pair definitions.
   */
  @NotNull
  BracePair[] getPairs();

  /**
   * Returns true if paired rbrace should be inserted after lbrace of given type when lbrace is encountered before contextType token.
   * It is safe to always return true, then paired brace will be inserted anyway.
   * @param lbraceType lbrace for which information is queried
   * @param contextType token type that follows lbrace
   * @return true / false as described
   */
  boolean isPairedBracesAllowedBeforeType(@NotNull IElementType lbraceType, @Nullable IElementType contextType);

  /**
   * Returns the start offset of the code construct which owns the opening structural brace at the specified offset. For example,
   * if the opening brace belongs to an 'if' statement, returns the start offset of the 'if' statement.
   *
   * @param file the file in which brace matching is performed.
   * @param openingBraceOffset the offset of an opening structural brace.
   * @return the offset of corresponding code construct, or the same offset if not defined.
   */
  int getCodeConstructStart(final PsiFile file, int openingBraceOffset);
}

Релизация, которая была сделана мной для языка Monkey, можно посмотреть тут, для плагина go-plugin тут, для Java - тут и тут.

Вторая точка расширения: highlightVisitor . Я ее уже упоминал для создания семантического анализатора. В своем плагине я ее не использовал, но она используется в популярном плагине Rainbow Brackets, который раскрашивает пары скобок в уникальные цвета.

Пример из описания плагина  Rainbow Brackets
Пример из описания плагина Rainbow Brackets

Если посмотреть в его plugin.xml, то можно найти вот такую строчку

<highlightVisitor implementation="com.github.izhangzhihao.rainbow.brackets.visitor.DefaultRainbowVisitor"/>

Класс реализует интерфейс - com.intellij.codeInsight.daemon.impl.HighlightVisitor. Реализацию можно посмотреть здесь. Само раскрашивание происходит в методе com.github.izhangzhihao.rainbow.brackets.visitor.RainbowHighlightVisitor#setHighlightInfo

holder.add(HighlightInfo
    .newHighlightInfo(rainbowElement)
    .textAttributes(attr)
    .range(element)
    .create())

Продолжение тут.

Автор:
pyltsinm

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js