Android Lint: оптимизируем проверку мердж-реквестов

Привет, это Android-разработчик из МТС Диджитал, Никита Пятаков. Когда я только начал работать над приложением «Мой МТС», мне было нужно время, чтобы адаптироваться и ознакомиться с проектом. На первых МР-ах коллеги подсвечивали готовые решения, которые можно переиспользовать. Когда к нам стали приходить новые разработчики, такие комментарии оставлял уже я. Это натолкнуло меня на мысль, что использование синтаксического анализатора оптимизирует процесс проверки. К тому моменту мы уже использовали Android Lint, так что выбирать не пришлось. 

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

a53f6779bfa7d9f69f3de160976a8858.png

К чему стремимся

Функция, которую мы используем вместо конструкции »?: false» имеет вид:

val Boolean?.safeBoolean: Boolean
    get() {
        return this == true
    }

В итоге мы хотим получить такой результат:

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

Issue

Сначала нам нужно создать issue — зарегистрировать новое правило для Lint:

class MyMtsIssueRegistry : IssueRegistry() {

    override val api: Int
        get() = CURRENT_API

    override val issues: List
        get() = listOf(ISSUE_ELVIS_OPERATOR_WITH_FALSE)

    companion object {

        val ISSUE_ELVIS_OPERATOR_WITH_FALSE = Issue.create(
                id = "ElvisOperatorWithFalse",
                briefDescription = "Elvis expression with false is used",
                explanation = "Replace Elvis expression with .safeBoolean function",
                category = Category.CORRECTNESS,
                priority = 10,
                severity = Severity.WARNING,
                implementation = Implementation(ElvisOperatorWithFalseDetector::class.java, JAVA_FILE_SCOPE)
        )
    }
}

Наследуемся от абстрактного класса IssueRegistry. Переопределяем два поля:

  • api — версия, с которой будут скомпилированы наши issue (можно указать актуальную CURRENT_API из Lint)

  • issues — список кастомных правил, добавляем ISSUE_ELVIS_OPERATOR_WITH_FALSE

В issue указываем:

  • id — должен быть уникальным

  • briefDescription — краткое описание проблемы

  • explanation — более подробное описание проблемы

  • category, priority — категоризация issue, можно задать любое (обычно используется для репортов)

  • severity — уровень серьезности проблемы, Lint подсвечивает их по-разному. В нашем случае, мы хотим, чтобы участок кода подчеркивался зеленым, выбираем WARNING.

  • implementation — указываем детектор. В нём опишем процесс поиска кейса, который нужно поправить. Также нужно указать тип файлов, по которому Lint будет проходиться. Так как Kotlin-файлы декомпилируются в Java, используем JAVA_FILE_SCOPE. Lint умеет анализировать Gradle, manifest и так далее, здесь можно выбрать соответствующий scope.

Детектор

class ElvisOperatorWithFalseDetector : Detector(), SourceCodeScanner {

    override fun getApplicableUastTypes(): List> = listOf(UIfExpression::class.java)

    override fun createUastHandler(context: JavaContext): UElementHandler = ElvisOperatorWithFalseHandler(context)
}

В Lint для обработки кода используется так называемое Uast-дерево, в виде которого представляется код. Например, для показанного на видео в начале статьи класса Test (до исправления), дерево будет выглядеть вот так:

UFile (package = ru.mts.accordion.presentation.extensions)
    UImportStatement (isOnDemand = false)
    UClass (name = Test)
        UField (name = options)
            UAnnotation (fqName = org.jetbrains.annotations.Nullable)
        UMethod (name = boo)
            UBlockExpression
                UReturnExpression
                    UExpressionList (elvis)
                        UDeclarationsExpression
                            ULocalVariable (name = var223e1170)
                                UQualifiedReferenceExpression
                                    UQualifiedReferenceExpression
                                        USimpleNameReferenceExpression (identifier = options)
                                        USimpleNameReferenceExpression (identifier = titleFontSize)
                                    UCallExpression (kind = UastCallKind(name='method_call'), argCount = 0))
                                        UIdentifier (Identifier (isEmpty))
                                        USimpleNameReferenceExpression (identifier = isEmpty, resolvesTo = null)
                        UIfExpression
                            UBinaryExpression (operator = !=)
                                USimpleNameReferenceExpression (identifier = var223e1170)
                                ULiteralExpression (value = null)
                            USimpleNameReferenceExpression (identifier = var223e1170)
                            ULiteralExpression (value = false)
        UMethod (name = Test)
            UParameter (name = options)
                UAnnotation (fqName = org.jetbrains.annotations.Nullable)

Lint проходится по деревьям файлов в проекте по алгоритму, который мы опишем в детекторе, и, если, согласно этому алгоритму, будет найден соответствующий участок кода, — Lint его подсветит. UFile, UImportSatement, UClass — это все интерфейсы-наследники UElement, об экземплярах которых (в реализации Java или Kotlin) можно получать необходимую информацию, например, представление кода в виде строки.

Чтобы написать свое правило, нужно унаследоваться от Detector, SourceCodeScanner и переопределить две функции:

  • getApplicableUastType — указываем, какие Uast элементы нас интересуют.

  • createUastHandler — подставляем свой обработчик для выбранных выше Uast-элементов.

Так как оператор Элвиса декомпилируется в Java в виде простого if-else, нам нужен UIfExpression.

Обработчик

class ElvisOperatorWithFalseHandler(private val context: JavaContext) : UElementHandler() {
    override fun visitIfExpression(node: UIfExpression) {
        node.accept(object : AbstractUastVisitor() {
            override fun afterVisitIfExpression(node: UIfExpression) {
                if ((node.uastParent as? UExpressionList)?.kind?.name == "elvis" &&
                        node.elseExpression?.asRenderString() == "false") {
                    val elvisExpressionString = node.uastParent?.sourcePsi?.text
                    node.getParentOfType()?.let { uClassWithElvis ->
                        reportIssue(context, uClassWithElvis, node, elvisExpressionString)
                    }
                }
            }
        })
    }

Наследуемся от UElementHandler и переопределяем visitIfExpression. Если в коде встретится if, то мы попадем сюда. По дереву можно двигаться в двух направлениях — вверх и вниз. Чтобы «провалиться» ниже, используется функция accept, передаем в нее анонимный объект класса AbstractUastVisitor и переопределяем afterIfExpression. Так как в Java нет оператора Элвиса, нет отдельного expression для него, но тем не менее есть поле, в котором хранится информация о его использовании — kind.name.  Данный подход по обработке оператора Элвиса был взят из исходников Jetbrains. Помимо того, что используется Элвис, надо проверить, что после него стоит «false». 

Далее, так как мы хотим в этом файле добавить новый импорт, нам нужно подняться «вверх» по дереву и дойти до уровня файла. Для этого используется функция getParentOfType, вызываем и указываем интересующий нас класс — Ufile. После этого переходим в reportIssue, передавая контекст, весь файл и код, в котором используется оператор.

Репорт

Для того, чтобы мы в студии получили сообщение о том, что нужно поправить код, необходимо вызвать функцию report:

private fun reportIssue(context: JavaContext, nodeFile: UFile, nodeElvisExpression: UIfExpression, elvisExpressionString: String?) {
    elvisExpressionString?.let {
        context.report(
                ISSUE_ELVIS_OPERATOR_WITH_FALSE,
                context.getLocation(nodeElvisExpression),
                "Elvis expression can be replaced with .safeBoolean function",
                createFix(nodeFile, it)
        )
    }
}

При вызове передаем:

  • Issue

  • Location — то место в коде, которое будет подчеркнуто и заменено при фиксе

  • Message — краткое описание

  • QuickFixData — объект класса LintFix. Используем, если хотим не только подсветить проблему, но и предложить исправление.

Исправление

private fun createFix(nodeFile: UFile, oldText: String): LintFix {
    val newString = oldText.substringBeforeLast("?:").trim() + ".safeBoolean"
    val lastFileImport = nodeFile.imports.lastOrNull()
    val elvisFix = LintFix
            .create()
            .name("Replace")
            .replace()
            .text(oldText)
            .with(newString) 
            .reformat(false)
            .build()

    return if (lastFileImport != null && "import ru.mts.utils.extensions.safeBoolean" !in nodeFile.imports.map { it.sourcePsi?.text }) {
        val addImportFix = LintFix.create()
                .replace()
                .with("\nimport ru.mts.utils.extensions.safeBoolean")
                .reformat(true)
                .range(context.getLocation(lastFileImport))
                .end()
                .autoFix()
                .build()
        LintFix.create().composite(addImportFix, elvisFix)
    } else {
        elvisFix
    }
}

Как мы помним, фикс будет двойной, нужно вместо оператора Элвиса подставить вызов функции safeBoolean и добавить новый импорт. Для исправления оператора используем функцию replace и указываем, какой код (text) на что меняем (with).

Область исправление определяется location, который мы указали в report выше. Чтобы добавить фикс импортов, необходимо location поменять. Для этого нужно в range передать новый location (поле imports в UFile), и в reformat поменять флаг на true. Далее, указываем, что хотим добавить новый импорт в конец (end) и используем replace и with, с указанием нового текста. 

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

Ура, новое правило успешно создано! Спасибо Вам за уделенное время. Если возникнут какие-либо вопросы, я с удовольствием отвечу на них в комментариях. А если захотите присоединиться к нашей команде — следите за вакансиями!

© Habrahabr.ru