Иногда простые вещи очень утомляют, особенно когда их необходимо делать постоянно. Одна из таких вещей при работе с фреймворком Moxy — это добавление стратегий к функциям. Для ускорения этого процесса был написан плагин, который по "alt+enter" предоставляет выбор стратегии если ее нет или диалог с заменой на другую стратегию. Те, кто хочет узнать как это работает, добро пожаловать под кат.



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


В этот момент я заинтересовался созданием плагинов, и adev_one подсказал мне, что плагин как раз и может решить такую проблему.


Inspection


Итак, мы написали функцию и хотим добавить стратегию. В этот момент нам на помощь приходит code inspections. Они анализируют код текущего файла при помощи PSI (Program Structure Interface), и если что то не так, подсвечивают код красным или серым, и по alt+enter предлагают возможные исправления.
Официальная документация по разработке code inspections.
PSI — это структура, в которой у каждого выражения, ключевого слова и т.д. есть свой аналог, который содержит информацию о нем, его parent и child. Для того, чтобы понять какой класс соответствует необходимой конструкции языка, есть хороший плагин Psi viewer plugin.


Рассмотрим для примера с помощью него структуру интерфейса с функцией.



Видим, что интересующий нас класс это KtNamedFunction.


Рассмотрим создание Inpection.


Как создавать проект и не только хорошо расписано в цикле статей. Дополнительно понадобятся зависимости в файле plugins.xml


    <depends>org.jetbrains.kotlin</depends>
    <depends>com.intellij.modules.lang</depends>

и в файле build.gradle


dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
    implementation "org.jetbrains.kotlin:kotlin-reflect"
    implementation "com.github.moxy-community:moxy:1.0.13"
}

intellij {
 ...
    plugins = ["Kotlin"]
 ...
}

Перейдем к созданию inpection.


Для начала необходимо наследовать AbstractKotlinInspection


class MvpViewStrategyInspection : AbstractKotlinInspection() {}

В нем нам понадобится переопределить следующую функцию:


override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor 

В ней мы создаем Visitor, который будет анализировать текущий код файла и добавлять ошибки и предупреждения.


Хорошей документации по Visitor я не нашел, но когда смотрел плагины kotlin наткнулся на очень удобные функции-фабрики для создания Visitor. Для анализа KtNamedFunction есть следующая фабрика:


org.jetbrains.kotlin.psi VisitorWrappersKt.class 
public fun namedFunctionVisitor(block: (KtNamedFunction) → Unit): KtVisitorVoid

В лямбде нужно проанализировать PSI: выбрана ли функция без аннотации и является ли она частью интерфейса, который реализует интерфейс MvpView. Когда все условия выполнены, регистрируем ошибку с помощью ProblemsHolder.
В итоге выглядит это вот так:


private val fixes = MoxyStrategy.values().map { AddStrategyFix(it) }.toTypedArray()

override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
        return namedFunctionVisitor { ktNamedFunction ->
            if (!ktNamedFunction.isClassInheritMvpView() ||
                ktNamedFunction.isHasMoxyAnnotation()
            ) return@namedFunctionVisitor
            holder.registerProblem(
                ktNamedFunction, // psiElement
                StrategyIntentionType.MissingStrategy.title,  // description
                *fixes // LocalQuickFix
            )
        }
    }

Что такое AddStrategyFix? Это объект, который наследует LocalQuickFix. Он отвечает за исправления кода и выполнится, когда будет выбран из предложенных пунктов:


class AddStrategyFix(
        private val strategy: MoxyStrategy
    ) : LocalQuickFix {
        override fun getFamilyName(): String = "add ${strategy.className}"

        override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
            val ktFunction = (descriptor.psiElement as KtNamedFunction)
            val editor = ktFunction.getProjectEditor()
            ktFunction.addStrategyAnnotation(strategy, project, editor)
        }
    }

getFamilyName — определяет название пункта, который будет в диалоге.
В applyFix очевидно нужно выполнить исправление. В нашем случае добавить аннотацию.


Осталось зарегистрировать Inspection в plguin.xml, (похоже Activity в Manifest)


 <extensions defaultExtensionNs="com.intellij">
        <localInspection language="kotlin"
                         displayName="missing strategy for function"
                         groupPath="Moxy"
                         groupBundle="messages.InspectionsBundle"
                         groupKey="group.names.probable.bugs"
                         enabledByDefault="true"
                         level="ERROR"
                         implementationClass="com.maksimnovikov.inspection.MvpViewStrategyInspection"/>
    </extensions>

В итоге получаем:



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


Для стратегии AddToEndSingleTagStrategy дополнительно сделал добавление второго аргумента
tag и перенос курсора на него.



Intention


Функцию написали, стратегию быстро добавили. Передумали и решили заменить ее на другую.
Теперь наш помощник это Intention.
Принцип работы аналогичен с Inspection, но без какого либо выделения кода и анализируется только текущий выбранный элемент под кареткой. Как использовать уже имеющиеся intention. Официальная документация по разработке.


Рассмотрим создание Intention.


Необходимо наследовать PsiElementBaseIntentionAction :


class MvpViewStrategyIntention : PsiElementBaseIntentionAction(){
    override fun getText(): String 
    override fun getFamilyName(): String
    override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean
    override fun invoke(project: Project, editor: Editor?, element: PsiElement)
}

getText — определяет отображаемое название
getFamilyName — нужен для иерархии intention и отображения в настройках
isAvailable — в этой функции исходя из текущего элемента под курсором, необходимо определить будет ли добавлен intention в текущий список или нет.
invoke — вызывается, когда мы выбрали данный intention из списка


Рассмотрим подробнее isAvailable и invoke:


isAvailable


В этой функции нужно как можно раньше определить, что intention не нужно добавлять и не анализировать дальше. Это увеличит производительность.
Так же текущий выбранный элемент может быть child текущий функции, потому необходимо получить саму функцию. Остальные проверки аналогичны.


override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean {
        if ((element.containingFile ?: return false) !is KtFile) return false
        if (!element.isClassInheritMvpView()) return false
        val ktFunction = element.getParentOfType<KtNamedFunction>() ?: return false
        return ktFunction.isHasMoxyAnnotation()
    }

invoke


Поскольку добавление отдельного Intention для каждой стратегии не выгодно по производительности, необходимо п��казать еще один диалог по нажатию на один пункт.
Под капотом наша любимая IDE использует swing. Соответственно, кто хорошо с ним знаком, может создавать разнообразный интерфейс. Я пошел другим путем и использовал простой билдер для диалога выбора — JBPopupFactory


fun <T> Editor.showSelectPopup(
    items: List<T>,
    onSelected: (T) -> Unit
) {
    JBPopupFactory.getInstance()
        .createPopupChooserBuilder(items)
        .setRequestFocus(true)
        .setCancelOnClickOutside(true)
        .setItemChosenCallback { onSelected(it) }
        .createPopup()
        .showInBestPositionFor(this)
}

Однако, после создания диалога контекст сменяется на другой, который уже не может изменять код и при попытки сделать это выдает ошибку. Чтобы это исправить дополнительно оборачиваем действие по замене стратегии в функцию WriteCommandAction.runWriteCommandAction(project) {}


  override fun invoke(project: Project, editor: Editor, element: PsiElement) {
        editor.showSelectPopup(MoxyStrategy.values().toList()) { selectedStrategy ->
            WriteCommandAction.runWriteCommandAction(project) {
                val ktFunction = element.getParentOfType<KtNamedFunction>() ?: return@runWriteCommandAction
                ktFunction.replaceAnnotation(selectedStrategy, project, editor)
            }
        }
    }

Так же регистрируем intention в plugin.xml


<extensions defaultExtensionNs="com.intellij">
        <intentionAction>
            <className>com.maksimnovikov.intention.MvpViewStrategyIntention</className>
            <category>Moxy intentions</category>
        </intentionAction>
</extensions>

В итоге получаем следующий функционал:




Итого


С этим плагином решается проблема запоминания названий стандартных стратегий. Их список всегда виден. Если принцип работы стратегии поначалу может быть не понятен, возможно вставить её и перейти к исходному коду, где есть документация. Также в общем улучшается удобство работы со стратегиями Moxy.


Создание плагинов раньше для меня казалось очень сложным и непонятным. В основном из-за отсутствия примеров и недостаточности документации на официальном сайте. Это и подтолкнуло меня написать эту статью. Надеюсь создание плагинов стало для вас чуть более понятным. Буду рад конструктивной критике и обратной связи.


Полный код проекта можно посмотреть здесь


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