Статические анализаторы уже давно являются неотъемлемой частью разработки проектов не только на Android. Они позволяют выявлять ошибки, несоответствия стандартам code style, производительности или безопасности, обозначать какие-то узкие места, сокращать code review и т. д. Android Studio (далее просто студия) «из коробки» содержит огромное количество всевозможных проверок, но, как правило, этого недостаточно, всегда есть какие-то неучтённые проблемы, внутренние правила компании или команды разработки. Кратко расскажем про Lint, как начинали делать свои правила, с какими задачами сталкивались на первых этапах и как решали. Это поможет вам впервые погрузиться в тему, так как интернет весьма скуден на статьи по ней.

Почему именно Lint, а не популярные Detekt и Ktlint?

Во‑первых, если вы просто хотите погрузиться в тему, понять, как это устроено и работает, то лучше подходит «нативное» решение. Ничто не мешает пользоваться Detekt, там очень похожий подход и синтаксис.

Во‑вторых, если у вас в компании есть предвзятое отношение к сторонним библиотекам (так себе аргумент, согласен).

В третьих, если у вас в проекте исторически уже есть Lint и его нужно «поддержать», а также если (не дай бог, конечно) ещё остался Java-код. Кроме того, Lint позволяет анализировать манифесты, Gradle-файлы, файлы ресурсов и много чего ещё. Ну и, наконец, это же просто очень увлекательно. Далее мы не будем проводить параллели, сконцентрируемся только на Lint'e.

Не будем останавливаться на том, для чего нужны анализаторы. Кратко поговорим, как Lint устроен, из каких «сущностей» состоит, простыми словами опишем принцип работы почти любого детектора. Подключим его в проект и рассмотрим не очень сложные примеры и в каждом попробуем зацепить какую‑нибудь неочевидную изюминку, о которой обычно не пишут в статьях про знакомство с Lint'ом.

Немного о Lint

Думаю, не надо говорить на том, что такое Lint. Наверняка все из вас сталкивались с инспекцией в студии. В Google подготовили неплохую документацию по управлению этими встроенными правилами. То есть Lint по умолчанию всегда включен и является частью разработки. Кроме того, при сборке релизного таргета запускаются стандартные проверки, которые, опять же по умолчанию, могут «сфейлить» сборку, если не прошло правило с типом Fatal (об этом чуть позже).

Если навести курсор на инспекцию, мы можем наблюдать некое сообщение, а также список доступных действий.
Если навести курсор на инспекцию, мы можем наблюдать некое сообщение, а также список доступных действий.

Для написания своих правил API Lint'а предоставляет обширные возможности. Порой настолько обширные, что постоянно находишь какие-то новые интересные функции, позволяющие сделать что-то сложное одной строкой. Отметим некоторые преимущества этого API (какие-то довольно очевидны):

  • Обнаружение почти любых проблем в почти любых файлах.

  • Широкие возможности для анализа от синтаксических деревьев до обычного текста.

  • Для работы не требуется сборка проекта.

  • Проверки можно запускать в любое время, как в конвейере, так и локально, например, в pre-commit hook’е.

  • Правила, проверки и исправления можно покрывать тестами.

  • Из «коробки» выдаёт приличный и удобно читаемый HTML.

  • Инспекция в студии и быстрые исправления (в том числе вкупе с автоформатом и т. д.).

Примерно так выглядит фрагмент отчёта, там даже всё кликабельное.
Примерно так выглядит фрагмент отчёта, там даже всё кликабельное.

Подключение в проект

Тут всё предельно просто, Lint полностью интегрирован с Gradle. Создаём в проекте Java/Kotlin-модуль (назовём его «lint‑checks»), который добавляется в основной/feature/core-модуль через «lintChecks». В этом модуле будут находиться наши проверки и конфигурация линта, через него будут запускаться задачи для сборки. В секции Android → Lint можно уточнить конфигурацию, включить и выключить что‑то и прочее.

Примерный Gradle-файл модуля с Lint.
Примерный Gradle-файл модуля с Lint.
Подключение нового модуля в основной проект.
Подключение нового модуля в основной проект.

Точка входа в детекторы — некий IssueRegistry, который нужно добавить в секцию jar Gradle-модуля lint‑checks. Это простенький класс, где будет просто перечисление всех ваших Issue (об этом далее).

Пример класса IssueRegistry.
Пример класса IssueRegistry.

Issue

Как следует из названия, это «проблема», которую мы хотим решить. Она содержит в себе описание проблемы, разные категории «важности» и ссылку на реализацию детектора. По каждому из свойств класса есть внятная документация, по большей части все эти поля сугубо информационные. Главное, на что обратить внимание, это id — идентификатор проблемы, по которой настраиваются конфигурации и настройки инспекций в студии, а также severity — по этому свойству Lint будет понимать критичность проблемы: каким цветом и форматированием (по умолчанию) выделить в редакторе кода, стоит ли остановить сборку при обнаружении. Как уже говорили, для Severity.Fatal сборка релиза будет падать по умолчанию, но это можно изменить под себя.

Класс Issue, тоже ничего сложного.
Класс Issue, тоже ничего сложного.

Когда мы прописываем Implementation в Issue, есть ещё такая сущность, как Scope. Она указывает детектору какие-то конкретные типы файлов для анализа, например, только файлы ресурсов или только файлы с кодом. 

Набросали какой-то типичный Issue.
Набросали какой-то типичный Issue.

Detector

Теперь самое интересное — класс, где будет написана логика некоего правила. Чаще всего реализует один из подготовленных Scanner'ов, то есть интерфейсов для работы с выбранным ранее Scope. Чаще всего будет использоваться UastScanner — сканер абстрактного синтаксического дерева для анализа исходного кода, ну и XmlScanner — для анализа ресурсов.

К слову, можно изучить огромное количество готовых детекторов и вдохновиться чем-нибудь.
К слову, можно изучить огромное количество готовых детекторов и вдохновиться чем-нибудь.

Все Detector'ы реализуют «visitor pattern» (от интерфейса UastVisitor), то есть некий callback, срабатывающий при посещении анализатором указанного узла внутри выбранного Scope (рассмотрим на примере). Предположим, вам нужно проверить какой‑то вызов метода, настраиваете область видимости в Issue как JAVA_FILE_SCOPE, в новом детекторе наследуемся от UastScanner, переопределяем метод getApplicableUastTypes, где указываются элементы (узлы), которые мы хотим проверять. Сейчас это будет UMethod. Далее нам необходимо переопределить visitMethod. Сделать это можно как у детектора, так и в UElementHandler, последний — расширенное решение по сравнению с некоторым количеством стандартных методов в базовом классе детектора. Вызов super следует удалить, а метод visit должен быть для узла, указанного в getApplicableUastTypes (иначе будет кидать исключение). Таким образом, анализатор будет пробегать по всем методам проекта и кидать обратный вызов, в котором мы что‑нибудь проверим и зарепортим проблему. Ещё раз: настраиваете область видимости (узлы) и реализуете метод, что сделать, когда мы в эти узлы попали при проверке.

Наш пример с методом.
Наш пример с методом.

При обнаружении проблемы вызывается метод report с указанием нашей проблемы, описания, узла, места или диапазона, где нужно разметить инспекцию. Опционально можно добавить LintFix (те действия, что студия предлагает под лампочкой). LintFix позволяет реализовать автоматическую или ручную правку найденной проблемы средствами IDE. Чаще всего представляет собой замену текста с некоторыми настройками, но может также создавать или удалять файлы, менять аннотации и прочее.

Пример LintFix'a, здесь просто заменяем один текст на другой по заданному положению в коде.
Пример LintFix'a, здесь просто заменяем один текст на другой по заданному положению в коде.
Для вложенных проверок можно использовать метод accept и реализовать ещё один "visitor"-метод.
Для вложенных проверок можно использовать метод accept и реализовать ещё один «visitor»‑метод.

Detector'ы и LintFix'ы можно тестировать, для этого Lint предоставляет специальные тестовые артефакты. В большинстве случаев это единственный способ убедиться в правильной работе и довольно быстро перебрать разные случаи, а также отлаживать UAST. Однако, так как модуль lint-checks ничего не знает ни про какие зависимости (а мы и не хотим их туда провайдить), не видит файлы ресурсов и т. д., необходимо создавать заглушки, по сути просто фейковые файлики с правильными импортами и контрактом. 

Для создания теста на детектор в первую очередь нужно реализовать TestLintTask и в нём указать, какие Issue мы собираемся тестировать. Тут также указываются конфигурации для тестов, самое популярное у нас — allowMissingSdk(). Далее в lintTask подсовываем файлы с кодом или ресурсами, которые собираемся тестировать, а также стабы для корректной работы импортов. Запускаем через run() и в expect() и expectFixDiffs() ожидаем отчёт о проблеме или ничего (как в обычных модульных тестах, тут доступно много вариантов).

Создали стабы и LintTask.
Создали стабы и LintTask.
Подсунули все файлы и ожидаем вывод ошибки.
Подсунули все файлы и ожидаем вывод ошибки.

AST и PSI

Как уже говорили, наиболее востребованный для нас будет UastScanner. Тут важно немного остановиться и разобраться, что такое UAST и как оно верхнеуровнево работает. Не сильно углубляясь в детали, у компилятора кода есть несколько этапов работы, в которых из текстового описания кода происходит преобразование в машинное описание (синтаксическое дерево с некоторой мета-информацией).

То, что мы видим в редакторе студии, преобразуется в PSI (Programming Structure Interface). Это просто некоторая формальная модель синтаксиса и семантики кода. Здесь описывается вообще всё, что вы видите в редакторе: не только классы и функции, но и комментарии, скобочки и пробелы, которые, очевидно, для исполнения машинного кода не нужны. Тут также важно понимать, что PSI разных языков программирования (Kotlin и Java, например) будет существенно отличаться. В студии можно поставить плагин PsiViewer, который позволит смотреть PSI любого файла (полное дерево со всеми свойствами, нам это понадобится позже).

Так выглядит плагин PsiViewer.
Так выглядит плагин PsiViewer.

Далее компилятор выполняет синтаксический анализ, на основе которого формируется абстрактное синтаксическое дерево (AST). Это тоже формат описания кода в виде дерева, однако здесь структура более понятна машине, но менее понятна для нас. К сожалению, посмотреть AST не так просто, как PSI (ну или мы не нашли), тут нам помогают тесты, где можно описать необходимый класс и поставить breakpoint на узле, чтобы посмотреть его структуру.

Пример вывода AST в журнал.
Пример вывода AST в журнал.

И вот мы подошли к Universal AST. UAST представляет собой некую абстракцию для обобщения дерева AST в Kotlin и Java. Даже если ваш проект полностью на Kotlin, некоторые конструкции кода в AST могут быть описаны структурами из Java, к этому надо быть готовыми.

Точка входа в UAST — UElement, от него наследуются все другие «элементы». В API Lint'а мы можем пользоваться как UAST, так и PSI, как угодно их друг с другом смешивая. То есть что‑то удобнее делать через PSI (обычно что-то связанное с оформлением файла или code style, ну или сложная структура, которую удобнее смотреть через PSIViewer), а что‑то — через AST (чаще всего что-то связанное с назначением кода, например, получение полного пути к типу property).

Интероп в PSI заложен прямо в UElement.
Интероп в PSI заложен прямо в UElement.

Примеры

С азами разобрались, давайте набросаем несколько примеров. Писать будем «в лоб», как можно проще, оптимизацию рассмотрим как‑нибудь в другой раз.

Пример 1

В первом примере попробуем помечать классы UseCase, если на них не написаны тесты. Особенность примера в том, что при анализе конкретного класса в синтаксическом дереве нет информации о других классах и файлах проекта.

Для начала создадим Issue и Detector. Наследуемся от UastScanner, в getApplicableUastTypes у нас будет listOf(UClass::class.java). Так как документации особо нет, приходится копаться самостоятельно. Первое, что нам попалось, это довольно интересная и неочевидная возможность перезапускать цикл проверки. В объекте context, который предоставляют методы детектора, можно запросить requestRepeat, а через phase узнать текущую итерацию. Таким образом можно собрать коллекцию всех классов, чтобы на второй итерации поискать, есть ли там какие‑то соответствия (в нашем простом примере по имени класса UseCase попробовать сопоставить имя теста).

Пример подхода с requestRepeat. Оставлять это мы, конечно, не будем.
Пример подхода с requestRepeat. Оставлять это мы, конечно, не будем.

Сразу возникает куча вопросов: когда очищать коллекцию классов, почему всё так сложно и т. д. Должно быть как‑то проще. Если заглянуть внутрь JavaContext, то сразу бросается в глаза объект project, а внутри него есть чудесный метод getTestSourceFolders. Осталось только написать рекурсивный поиск по всем директориям. Добавим парочку простых условий, что это не интерфейс или абстрактный класс, а при переборе файлов будем смотреть только файлы Kotlin, для этого воспользуемся случайно найденным расширением isKotlinFile. Ну вот, если у нас нет в тестовом sourceSet'e файла с именем, как у нашего UseCase + «Test» на конце, — сообщаем о проблеме.

Код
class UseCaseTestDetector : Detector(), Detector.UastScanner {

    companion object {
        private const val MESSAGE = "Юзкейс нужно покрыть тестом\n"

        private val lintCategory = LintCategory.Default()
        val ISSUE = Issue.create(
            id = "UseCaseTest",
            briefDescription = "Нарушение стандарта",
            explanation = MESSAGE,
            category = lintCategory.category,
            priority = lintCategory.priorityLevel,
            severity = lintCategory.severity,
            implementation = Implementation(
                UseCaseTestDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }

    override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(UClass::class.java)

    override fun createUastHandler(context: JavaContext): UElementHandler {
        return object : UElementHandler() {

            override fun visitClass(node: UClass) {
                if (node.isInterface || node.modifierList?.hasModifierProperty(PsiModifier.ABSTRACT) == true)
                    return
                val originalName = node.name.orEmpty().removeSuffix("Impl")
                if (originalName.endsWith("UseCase")
                    || originalName.endsWith("UseCaseImpl")
                ) {
                    val testFileNames = getAllTestFileNames(context.project.testSourceFolders)
                    if (!testFileNames.contains(originalName + "Test.kt")
                        && !testFileNames.contains(originalName + "ImplTest.kt")
                    ) {
                        context.report(
                            ISSUE,
                            node,
                            context.getNameLocation(node),
                            MESSAGE
                        )
                    }
                }
            }
        }
    }

    //Поиск всех тестовых файлов модуля, возможно, не оптимальный
    private fun getAllTestFileNames(files: List<File>): List<String> {
        val names = mutableListOf<String>()
        files.forEach {
            if (it.isKotlinFile(listOf("kt")))
                names.add(it.name)
            else if (it.isDirectory) {
                names.addAll(getAllTestFileNames(it.listFiles().toList()))
            }
        }
        return names
    }
}

Здесь context.getNameLocation(node) позволяет выделить только имя класса в редакторе IDE. В принципе, это уже относительно рабочий вариант (особенно, если у вас уже есть проверки на нейминг), так что посмотрим что-нибудь ещё.

Пример 2

Оставим наш UseCase, но теперь попробуем проверить, что он не помечен аннотацией Singleton, имеет какую-нибудь документацию и не содержит публичных свойств с типом Observable из RxJava (просто для примера, в реальности мы хотели бы избавиться в UseCase от любых «побочных эффектов», так что там сильно больше проверок). Инициализация остаётся без изменений, изменяется только содержимое метода visitClass().

Итак, по порядку: проверить аннотацию довольно просто, UClass уже содержит кучу разных готовых объектов, в том числе annotations, где нужный можно найти по каноническому пути (в PSI такой возможности нет).

Код
node.annotations.find { it.hasQualifiedName("javax.inject.Singleton") }?.let {
    context.report(
          issue = ISSUE,
          scopeClass = node,
          location = context.getLocation(it),
          message = SINGLETON_MESSAGE
    )
}               

Документацию на этот раз давайте поищем через PSI. Тут тоже довольно много расширений. Сначала мы про них не знали и писали куда более сложные условия. Впрочем, иногда так хоть и некрасиво, но по сложности поиска может быть оптимальнее, тут уже необходимо более детальное погружение. Воспользуемся findDescendantOfType, получив из UClass объект sourcePsi. Тип Descendant'a дерева подсмотрим через PsiViewer (наводимся на нужный элемент в классе и смотрим строчку class) — это KDocSection.

Код
if (node.sourcePsi?.findDescendantOfType<KDocSection>() == null) {
      context.report(
            issue = ISSUE,
            scopeClass = node,
            location = context.getNameLocation(node),
            message = DOC_MESSAGE
      )
}

Хм, слишком просто, как будто это и за минуту можно сделать, тогда и последнее быстро допишем. Первое, что мы делаем по аналогии с аннотацией, — видим в UClass fields, далее мы можем просто проверить модификатор доступа и каноническое имя. Так и делаем, тесты проходят, всё хорошо. Ну, не совсем. Дело в том, что UField и property в Kotlin — не одно и то же. Например, companion object тоже будет в массиве fields. Можно, конечно, поисключать ненужное, но это довольно утомительно и есть вероятность что‑нибудь забыть. Придётся снова воспользоваться услугами PSI (подсмотрев структуру класса в PsiViewer), а затем снова переключиться на UElement для получения канонического пути (чтобы не сравнивать просто по имени, так как с таким именем много чего может быть в большом проекте). Ну и как бонус, именно в PSI есть расширение isPublic, избавляющее нас от необходимости перебирать разные варианты этих модификаторов.

Код
node.sourcePsi
  ?.getChildOfType<KtClassBody>()
  ?.children
  ?.filterIsInstance<KtProperty>()
  ?.forEach { property ->
    if (property.isPublic) {
        val type = (property.toUElementOfType<UMethod>()?.returnType
            ?: property.toUElementOfType<UField>()?.type)?.canonicalText.orEmpty()

        if (type.contains("io.reactivex.Observable")) {
            context.report(
                issue = UseCaseUsageDetector.ISSUE,
                scopeClass = node,
                location = context.getLocation(property),
                message = OBSERVABLES_MESSAGE
            )
        }
    }
}

В целом, получилось вполне даже читаемо. Но даже если «раскрыть» все расширения, логика довольно простая: у нас есть дерево, и мы можем ходить по нему как вверх, так и вниз. У каждого узла ссылка на родителя и на список детей, можно просто ходить по массиву и искать нужное, при необходимости кастуя к какому‑нибудь классу, чтобы получить больше вспомогательных свойств и методов.

Пример 3

В принципе, можно и не кастовать к каким‑то классам, а вообще парсить файл как обычный текст, иногда это даже быстрее и удобнее: любой PSI-объект содержит свойство text, куда выводится всё дерево. Давайте попробуем на типичном примере: проверим, что у условного выражения if/else всегда стоят фигурные скобки для тела, если всё выражение занимает более одной строки. А также рассмотрим добавление LintFix'a, ну и давненько что‑то мы тесты не писали.

В getApplicableUastTypes поменяем тип на UIfExpression, переопределим метод visitIfExpression. Сразу уберём тернарные операторы и элвисы, чтобы они не мешали. Далее, по уму, нам бы выудить все скобочки как объекты (они в API называются Leaf и работать с ними не очень удобно), но тут почему‑то хочется просто спарсить текст у блока then и else, так и сделаем. Посмотрев в PsiViewer, мы убедились, что скобки (если они есть) — это всегда первый и последний элемент в теле выражения. Быстро убедимся в этом, а также через text получим и количество строк и символов, проще некуда.

Код
override fun visitIfExpression(node: UIfExpression) {
    if (node.isTernary || node.uastParent is KotlinUElvisExpression) return
  
    val thenExpression = node.thenExpression ?: return
    val elseExpression = node.elseExpression
  
    if (thenExpression.sourcePsi?.firstChild?.text.orEmpty() != "{"
        && thenExpression.sourcePsi?.lastChild?.text.orEmpty() != "}"
        || elseExpression != null
        && elseExpression.sourcePsi?.firstChild?.text.orEmpty() != "{"
        && elseExpression.sourcePsi?.lastChild?.text.orEmpty() != "}"
    ) {
        val sourcePsi = node.sourcePsi
        if (sourcePsi != null && !isMaxSymbolsCondition(sourcePsi) && sourcePsi.text.lines().size == 1) {
            return
        } else {
            context.report(
                issue = CurlyBracketsInConditionsDetector.ISSUE,
                scope = node,
                location = context.getLocation(node),
                message = CurlyBracketsInConditionsDetector.MESSAGE,
                quickfixData = buildIfQuickFix(node, context.getLocation(node), thenExpression)
            )
        }
    }
}

Теперь нужно в студии предложить замену кода без скобок на код со скобками, то есть автоматически отформатировать. Для этого сделаем LintFix. Суть его простая: мы знаем место в коде для всего выражения, и просто заменим всё выражение на новое, добавив скобки как обычные строки и применив автоформатирование. Выглядит, конечно, «олдскульно», но это работает, и в некоторых случаях может быть проще, а главное быстрее в реализации.

Код
private fun buildIfQuickFix(
            node: UIfExpression,
            location: Location,
            thenExpression: UExpression
    ): LintFix =
        fix()
            .replace()
            .range(location)
            .name("Отформатировать")
            .with(
                buildIfQuickFixString(
                    node,
                    thenExpression
                )
            )
            .reformat(true)
            .build()

private fun buildIfQuickFixString(
            node: UIfExpression,
            thenExpression: UExpression,
    ): String {
        val elseExpression = node.elseExpression
        val isNeedThenExpressionFirstBrace = thenExpression.sourcePsi?.firstChild?.text != "{"
        val isNeedThenExpressionLastBrace = thenExpression.sourcePsi?.lastChild?.text != "}"
        return buildString {
            append("if(")
            append(node.condition.sourcePsi?.text)
            append(")")
            append(if (isNeedThenExpressionFirstBrace) "{\n" else "")
            append(thenExpression.sourcePsi?.text)
            append(if (isNeedThenExpressionLastBrace) "\n}" else "")
            if (elseExpression != null) {
                val isNeedElseExpressionFirstBrace = elseExpression.sourcePsi?.firstChild?.text != "{"
                val isNeedElseExpressionLastBrace = elseExpression.sourcePsi?.lastChild?.text != "}"
                append("\nelse")
                append(if (isNeedElseExpressionFirstBrace) "{\n" else "")
                append(elseExpression.sourcePsi?.text)
                append(if (isNeedElseExpressionLastBrace) "\n}" else "")
            }
        }
    }

Теперь напишем тест, проверим вывод ошибки и работу фикса, но только для одного варианта, чтобы не загромождать. Для теста LintFix'a, к сожалению, приходится полагаться только на текстовый формат, самый простой вариант — скопировать результат из «<Click to see difference>» в логе из ошибки, когда тест упадёт, убедившись в корректности написанного. То есть здесь строчки, что начинаются с «‑», будут удалены, а с «+» — добавлены. Видим, что скобочки появились, а смысл и «компилируемость» не нарушена (форматирование не в счёт, оно автоматически поправится).

Код
class CurlyBracketsInConditionsDetectorTest {

    private val lintTask = TestLintTask
        .lint()
        .allowMissingSdk()
        .issues(CurlyBracketsInConditionsDetector.ISSUE)

    @Test
    fun curlyBracketsInIfConditionsTest() {
        lintTask
            .files(
                kotlin(
                    """
                        package com.example.examplelint

                        class Name{
                        
                            fun test() {
                                null ?: 0

                                if (true) {
                                    foo()
                                 } else {
                                    bar()
                                 }

                                if (true) foo() else bar()

                                if (true)
                                    foo()
                                else
                                    bar()
                            }
                        
                            fun foo() {}
                            fun bar() {}
                        }
                    """
                )
            )
            .testModes(TestMode.BODY_REMOVAL)
            .run()
            .expect(
                """
                    src/com/example/examplelint/Name{.kt:9: Error: Тело условных операторов должно быть заключено в фигурные скобки [CurlyBracketsInConditions]
                                                    if (true) 
                                                    ^
                    1 errors, 0 warnings
                """.trimIndent()
            )
            .expectFixDiffs(
                """
                    NOTE: The following is specific to test mode "Body Removal" (TestMode.BODY_REMOVAL) :

                    Fix for src/com/example/examplelint/Name{.kt line 9: Отформатировать:
                    @@ -9 +9
                    - if (true)
                    -    foo()
                    - else
                    -    bar()
                    + if(true){
                    + foo()
                    + }
                    + else{
                    + bar()
                    + }
                """.trimIndent()
            )
    }
}

Пример 4

Для разнообразия давайте ещё попробуем написать что-нибудь несложное про наши любимые XML. Например, проверим, что в строковых ресурсах используется особый символ переноса строки, вместо тех, что обычно подставляются из Figma. Тут нам понадобится унаследоваться от ResourceXmlDetector и указать Scope.RESOURCE_FILE_SCOPE. Для уточнения области работы тут используются другие методы:

  • getApplicableAttributes — здесь задаём поиск по атрибутам. Это больше актуально для файлов разметки (если вы её ещё используете), например, можно проверить, что в атрибуте background и других не задаётся «сырой» код цвета, а используются ресурсы из дизайн системы.

  • getApplicableElements — здесь задаётся поиск по типу ресурса, то есть «string», «plurals», «dimen» и т. д.

  • appliesTo — указываем директории, которые следует проверять, например, ResourceFolderType.DRAWABLE.

В нашем примере атрибуты не нужны, getApplicableElements — setOf("string", "plurals"), appliesTo — ResourceFolderType.VALUES. Далее нам нужно реализовать Visitor'ы. Класс ResourceXmlDetector предоставляет нам visitAttribute, visitElement и visitDocument (обычно этих достаточно). Тут есть неявный нюанс, при некоторой комбинации всех указанных методов: методы visit могут не вызываться, поэтому вы не сразу поймёте, в чём проблема. Самое простое — не злоупотреблять методами области видимости, но если они вам необходимы, то переопределять оба метода (visitAttribute и visitElement), даже если для вашего отчёта вам достаточно только одного (другое оставьте пустым, без вызова super).

Код
class NonBreakingSpaceDetector : ResourceXmlDetector() {

    companion object {
        private const val MESSAGE = "Для обозначения неразрывных пробелов в строках следует использовать unicode символ \\u00A0\n"

        private val lintCategory = LintCategory.Default()
        val ISSUE = Issue.create(
            id = "NonBreakingSpace",
            briefDescription = "Нарушение стандарта",
            explanation = MESSAGE,
            category = lintCategory.category,
            priority = lintCategory.priorityLevel,
            severity = lintCategory.severity,
            implementation = Implementation(
                NonBreakingSpaceDetector::class.java,
                Scope.RESOURCE_FILE_SCOPE
            )
        )
    }

    override fun getApplicableElements(): Collection<String> = setOf("string", "plurals")

    override fun appliesTo(folderType: ResourceFolderType): Boolean = folderType == ResourceFolderType.VALUES

    override fun visitElement(context: XmlContext, element: Element) {
        if (element.tagName == "string") {
            val node = element.firstChild
            buildReport(value = node.nodeValue, context = context, location = context.getLocation(node))
        } else {
            buildReport(value = element.textContent, context = context, location = context.getLocation(element))
        }
    }

    private fun buildReport(value: String, context: XmlContext, location: Location) {
        if (value.toLowerCaseAsciiOnly().contains(" ")) {
            context.report(
                issue = ISSUE,
                location = location,
                message = MESSAGE,
                quickfixData = buildQuickFix(location)
            )
        }
    }

    private fun buildQuickFix(location: Location): LintFix =
        fix()
            .name("Отформатировать")
            .replace()
            .range(location)
            .text(" ")
            .with("\\u00A0")
            .build()
}

Заключение

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

В качестве бонуса можно добавить несколько советов:

  • В объекте JavaContext есть такая штука — evaluator, там лежит множество полезных функций. Например, получить имя пакета класса или метода, или сравнить сигнатуры двух методов (делать это ручками довольно утомительно).

  • Иногда тестами не очень удобно проверять какой‑то конкретный случай в конкретном месте кода, хочется сделать правку в детекторе и сразу увидеть изменения в инспекции студии. Нам поможет задача «:lint‑checks:jar» в Gradle, она сразу подсунет изменения в jar-директорию со всеми линтами студии, выполняется почти мгновенно.

  • Успешный прогон всех тестов ваших проверок не всегда гарантирует, что правило будет работать во всех случаях. В языке очень много разных вариантов, которые сложно учесть с первого раза, поэтому часто полезно запускать lintDebug на каких‑то реальных модулях и смотреть отчёты.

  • LintFix'ы можно подружить с автоформатированием студии (например, галочка «Reformat code» при коммите) через метод autoFix и другие. Но нужно убедиться, что это исправление работает безупречно.

  • Location для отчёта чаще всего будет вычисляться из узла через методы контекста, но иногда может понадобиться создать свой. Для этого можно использовать методы create у этого класса. Только тут нужно понимать, что offset, который обычно там везде фигурирует, это отступ от начала файла до текущего узла, а не от текущего узла, как может сначала показаться. То есть если вы хотите смотреть только в UMethod, а не весь UFile, придётся вычислить отступ для того участка кода, где вы хотите сообщить о проблеме. Для этого можно воспользоваться готовыми функциями из класса Document из psiElement.containingFile.viewProvider.document.

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