[Перевод] Улучшаем автокомплит в смешанных Java-Kotlin проектах

Честно говоря, не знаю, нужно ли ставить тэг «перевод» на собственную статью.

Ну ок, поставил.

Всем привет! Недавно я наткнулся на статью, где описывается, как можно убрать мешающие варианты из автокомплита в Android Studio. Этот способ касается только классов — с методами у меня так же не получилось, и тогда мне пришла идея.

Как-то раз я дизайнил публичный API Kotlin-библиотеки, чтобы клиенты на Java могли пользоваться ей бесшовно, как и клиенты на Kotlin (ну, насколько это возможно). Если вы используете Kotlin, то, возможно, знаете, что для data-классов компилятор кучу всего генерирует за нас, в том числе функции componentN() для деструктуризации параметров primary-конструктора.

Деструктуризация это фича языка, благодаря которой можно объявить сразу несколько переменных, производных от одного источника. Допустим, если у нас есть val pair = Pair("value1", 42), мы можем вызвать val (a, b) = pair. Это работает, когда у класса объявлены функции-операторы component1(), component2(), и т.п., причем как member, так и extension.

Так вот, такие сгенерированные функции в data-классах как бы неявные, и если вы взаимодействуете с data-классом из Kotlin, то всё хорошо:

a6742ec1891d257ceed879c212fa5ffe.png

А из Java это выглядит вот так:

Тут появились функции componentN() , которых не было при вызове из Kotlin-файла

Тут появились функции componentN(), которых не было при вызове из Kotlin-файла

И это одна из причин, почему я тогда заменил data-классы публичного API на обычные с переопределенными методами из kotlin.Any, благодаря чему всё это полотно со скрина выше пропало. Если вы тоже так хотите — у меня хорошие новости! Можно скачать мой плагин с JetBrains Marketplace, добавить его себе в Android Studio или IntelliJ IDEA, похвалить поделиться конструктивным фидбэком!

А если вы хотите сами сделать такой же, го под кат.

На самом деле такую функциональность сделать довольно просто. Во-первых, когда вы создаете плагин в IDEA, куча всего, как оно обычно и бывает, сразу же создается под капотом. Нажимаем New Project и затем выбираем IDE Plugin в секции Generators. Тут нам интересны 2 файла — build.gradle.kts и plugin.xml.

Первый — стандартный конфигурационный файл сборки, в нем вы можете поправить цифры в версиях, если нужно. Сразу заметим сгенерированный плейсхолдер для зависимостей плагина (plugins.set(listOf(/* Plugin Dependencies */))), давайте их укажем: plugins.set(listOf("java", "org.jetbrains.kotlin")) и синхронизируем проект.

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

Внутри тэга добавим следующее:

your.package.KotlinDataClassCompletionContributor это путь к нашей кастомной реализации CompletionContributor, которую нам еще предстоит создать.

Если IDE выдает ошибку и что-либо выделяет красным, синхронизируем проект.

Рядом с уже сгенерированным для нас блоком добавляем зависимости, как мы делали это на предыдущем шаге в build.gradle.kts:

...
org.jetbrains.kotlin
com.intellij.modules.java

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

881e88cbdc9f5a2e0c182a4962c71a42.png

С дебагом тут, кстати, тоже интересно. Если вы запустите конфигурацию Run Plugin, откроется новый инстанс IDEA (в данном случае 2023.2.6, отсюда, хоть у меня стоит 2024.2.1), но уже с включенным плагином.

Теперь к главному — реализуем CompletionContributor. Посмотреть сорсы можно тут, там немного и всё довольно тривиально. Перейдем сразу к самому интересному — как найти сгенерированные operator fun componentN() и убрать их из выдачи.

А теперь plot twist… Это не operator и не fun.

1b2b408931af18bdd69b2c6314f65b67.gif

Давайте рассмотрим вот такой data-класс:

data class User(val firstName: String, val lastName: String) {
    operator fun component3() = "$firstName $lastName"
}

И проверим его на нашей реализации. Возьмем начало функции shouldFilterOut и добавим логи:

val psiElement = lookupElement.psiElement as? KtLightMethod ?: return false
val ktOrigin = psiElement.kotlinOrigin ?: return false
println("psiName = ${psiElement.name}; originName = ${ktOrigin.name}; $ktOrigin")

Что мы сделали:

  • отфильтровали PSI-элементы, которые являются KtLightMethod. Это тип для представления Kotlin-функций и пропертей в понятном для Java виде, что полезно для анализа кода, рефакторинга, а также автокомплита

  • отфильтровали среди них те, у которых есть Kotlin PSI-элемент

И тут мы в логах видим кое-что интересное:

psiName = component3; originName = component3; FUN
psiName = getFirstName; originName = firstName; VALUE_PARAMETER
psiName = getLastName; originName = lastName; VALUE_PARAMETER
psiName = component1; originName = firstName; VALUE_PARAMETER
psiName = component2; originName = lastName; VALUE_PARAMETER

Примечательно, что оба геттера, как и функции component1() и component2(), представлены в PSI как VALUE_PARAMETER, хоть и вызываются в Kotlin как функции. Возможно, такое представление связано с тем, что эти сущности напрямую связаны с параметрами конструктора.

Warning

Теперь важный момент: хоть мы и наблюдаем такое неожиданное поведение на уровне PSI, сгенерированные функции componentN() технически всё так же остаются функциями. Мимикрируют под value-параметры они исключительно для PSI.

Однако вы могли заметить, что представление явной функции component3()отличается от тех сгенерированных componentN(). Более того, оно бы отличалось, даже если б функция была объявлена как operator fun component3() = firstName, т.е. явно указывала на value-параметр.

И это хорошо! Когда мы используем класс User из Kotlin-кода, явные функции componentN() не отфильтровываются:

component3() был создан явно и не отфильтровывается из автокомплита при работе из Kotlin

component3() был создан явно и не отфильтровывается из автокомплита при работе из Kotlin

И, по удивительному совпадению, в нашей реализации точно так же!

Далее всё довольно просто — мы проверяем, что штука из саджеста пришла к нам из data-класса, что она является инстансом KtParameter, и что ее название проходит регулярку для строк, содержащих только слово component и следующее за ним натуральное число:

...
val isParamInDataClass = ktOrigin.containingClass()?.isData() == true && ktOrigin is KtParameter
if (!isParamInDataClass)
    return false
return componentNRegex.matches(psiElement.name)

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

Буду рад, если установите мой плагин, поделитесь фидбэком и поставите звезду на репозитории! Спасибо!

© Habrahabr.ru