Часто при разработке собственных фреймворков (или для проверки соответствия кода требованиям организации) возникает необходимость реализовать сложные проверки корректности использования в коде приложения. Это может быть реализовано через расширение возможностей линтера, который используется в Android Studio. В этой статье мы рассмотрим общие подходы к созданию таких расширений для Android-приложений и несколько примеров для проверки названий функций и наличия аннотаций.

Прежде чем мы перейдем к рассмотрению расширения нужно разобраться с порядком выполнения компиляции Kotlin-кода и представлением дерева элементов PSI (Program Structure Interface). PSI является минимальной единицей представления исходного текста и он может отображать как ключевые слова, так и идентификаторы, константы и операторы. PSI создается при выполнении парсера (может быть сгенерирован с использованием Gradle Grammar Kit Plugin) и создается с использованием объекта класса PsiBuilder, который может сохранять промежуточное представление и накапливать дерево PSI-элементов на основе потока токенов, которые поступают от лексического анализатора. Именно анализ PSI-структуры поможет нам в создании расширения для проверки корректности вызова методов фреймворка (а также, например, может использоваться для проверки принятой в организации схемы именования). Для удобства работы с сущностями языков, основанных на JVM (включая Kotlin) также доступен API UAST (Unified Abstract Syntax Tree) для извлечения и анализа высокоуровневых единиц (определений классов, методов, операторов языка, литералов и констант).

Для создания расширения линтера мы добавим зависимость от библиотеки com.android.tools.lint:lint-api, а также подключим com.android.tools.lint:lint-tests для автоматических проверок расширения.

 plugins {
    kotlin("jvm") version "1.7.21"
    application
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
    google()
}

dependencies {
    implementation("com.android.tools.lint:lint-api:30.3.1")
    testImplementation("com.android.tools.lint:lint-tests:30.3.1")

    testImplementation(kotlin("test"))
}


tasks.test {
    useJUnitPlatform()
}

kotlin {
    jvmToolchain(8)
}

Для уведомления об ошибке используется объект класса Issue (входит в пакет com.android.tools.lint.detector.api), при создании которого необходимо указать идентификатор проблемы, короткое и подробное описание (также будет выводиться во всплывающей подсказке в IDE), уровень проблемы (может быть предупреждение, ошибкой, фатальной ошибкой, информацией, а также быть помечен как Severity.IGNORE для временного отключения проверки), категорию (часто используется Category.CUSTOM_LINT_CHECKS), приоритет (целое число от 1 до 10, где 10 наиболее значимая проблема), а также класс реализации детектора ошибки.

Класс детектора создается как реализация абстрактного класса Detector и часто добавляется интерфейс Detector.UastScanner для работы с высокоуровневыми абстракциями языка (Java или Kotlin). UastScanner представляет для определения несколько групп методов:

  • getApplicableMethodNames - переопределяет список названий вызываемых методов, для которых будет применяться детектор

  • visitMethodCall - будет вызываться при обнаружении вызова методов (всех или перечисленных в результате метода getApplicableMethodNames)

  • getApplicableConstructorTypes, visitConstructor - аналогично предыдущим методам, но для создания объектов

  • getApplicableReferenceNames, visitReference - при обнаружении ссылок на переменные

  • appliesToResourceRefs (возвращает логическое значение) и visitResourceReference - при обнаружении ссылок на ресурсы Android (через класс R)

  • applicableSuperClasses, visitClass - при определении класса, наследуемого от разрешенных суперклассов (или всех, если applicableSuperClasses не определен), либо лямбды в Kotlin (транслируется в класс с одним методом).

  • applicableAnnotations, visitAnnotationUsage - при обнаружении применения аннотации к полю, методу, классу или пакету

  • getApplicableUastTypes - перечисляет поддерживаемые типы UElement, важно переопределить для указания поддерживаемых visit-методов

  • createUastHandler - регистрирует обработчик для обработки элементов синтаксического дерева (наследуется от UElementHandler)

Также от класса Detector наследуются методы жизненного цикла before* и after* (например, перед и после проверки нового файла, проекта, …).

Класс обработчика элемента (от UElementHandler) может переопределять методы, которые будут вызываться при обнаружении элемента в синтаксическом дереве, например:

  • visitBreakExpression - при обнаружении оператора break

  • visitCallExpression - при вызове функций или методов

  • visitClass - определение класса

  • visitDeclaration - определение переменных

  • visitIfExpression - обнаружено выражение if

  • visitImportStatement - найден импорт пакета

  • visitLambdaExpression - обнаружено лямбда-выражение

  • visitMethod - обнаружено определение метода

  • visitReturnExpression - обнаружено выражение return

Полный список методов можно посмотреть в официальной документации.

Определим visitMethod для проверки префикса в названии метода, в случае ошибки сообщим о ней через вызов context.report, в который нужно передать объект Issue, уточнить в какой строке произошла ошибка (можно получить из контекста через вызов метода getNameLocation или getLocation). Полный код класса-расширения линтера может выглядеть следующим образом:

object NamingIssue {
    val ISSUE = Issue.create(
        id = "NamingIssue",
        briefDescription = "Wrong prefix in method name",
        explanation = "You must use prefix 'my' or 'on' for any method",
        category = Category.CUSTOM_LINT_CHECKS,
        priority = 5,
        severity = Severity.ERROR,
        Implementation(NameIssueDetector::class.java, Scope.JAVA_FILE_SCOPE)
    )
}

class NameIssueDetector : Detector(), Detector.UastScanner {
    override fun getApplicableUastTypes(): List<Class<out UElement>>? = listOf(UMethod::class.java)

    override fun createUastHandler(context: JavaContext): UElementHandler? = NameVisitor(context)
}

class NameVisitor(private val context: JavaContext) : UElementHandler() {

    override fun visitMethod(node: UMethod) {
        if (!node.name.startsWith("my") && !node.name.startsWith("on")) {
            context.report(issue=NamingIssue.ISSUE, scopeClass=node, location=context.getNameLocation(node), message="""
                Function name must have prefix "my" or "on"
            """.trimIndent())
        }
        super.visitMethod(node)
    }
}

Теперь для подключения нового правила нужно создать реестр Issue и подключить его к проекту:

class CustomLintRegistry : IssueRegistry() {
    override val issues = listOf(NamingIssue.ISSUE)

    override val api: Int = CURRENT_API

    override val minApi: Int = 6
}

И добавим запись в манифест для JAR-файла (поскольку он будет добавляться во внешний проект)

tasks.withType<Jar> {
    manifest {
        attributes["Lint-Registry-v2"] = "com.example.CustomLintRegistry"
    }
}

Для подключения линтера к проекту Android Studio нужно добавить его в блок dependencies через lintChecks, например:

dependencies {
  //...зависимости проекта
  lintChecks(project(":mylint"))
}

Подключенный линтер будет работать после запуска ./gradlew lint

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

Теперь расширим наш линтер и добавим к нему возможность проверки наличия аннотации EventHandler перед методами, которые называются с префиксом handle. Добавим поддержку аннотаций в список типов:

    override fun getApplicableUastTypes(): List<Class<out UElement>>? = listOf(UMethod::class.java, UAnnotation::class.java)

и реализуем метод, который будет также сохранять состояние наличия аннотации, которое будет использоваться в visitMethod:

class NameVisitor(private val context: JavaContext) : UElementHandler() {
    var annotationFound = false

    override fun visitAnnotation(node: UAnnotation) {
        if (node.qualifiedName=="EventHandler") {
            annotationFound = true
        }
        super.visitAnnotation(node)
    }

    override fun visitMethod(node: UMethod) {
        if (node.name.startsWith("handle") && !annotationFound) {
            context.report(issue=NamingIssue.ISSUE, scopeClass=node, location=context.getNameLocation(node), message="""
                Handler functions must be annotated with @EventHandler
            """.trimIndent())
        }
        annotationFound = false
        super.visitMethod(node)
    }
}

При этом сам класс аннотации должен быть помечен для использования только с методами:

@Target(AnnotationTarget.FUNCTION)
annotation class EventHandler

Аналогично могут быть сделаны проверки по типу возвращаемого значения и аргументов функции (или метода), вся информация о сигнатуре вызова доступа в объекте класса UMethod. Также могут быть выполнены проверки выражений (ArrayAccessExpression, BinaryExpression, UnaryExpression, BreakExpression, PrefixExpression, PostfixExpression), вызовов функций (CallExpression), определений классов (ClassExpression), операторы языка (BlockExpression, DoWhileExpression, IfExpression, SwitchExpression, SwitchClauseExpression, TryExpression, ThrowExpression, WhileExpression) и многие другие. Благодаря возможностям расширения статического анализа можно создавать дополнительные проверки исходных кодов и интегрировать их с библиотеками, как подключаемую зависимость (поскольку информация для настройки lintChecks извлекается из манифеста jar-файла).

В завершение приглашаю вас на бесплатный урок по теме: "Создаем приложение таймер за 60 минут с использованием MVVM, StateFlow и Coroutines со звуковой сигнализацией и вибрацией."

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