company_banner

Фантастические плагины, vol. 2. Практика

    Здесь можно почитать первую статью с теорией плагиностроения.


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


    О чем буду говорить?


    • Практическая часть
      • Многостраничный UI
      • DI в плагинах
      • Генерация кода
      • Модификация кода
    • Что делать дальше?
      • Советы
      • FAQ

    Многостраничный UI


    Первым делом нам нужно было создать многостраничный UI. Мы сделали первую сложную форму с кучей галочек, полей ввода. Чуть позже решили добавить возможность выбора списка модулей, которые пользователь сможет подключить к новому модулю. А еще мы хотим выбирать те application-модули, к которым планируем подключить созданный модуль.


    На одной форме располагать столько контролов не очень удобно, поэтому сделали три отдельные страницы, три отдельных формочки. Короче, Wizard-диалог.


    image


    Но поскольку делать многостраничный UI в плагинах очень больно, хотелось найти что-то готовое. И в недрах IDEA мы обнаружили класс, который называется WizardDialog.


    image


    Это класс-обертка над обычным диалогом, самостоятельно следящая за продвижением пользователя по wizard-у, и показывающая нужные кнопки (Previous, Next, Finish, etc). К WizardDialog-у прикрепляется специальная WizardModel, к которой добавляются отдельные WizardStep-ы. Каждый WizardStep представляет собой отдельную формочку.


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


    WizardDialog
    class MyWizardDialog(
        model: MyWizardModel,
        private val onFinishButtonClickedListener: (MyWizardModel) -> Unit 
    ): WizardDialog<MyWizardModel>(true, true, model) {
    
        override fun onWizardGoalAchieved() {
            super.onWizardGoalAchieved()
            onFinishButtonClickedListener.invoke(myModel)
        }
    
    } 

    Наследуемся от класса WizardDialog, параметризуем классом нашей WizardModel. У этого класса есть специальный callback (onWizardGoalAchieved), который говорит нам, что пользователь прошел wizard до конца и нажал на кнопку “Finish”.
    Важно отметить, что изнутри этого класса есть возможность достучаться только до WizardModel. Это означает, что все данные, которые пользователь наберет по ходу прохождения wizard-а, вы должны складывать в WizardModel.


    WizardModel
    class MyWizardModel: WizardModel("Title for my wizard") {
    
        init {
            this.add(MyWizardStep1())
            this.add(MyWizardStep2())
            this.add(MyWizardStep3())
        }
    
    }

    Модель выглядит следующим образом: наследуемся от класса WizardModel и при помощи встроенного метода add добавляем в диалог отдельные WizardStep-ы.


    WizardStep
    class MyWizardStep1: WizardStep<MyWizardModel>() {
    
        private lateinit var contentPanel: JPanel
    
        override fun prepare(state: WizardNavigationState?): JComponent {
            return contentPanel
        }
    
    }

    WizardStep-ы тоже устроены просто: наследуемся от класса WizardStep, параметризуем его нашим классом-моделью, и, главное, переопределяем метод prepare, который возвращает рутовый компонент вашей будущей формы.


    В простом виде он действительно выглядит так. Но в реальном мире, скорее всего, ваша форма будет напоминать что-то подобное:


    image


    Тут можно вспомнить те времена, когда мы в мире Android еще не знали, что такое Clean Architecture, MVP и писали весь код в одной Activity. Здесь есть новое поле для архитектурных баталий, и если вы захотите заморочиться, то можете внедрить собственную архитектуру для плагинов.


    Вывод


    Если вам нужен многостраничный UI, используйте WizardDialog – будет проще.


    Переходим к следующей теме – DI в плагинах.


    DI в плагинах


    Зачем вообще может потребоваться Dependency Injection внутри плагина?
    Первая причина – организация архитектуры внутри плагина.


    Казалось бы, зачем вообще соблюдать какую-то архитектуру внутри плагина? Плагин – это же утилитная вещь, раз написал – и все, забыл.
    Да, но нет.
    Когда ваш плагин разрастается, когда вы пишите много кода, вопрос о структурированности кода возникает сам собой. Тут DI может вам пригодиться.


    Вторая, более важная причина, – при помощи DI можно достучаться до компонент, написанных разработчиками других плагинов. Это могут быть event bus-ы, logger-ы и многое другое.


    Несмотря на то, что вы вольны использовать любой DI-фреймворк (Spring, Dagger, etc), внутри IntelliJ IDEA есть свой собственный DI-фреймворк, который основан на первых трех уровнях абстракции, о которых я уже говорил: Application, Project и Module.


    image


    С каждым из этих уровней связана своя абстракция, которая называется Component. Компонент нужного уровня создается на один инстанс объекта этого уровня. Так ApplicationComponent создается единожды на каждый инстанс класса Application, аналогично ProjectComponent на инстансы Project, и так далее.


    Что нужно сделать, чтобы использовать DI-фреймворк?


    Во-первых, создаем класс, который реализует один из нужных нам компонентов интерфейса – например, класс, который имплементирует ApplicationComponent, или ProjectComponent, или ModuleComponent. При этом у нас есть возможность заинжектить объект того уровня, чей интерфейс мы имплементируем. То есть, например, в ProjectComponent вы можете заинжектить объект класса Project.


    Создание классов-компонентов
    class MyAppComponent(
        val application: Application,
        val anotherApplicationComponent: AnotherAppComponent
    ): ApplicationComponent
    
    class MyProjectComponent(
        val project: Project,
        val anotherProjectComponent: AnotherProjectComponent,
        val myAppComponent: MyAppComponent
    ): ProjectComponent
    
    class MyModuleComponent(
        val module: Module,
        val anotherModuleComponent: AnotherModuleComponent,
        val myProjectComponent: MyProjectComponent,
        val myAppComponent: MyAppComponent
    ): ModuleComponent

    Во-вторых, есть возможность инжектить другие компоненты того же уровня или уровнем выше. То есть, например, в ProjectComponent вы можете заинжектить другие ProjectComponent или ApplicationComponent. Как раз здесь вы можете получить доступ к инстансам "чужих" компонентов.


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


    Следующая вещь, которую нужно будет сделать, – зарегистрировать компонент в файле plugin.xml. Как только вы реализуете один из Component-интерфейсов (например, ApplicationComponent), IDEA сразу предложит зарегистрировать ваш компонент в plugin.xml.


    Регистрируем компонент в plugin.xml
    <idea-plugin>
    
        ...
        <project-components>
            <component>
                <interface-class>
                    com.experiment.MyProjectComponent
                </interface-class>
    
                <implementation-class>
                    com.experiments.MyProjectComponentImpl
                </implementation-class>
            </component>
        </project-components>
    
    </idea-plugin>

    Как это делается? Появляется специальный тег <project-component> (<application-component>, <module-component> – в зависимости от уровня). Внутри него есть тег , в нем еще два тега: <interface-class>, где указывается имя интерфейса вашего компонента, и <implementation-class>, где указывается класс реализации. Один и тот же класс может быть как интерфейсом компонента, так и его реализацией, поэтому можно обойтись одним тэгом <implementation-class>.

    Последняя вещь, которую нужно сделать, – получить компонент из соответствующего объекта, то есть ApplicationComponent получаем из инстанса Application, ProjectComponent – из Project и т.д.


    Получаем компонент
    val myAppComponent = application.getComponent(MyAppComponent::class.java) 
    
    val myProjectComponent = project.getComponent(MyProjectComponent::class.java) 
    
    val myModuleComponent = module.getComponent(MyModuleComponent::class.java) 

    Выводы


    1. Внутри IDEA есть DI-фреймворк – не нужно тащить ничего самим: ни Dagger, ни Spring. Хотя, конечно, вы можете.
    2. При помощи этого DI можно достучаться до уже готовых компонентов, и это самый сок.

    Переходим к третьей задаче – генерации кода.


    Генерация кода


    Помните, в чек-листе у нас была задача сгенерировать очень много файлов? Каждый раз, когда мы создаем новый модуль, мы создаем пачку файлов: интеракторы, презентеры, фрагменты. При создании нового модуля эти компоненты очень похожи друг на друга, и хотелось бы научиться генерировать этот каркас автоматически.


    Шаблоны


    Как проще всего нагенерировать тонну похожего кода? Использовать шаблоны. Для начала нужно посмотреть на свои шаблоны и понять, какие требования выдвигать к генератору кода.


    Кусочек шаблона build.gradle файла
    apply plugin: 'com.android.library'
    
    <if (isKotlinProject) {
        apply plugin: 'kotlin-android'
        apply plugin: 'kotlin-kapt'
    
        <if (isModuleWithUI) {
            apply plugin: 'kotlin-android-extensions'
        }>
    }>
    
    ...
    android {
        ...
        <if (isMoxyEnabled) {
            kapt {
             arguments {
                 arg("moxyReflectorPackage", '<include var="packageName">')
             }
            }
        }>
        ...
    }
    
    ...
    dependencies {
        compileOnly project(':common')
        compileOnly project(':core-utils')
    
        <for (moduleName in enabledModules) {
            compileOnly project('<include var="moduleName">')
        }>
        ...
    }

    Первое: мы хотели иметь возможность использовать условия внутри этих шаблонов. Привожу пример: если плагин каким-то образом связан с UI, мы хотим подключать специальный Gradle-плагин kotlin-android-extensions.


    Условие внутри шаблона
    <if (isKotlinProject) {
        apply plugin: 'kotlin-android'
        apply plugin: 'kotlin-kapt'
    
        <if (isModuleWithUI) {
            apply plugin: 'kotlin-android-extensions'
        }>
    }>

    Вторая вещь, которую мы хотим, – возможность использовать переменную внутри этого шаблона. Например, когда мы настраиваем kapt для Moxy, мы хотим вставлять имя пакета в качестве аргумента annotation processor-а.


    Подставляем значение переменной внутри шаблона
    kapt {
        arguments {
            arg("moxyReflectorPackage", '<include var="packageName">')
        }
    }

    Еще одна вещь, которая нам нужна, – возможность обрабатывать циклы внутри шаблона. Помните форму, где мы выбирали список модулей, которые хотим подключить к создаваемому новому модулю? Мы хотим обходить их в цикле и добавлять одну и ту же строчку.


    Используем цикл в шаблоне
    <for (moduleName in enabledModules) {
        compileOnly project('<include var="moduleName">')
    }>

    Таким образом, мы выдвинули три условия к генератору кода:


    • Хотим использовать условия
    • Возможность подставлять значения переменных
    • Нам нужны циклы в шаблонах

    Генераторы кода


    Какие есть варианты реализации генератора кода? Можно, например, написать свой собственный генератор кода. Так сделали, например, ребята из Uber-а: написали свой плагин для генерации риблетов (так называются их архитектурные единицы). Они придумали свой язык шаблонов, в котором использовали только возможность вставки переменных. Условия они вынесли на уровень генератора. Но мы подумали, что так делать не будем.


    Второй вариант – использовать встроенный в IDEA утилитный класс FileTemplateManager, но я бы не рекомендовал этого делать. Потому что в качестве движка у него – Velocity, у которого есть некоторые проблемы с пробросом Java-объектов в шаблоны. Кроме того, FileTemplateManager не умеет из коробки генерировать файлы, отличающиеся от Java или XML. А нам нужно было генерировать и Groovy-файлы, Kotlin, Proguard и другие типы файлов.


    Третьим вариантом стал… FreeMarker. Если у вас есть готовые шаблоны FreeMarker, не спешите их выбрасывать – они могут вам пригодиться внутри плагина.


    Что нужно сделать, что использовать FreeMarker внутри плагина? Во-первых, добавить шаблоны файлов. Вы можете создать папку /templates внутри папки /resources и добавлять туда все наши шаблоны для всех файлов – презентеров, фрагментов и т.п.


    image


    После этого нужно будет добавить зависимость от библиотеки FreeMarker. Поскольку плагин использует Gradle, добавить зависимость не составляет труда.


    Добавляем зависимость от библиотеки FreeMarker
    dependencies {
        ...
        compile 'org.freemarker:freemarker:2.3.28'
    }

    После этого конфигурируем FreeMarker внутри нашего плагина. Советую просто скопировать вот эту конфигурацию – она вымучена, выстрадана, копируйте ее, и все просто заработает.


    Конфигурация FreeMarker
    class TemplatesFactory(val project: Project) : ProjectComponent {
    
      private val freeMarkerConfig by lazy {
          Configuration(Configuration.VERSION_2_3_28).apply {
              setClassForTemplateLoading(
                  TemplatesFactory::class.java, 
                  "/templates"            
              )
    
              defaultEncoding = Charsets.UTF_8.name()
              templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER
              logTemplateExceptions = false
              wrapUncheckedExceptions = true
          }
      }
    
    ...

    Пора создавать файлы с помощью FreeMarker. Для этого мы получаем из конфигурации шаблон по его имени и при помощи обычного FileWriter создаем файл с нужным текстом прямо на диске.


    Создание файла через FileWriter
    class TemplatesFactory(val project: Project) : ProjectComponent {
    ...
    
      fun generate(
        pathToFile: String, 
        templateFileName: String, 
        data: Map<String, Any>
      ) {
          val template = freeMarkerConfig.getTemplate(templateFileName)
    
          FileWriter(pathToFile, false).use { writer ->
              template.process(data, writer)
          }
      }
    
    }

    И вроде бы задача решена, но нет. В теоретической части я упоминал, что структурой PSI пронизана вся IDEA, и это нужно учитывать. Если вы будете создавать файлы в обход структуры PSI (например, через FileWriter), то IDEA просто не поймет, что вы что-то создали и не отобразит файлы в дереве проекта. Мы ждали около семи минут, прежде чем IDEA проиндексировала и увидела созданные файлы.


    Вывод – делайте правильно, создавайте файлы, учитывая структуру PSI.


    Создаем PSI-структуру для файлов


    Для начала пилим структуру папок с помощью PsiDirectory. Стартовую директорию проекта можно получить с помощью extension-функций guessProjectDir и toPsiDirectory:


    Получаем PsiDirectory проекта
    val projectPsiDirectory = project.guessProjectDir()?.toPsiDirectory(project)

    Последующие директории можно либо находить с помощью метода класса PsiDirectory findSubdirectory, либо создавать методом createSubdirectory.


    Находим и создаем PsiDirectory
    val coreModuleDir = projectPsiDirectory.findSubdirectory("core")
    val newModulePsiDir = coreModuleDir.createSubdirectory(config.mainParams.moduleName)

    Также я рекомендую вам создать Map-ку, из которой по строковому ключу можно получать все PsiDirectory структуры папок, чтобы потом добавлять созданные файлы в любую из этих папок.


    Создаем Map-ку структуры папок

    return mutableMapOf<String, PsiDirectory?>().apply {
    this["root"] = modulePsiDir
    this["src"]= modulePsiDir.createSubdirectory("src")
    this["main"] = this["src"]?.createSubdirectory("main")
    this["java"] = this["main"]?.createSubdirectory("java")
    this["res"] = this["main"]?.createSubdirectory("res")


    // функция создаст PsiDirectory для указанного package name:
    // ru.hh.feature_worknear → ru / hh / feature_worknear
    createPackageNameFolder(config) 
    
    // data
    this["data"] = this["package"]?.createSubdirectory("data")
    
    // ...

    }


    Папки создали. Создавать PsiFile-ы будем при помощи PsiFileFactory. У этого класса есть специальный метод, который называется createFileFromText. Метод принимает на вход три параметра: имя (String fileName), текст (String text) и тип (FileType fileType) output-файла. Два из трех параметров понятно откуда брать: имя мы знаем сами, текст получим из FreeMarker-а. А откуда брать FileType? И что это вообще?


    FileType


    FileType – это специальный класс, обозначающий тип файла. Из “коробки” нам доступны всего два FileType-а: JavaFileType и XmlFileType, соответственно для Java-файлов и XML-файлов. Но возникает вопрос: откуда взять типы для build.gradle файла, для Kotlin-файлов, для Proguard, для .gitignore, наконец?!


    Во-первых, большую часть этих FileType-ов можно взять из других плагинов, которые уже кем-то написаны. GroovyFileType можно взять из Groovy-плагина, KotlinFileType – из Kotlin-плагина, Proguard – из Android-плагина.


    Как мы добавляем зависимость другого плагина к нашему? Мы используем gradle-intellij-plugin. Он добавляет в build.gradle файл плагина специальный блок intellij,, внутри которого есть специальное свойство – plugins. В это свойстве можно перечислить список идентификаторов плагинов, от которых мы хотим зависеть.


    Добавляем зависимости от других плагинов
    // build.gradle плагина
    
    intellij {
        …
        plugins = ['android', 'Groovy', 'kotlin']
    }

    Ключи берем из официального репозитория плагинов JetBrains. Для встроенных в IDEA плагинов (коими являются Groovy, Kotlin и Android) достаточно названия папки плагина внутри IDEA. Для остальных потребуется зайти на страничку определенного плагина в официальном репозитории плагинов JetBrains, там будет указано свойство Plugin XML ID, а также версия (например, вот страничка Docker-плагина). Подробнее про подключение других плагинов читайте на GitHub-е.


    Во-вторых, нужно добавить описание зависимости в файл plugin.xml. Это делается при помощи тега .

    Подключаем плагины в plugin.xml
    <idea-plugin>
    
        ...
    
        <depends>org.jetbrains.android</depends>
        <depends>org.jetbrains.kotlin</depends>
        <depends>org.intellij.groovy</depends>
    
    </idea-plugin>

    После того, как синхронизируем проект, у нас подтянутся зависимости с других плагинов, и мы сможем их использовать.


    Но что, если мы не хотим зависеть от других плагинов? В таком случае мы можем создать заглушку для нужного нам типа файла. Для этого сначала создаем класс, который будет наследоваться от класса Language. В этот класс будет передаваться уникальный идентификатор нашего языка программирования (в нашем случае – "ru.hh.plugins.Ignore").


    Создаем язык для GitIgnore-файлов
    class IgnoreLanguage private constructor() 
        : Language("ru.hh.plugins.Ignore", "ignore", null), 
          InjectableLanguage {
    
        companion object {
            val INSTANCE = IgnoreLanguage()
        }
    
        override fun getDisplayName(): String {
            return "Ignore() ($id)"
        }
    }

    Здесь есть особенность: некоторые разработчики в качестве идентификатора добавляют неуникальную строчку. Из-за этого интеграция вашего плагина с другими плагинами может ломаться. Мы молодцы, у нас строчка уникальная.


    Следующая вещь, которую нужно сделать после того, как мы создали Language, – создать FileType. Наследуемся от класса LanguageFileType, используем для инициализации инстанс языка, который мы определили, переопределяем несколько очень простых методов. Готово. Теперь мы можем использовать новый созданный FileType.


    Создаем собственный FileType для .gitignore
    class IgnoreFileType(language: Language) : LanguageFileType(language) {
    
        companion object {
            val INSTANCE = IgnoreFileType(IgnoreLanguage.INSTANCE)
        }
    
        override fun getName(): String = "gitignore file"
    
        override fun getDescription(): String = "gitignore files"
    
        override fun getDefaultExtension(): String = "gitignore"
    
        override fun getIcon(): Icon? = null
    
    }
    

    Завершаем создание файла


    После того как вы найдете все необходимые FileType-ы, я рекомендую создать специальный контейнер под названием TemplateData – он будет содержать в себе все данные о шаблоне, из которого вы хотите сгенерировать код. В нем будут храниться название файла-шаблона, название output-файла, который вы получите после генерации кода, нужный FileType и, наконец, PsiDirectory, куда вы добавите созданный файл.


    TemplateData
    data class TemplateData(
        val templateFileName: String,
        val outputFileName: String,
        val outputFileType: FileType,
        val outputFilePsiDirectory: PsiDirectory?
    )

    Затем возвращаемся к FreeMarker-у – достаем из него файл-шаблон, при помощи StringWriter получаем текст, в PsiFileFactory генерируем PsiFile с нужным текстом и типом. Созданный файл добавляем в требуемую директорию.


    Создаем PsiFile в нужной папке
    fun createFromTemplate(data: FileTemplateData, properties: Map<String, Any>): PsiFile {
        val template = freeMarkerConfig.getTemplate(data.templateFileName)
    
        val text = StringWriter().use { writer ->
            template.process(properties, writer)
            writer.buffer.toString()
        }
    
        return psiFileFactory.createFileFromText(data.outputFileName, data.outputFileType, text)
    }

    Таким образом PSI-структура учтена, и IDEA, а также другие плагины увидят, что мы сделали. От этого может быть профит: например, если плагин для Git увидит, что вы добавили новый файл, он самостоятельно покажет диалоговое окно с вопросом: не хотите ли вы добавить эти файлики в Git?


    Выводы про генерацию кода


    • Текст файлов можно генерировать FreeMarker-ом. Очень удобно.
    • При генерации файлов нужно учитывать PSI-структуру, иначе все пойдет не так.
    • Если вы захотите генерировать файлы при помощи PsiFileFactory, вам придется где-то найти FileType-ы.

    Ну а теперь переходим к последней, самой вкусной практической части – это модификация кода.


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


    На самом деле создавать плагин только для генерации кода – бред, потому что генерировать код можно и другими инструментами, да тем же FreeMarker-ом. Но вот чего FreeMarker-ом сделать нельзя – так это модифицировать код.


    В нашем чек-листе есть несколько задач, связанных с модификацией кода, начнем с самой простой — модификации settings.gradle файла.


    Модификация settings.gradle


    Напомню, что мы хотим сделать: нам нужно добавить в этот файл пару строк, которые будут описывать путь к новому созданному модулю:


    Описание пути к модулю
    // settings.gradle
    
    include ':analytics
    project(':analytics').projectDir = new File(settingsDir, 'core/framework-metrics/analytics)
    
    ...
    
    include ':feature-worknear'
    project(':feature-worknear').projectDir = new File(settingsDir, 'feature/feature-worknear')

    Я чуть раньше напугал вас, что нужно всегда обязательно учитывать PSI-структуру, когда работаешь с файлами, иначе все сгорит не заработает. На самом деле, в простых задачах, типа добавления пары строк в конец файла, этого можно не делать. Добавить какие-то строчки в файл можно, использовав обычный java.io.File. Для этого мы находим путь к файлу, создаем инстанс java.io.File, и при помощи котлиновских extension-функций добавляем две строчки в конец этого файла. Так делать можно, IDEA увидит ваши изменения.


    Добавление строчек в settings.gradle файл

    val projectBaseDirPath = project.basePath ?: return
    val settingsPathFile = projectBaseDirPath + "/settings.gradle"


    val settingsFile = File(settingsPathFile)


    settingsFile.appendText("include ':$moduleName'")
    settingsFile.appendText(
    "project(':$moduleName').projectDir = new File(settingsDir, '$folderPath')"
    )


    Ну а в идеале, конечно, лучше через PSI-структуру – это надежнее.


    Донастройка kapt-а для Toothpick


    Вновь напомню задачу: в application-модуле есть build.gradle файл, а внутри него – настройки annotation-процессора. И мы хотим в конкретное место добавить пакет нашего созданного модуля.


    Куда-куда?..

    image


    Наша цель – найти определенный PsiElement, после которого мы планируем добавить нашу строку. Поиск элемента начинается с поиска PsiFile-а, который обозначает build.gradle файл application-модуля. А для этого нужно найти модуль, внутри которого будем искать файл.


    Ищем модуль по имени
    val appModule = ModuleManager.getInstance(project)
                         .modules.toList()
                         .first { it.name == "headhunter-applicant" }

    Далее при помощи утилитного класса FilenameIndex можно найти PsiFile по его имени, указав в качестве области поиска найденный модуль.


    Ищем PsiFile по имени
    val buildGradlePsiFile = FilenameIndex.getFilesByName(
                                 appModule.project, 
                                 "build.gradle", 
                                 appModule.moduleContentScope
                             ).first() 

    После того как мы найдем PsiFile, можно приступать к поискам PsiElement-а. Чтобы его найти, я рекомендую установить специальный плагин – PSI Viewer. Этот плагин добавляет в IDEA специальную вкладку, там можно увидеть PSI-структуру открытого файла.


    image


    Если вы откроете какой-нибудь файл (например, build.gradle) и переместите курсор на интересующую вас строку кода, этот плагин перенесет вас в PSI-структуре к соответствующему элементу.


    image


    Это очень удобно – вы сможете понять, какой конкретно элемент вы ищете внутри вашего PsiFile-а.


    Возвращаемся к нашей задаче. Мы нашли PsiFile. Внутри него при помощи вот такой простыни вы можете найти нужный элемент.


    Находим нужный PsiElement
    val toothpickRegistryPsiElement = buildGradlePsiFile.originalFile
        .collectDescendantsOfType<GrAssignmentExpression>()
        .firstOrNull { it.text.startsWith("arguments") }
        ?.lastChild
        ?.children?.firstOrNull { it.text.startsWith("toothpick_registry_children_package_names") }
        ?.collectDescendantsOfType<GrListOrMap>()
        ?.first()
        ?: return

    Кто здесь?.. Что здесь происходит? Мы последовательно спускаемся к нужному элементу с самого верха PSI-дерева. Сначала получаем всех потомков дерева с типом GrAssignmentExpression, затем выбираем тот, который описывает выражение arguments = [ … ]. После спускаемся от этого элемента глубже, находим среди его потомков элемент toothpick_registry_children_package_names = [...], и достаем из него сам элемент Groovy-мапки.


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


    Для каждого языка программирования PSI-элементы уникальны, а значит для создания нужно использовать PsiElementFactory того языка программирования, в чьем контексте работаем. Модифицируем Java-файл? Нужна фабрика для Java-элементов. Работам с Groovy? Тогда GroovyPsiElementFactory. И так далее.


    Брать PsiElementFactory нужного языка проще всего из других плагинов. Поскольку мы уже подключили зависимости от Groovy и Kotlin плагина, фабрики элементов этих языков у нас в кармане.


    Создаем PsiElement с package name
    val factory = GroovyPsiElementFactory.getInstance(buildGradlePsiFile.project)
    
    val packageName = config.mainParams.packageName
    val newArgumentItem = factory.createStringLiteralForReference(packageName)

    Осталось только добавить созданный элемент в найденный ранее PsiElement.


    Добавляем строчку в Map-ку
    targetPsiElement.add(newArgumentItem)

    Донастройка kapt-а для Moxy в application модуле


    Последняя задача из чек-листа, связанная с модификацией кода, – донастройка kapt-а для Moxy в application модуле. Напоминаю задачу: мы хотим добавить название пакета нового модуля в аннотацию @RegisterMoxyReflectorPackages.


    Куда-куда?

    image


    На самом деле эту задачу можно решить так же, как предыдущую: найти PsiFile, найти PsiElement, модифицировать его… Но я покажу немного другой способ, чтобы продемонстрировать еще несколько возможностей при работе с PsiElement-ами.


    Можно было пойти следующим путем: отыскать класс, который помечен аннотацией @RegisterMoxyReflectorPackages, затем получить список значений в атрибуте value этой аннотации, модифицировать его и пересоздать аннотацию с нуля с новым списком.


    Начнем с поиска модуля, в котором мы будем искать наш файл. Затем, при помощи утилитного класса PsiManager, мы найдем PsiClass нужной аннотации.


    Находим PsiClass аннотации @RegisterMoxyReflectorPackages
    val appModule = ModuleManager.getInstance(project)
                         .modules.toList()
                         .first { it.name == "headhunter-applicant" }
    
    val psiManager = PsiManager.getInstance(appModule.project)
    
    val annotationPsiClass = ClassUtil.findPsiClass(
        psiManager,
        "com.arellomobile.mvp.RegisterMoxyReflectorPackages"
    ) ?: return

    С помощью утилитного класса AnnotatedMembersSearch ищем все классы, отмеченные этой аннотацией внутри заданного модуля.


    Ищем класс, отмеченный аннотацией
    val annotatedPsiClass = AnnotatedMembersSearch.search(
            annotationPsiClass,
            appModule.moduleContentScope
        ).findAll()
        ?.firstOrNull() ?: return

    Отыскав класс, получаем PsiElement самой аннотации, достаем из него список значений внутри атрибута value. Затем формируем обновленный список пакетов, который потом используем для пересоздания аннотации.


    Получаем список пакетов для новой версии аннотации
    val annotationPsiElement = (annotatedPsiClass
        .annotations
        .first() as KtLightAnnotationForSourceEntry
    ).kotlinOrigin
    
    val packagesPsiElements = annotationPsiElement
        .collectDescendantsOfType<KtValueArgumentList>()
        .first()
        .collectDescendantsOfType<KtValueArgument>()
    
    val updatedPackagesList = packagesPsiElements
                                .mapTo(mutableListOf()) { it.text }
                                .apply { this += "\"${config.packageName}\"" }
    
    val newAnnotationValue = updatedPackagesList.joinToString(separator = ",\n")

    При помощи KtPsiFactory создаем новый PsiElement – аннотацию с обновленным списком и заменяем старую аннотацию на новую.


    Пересоздаем аннотацию и заменяем старую на новую
    val kotlinPsiFactory = KtPsiFactory(project)
    val newAnnotationPsiElement = kotlinPsiFactory.createAnnotationEntry(
        "@RegisterMoxyReflectorPackages(\n$newAnnotationValue\n)"
    )
    
    val replaced = annotationPsiElement.replace(newAnnotationPsiElement)

    Задача решена.


    Что может пойти не так? Может слететь наш code style. Не волнуйтесь, для решения этой задачи внутри IDEA тоже есть утилитный класс: CodeStyleManager.


    Исправляем code style
    CodeStyleManager.getInstance(module.project).reformat(replacedElement)

    Все задачи нашего чек-листа мы решили, резюмируем часть про модификацию кода.


    Выводы


    • Модифицировать код внутри плагина возможно, но придется делать это через PSI-структуру, а значит придется в ней разобраться.
    • Помните, что PSI привязан к конкретному языку программирования, и чтобы все получилось, нужно использовать правильные PsiElement-ы.

    Что делать дальше?


    Давайте подведем итоги.


    • Разрабатывать плагин не очень сложно – по крайней мере теперь, когда вы прочитали эту статью до конца.
    • Плагины действительно помогают автоматизировать часть ваших задач. Например у нас получилось автоматизировать создание модулей. Тут свой профит: в нашем случае мы смогли ускорить создание модуля почти в два раза.
    • Откуда брать информацию по плагинам? Я советую просто посмотреть чужие плагины. К сожалению, документация по плагинам IDEA не так хороша, как хотелось бы. В ней есть очень много теоретической информации, которая может не пригодиться с практической точки зрения. — Чтобы решить какую-то конкретную задачу, просто ищите на GitHub чужие плагины. Изучайте их, и вы найдете то, что нужно.
    • Если вам нужен какой-то утилитный класс – скорее всего внутри IntelliJ IDEA он давно есть. Набираете необходимое, добавляете слово Util или Manager и, скорее всего, вы найдете утилитный класс, который решит вашу задачу.
    • И последний совет: проводите отладку на маленьких проектах. К сожалению, задача runIde, которая запускает отдельный инстанс IDEA, поднимается очень долго. Она очень неповоротливая, и если вы попытаетесь отладить ваш плагин на проекте уровня hh.ru, проверка ваших изменений будет проходить довольно долго.

    На этом все. Форкайте наш проект, смотрите изнутри, задавайте вопросы – ответим.


    FAQ


    • Много ли времени потратили на разработку плагина?

    Этой задачей я занимался по остаточному принципу, поэтому по времени это все довольно растянулось. Если же попытаться подсчитать чистое время, то около 2 или 3 недель.


    • Сталкивались ли с проблемами при обновлении IDEA на новую версию, ломалось ли что-нибудь в плагине?

    Да, при обновлении версии IDEA некоторые части IDEA SDK меняются, методы становятся deprecated, некоторые вообще исчезают, приходится подстраиваться. Но у SDK-шных методов хорошая документация, там обычно пишут, на что нужно заменить вызов.


    • Были ли проблемы интеграции плагина с уже установленными плагинами?

    Только один раз – при работе с gitignore файлами. Как раз из-за неуникального идентификатора языка.


    • Были ли проблемы с запуском плагина под разные операционные системы?

    Мы запускали свой плагин на Android Studio в Mac OS, а также под Ubuntu, проблем не было. Слышал, что бывают проблемы с Windows, но мы не пробовали.

    HeadHunter
    101,62
    HR Digital
    Поделиться публикацией

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое