Продолжим рассматривать различные нюансы статического анализа с помощью Lint. Опираться будем на предыдущую статью. С момента её публикации прошло много времени, за которое вышло несколько значимых обновлений Android Gradle Plugin (а с ним и всех артефактов линтера), а также вторая версия Kotlin с новым компилятором. Вкратце рассмотрим наиболее заметные для нас изменения с небольшими примерами. Кроме того, удалось найти что-то похожее на полноценную документацию по lint (правда, редко обновляющуюся), наиболее интересные моменты оттуда тоже рассмотрим.
Kotlin Analysis API
Выход нового компилятора K2 всех встряхнул и заинтриговал, изменения коснулись и линта. Как известно, фронтенд компилятора служит для анализа семантики и синтаксиса исходного кода, который преобразуется в некоторое дерево с дополнительной метаинформацией и далее передаётся в бэкенд, где уже и собираются в нашем случае .class-файлы. Если коротко, в компиляторе K1 в бэкенд передаётся синтаксическое дерево вместе с BindingContext, описывающее семантику кода. В такой реализации были проблемы с производительностью, а также трудности в поддержке бекендов под разные таргеты (например, для JS).
В K2 подход изменили. Теперь задача фронтенда — преобразовать исходный в код в FIR (Frontend Intermediate Representation), то есть в некую структуру, описывающую и синтаксис, и семантику. Намеренно не буду углубляться, для нас тут достаточно понимать, что кроме AST и PSI будет ещё один инструмент/дерево для анализа.
Назвали этот инструмент Kotlin Analysis API. Информации о нём не слишком много, но резюмируя то, что есть, можно сразу констатировать несколько преимуществ:
Основан на PSI, с которым существенно удобнее работать, чем с AST.
Поддерживается получение типа для PSI, сейчас это та ещё заморочка.
Не забыли про Any/Nothing/Unit и т. д., да и в целом API уже частично закрыт всевозможными простыми в использовании расширениями.
Работает, как и раньше, «из коробки», упомянуто также, что есть некоторая обратная совместимость с К1.
Тема довольно обширная, на полноценную статью. Кроме того, разработчики упомянули, что некоторые части API временные или экспериментальные (в коде уже приличное количество Deprecated-аннотаций), то есть будут ещё доработки. Поэтому ограничимся только простенькими примерами.
Начнём с самого интересного — с типов в PSI. Есть у нас старый детектор, который проверяет, что во «View‑мире» при создании ViewBinding
в фрагментах через свойства класса это свойство затем обнуляется. Так как разметка у нас может быть названа как угодно, мы не можем заложиться в проверке на имя (впрочем, это справедливо почти для любых проверок). То есть нам нужно убедиться, что свойство класса (ну или field в контексте Java и AST) имеет тип, унаследованный от ViewBinding
.
Мы бы делали примерно так:
property.toUElementOfType<UField>()
?.type
?.let { type ->
type.superTypes.find { it.canonicalText == "androidx.viewbinding.ViewBinding" } != null
} == true
Или можно вот так:
TypeEvaluator.evaluate(context,property)
.superTypes.any { it.canonicalText == "androidx.viewbinding.ViewBinding" }
В Kotlin Analysis получилось примерно так:
analyze(property){
property.typeReference?.type?.allSupertypes?.any {
it.symbol?.classId?.asFqNameString() == "androidx.viewbinding.ViewBinding"
}
}
Тут может показаться, что особой разницы нет, но это не так. Во‑первых, нет никакой гарантии, что любой узел дерева PSI сможет зарезолвиться в AST. Тот же field из примера не то же самое, что property (второй пример вообще будет возвращать null). Для некоторых конструкций Kotlin вообще нет аналога в AST, например, companion object, inline и т. д. Чтобы анализировать Kotlin, нужен PSI, а в нём нет типов.
Во‑вторых, нам предлагают полноценный API, который одинаковым образом будет использоваться для всех видов анализа. Раньше в каждой проверке мы индивидуально решали, как именно мы этот тип будем доставать.
Ну и наконец, этот API уже сейчас выглядит солидно, множество методов и расширений, позволяющих внутри блока analyze
получить куда больше информации, чем требовалось в примере. Здесь же можно сразу убедиться, что тип «нулабельный» (isMarkedNullable
), проверить модификаторы видимости (они не всегда доступны через PSI), аннотации и т. д. Но есть и более сложные штуки, например, одним вызовом получить всех наследников sealed‑интерфейса (с полной семантикой по каждому) или убедиться, что аргумент функции является интерфейсом.
Ключевая сущность в Kotlin Analysis — Symbol. Если коротко и упрощённо, то это некая абстракция узла дерева, которая и содержит в себе семантическую информацию, дополняя ею PSI. В примере выше полное имя класса получаем через свойство classId
у symbol. Точкой входа служит блок analyze
с KaSession
. Предполагается, что вся работа с этим API будет только внутри блока, уносить куда‑то или сохранять KaSession
противоречит контракту и приведёт к неправильной работе. Однако есть возможность создавать KaSymbolPointer
, если нужно как‑то связать между собой два разных блока analyze
.
Тестирование
TestMode
Где-то с год назад при написании тестов на детектор была распространена проблема, когда запускаешь тест, а он падает с непонятной ошибкой с предложением добавить какой-нибудь TestMode. После добавления оно и правда почему-то работало. После обновления Gradle-плагина эти ошибки получили довольно понятные, исчерпывающие описания. Как оказалось, за TestMode стоит целый дивный новый мир.
TestMode — это как бы режимы, в которых дополнительно к обычному прогону исполняются дополнительные тесты в различных вариациях. Они позволяют убедиться, что правило lint написано максимально корректно для всех возможных случаев написания кода. Например, есть у нас детектор, проверяющий правильность использования Serializable
. Уже из названия непонятно, какой именно Serializable
имеется в виду (kotlinx.serialization.Serializable
или java.io.Serializable
). Если в детекторе мы завяжемся просто на имя, то тест по умолчанию будет падать, так как умеет подменять импорты (то есть автоматически будут внесены изменения в код проверяемых классов и перезапущен тест). Также типы могут быть заменены на typealias
или на import alias
, добавлены ненужные полные пути, ненужные вызовы родительских классов, двойные пробелы, может быть изменён порядок именованных аргументов при вызове методов и т. д. В итоге тесты по умолчанию будут падать, если детектор не учитывает какой‑либо из этих режимов. Эти крайне полезные проверки заставляют думать наперёд и поменьше хардкодить.
Однако бывают случаи, когда делать это избыточно или неудобно (например, сразу после поднятия версии много тестов резко попадали), тогда можно какие-то режимы отключить через вызов .skipTestModes(TestMode.WHITESPACE)
. Ну или наоборот, написать тест под конкретный режим через addTestModes(TestMode.WHITESPACE)
. Здесь есть небольшое описание некоторых режимов, если по сообщению ошибки в тесте останутся вопросы.
trimIndent
При добавлении стабов и файлов для проверки в тестах мы использовали """ в студии, которая, в свою очередь, подставляла в конец .trimIndent()
. Такой подход не рекомендуется, предлагается убирать trimIndent
в пользу indented
у TestFiles
. Если честно, мы не сталкивались с какими-то проблемами, а форматирование в самом сообщении об ошибке в тесте нас не особо беспокоит, но иметь в виду не помешает.
composite lintFix
Бывают случаи, когда нужно предоставить несколько альтернативных вариантов автоматически исправить найденную проблему через lintFix
. Это делается через fix().composite()
. Но есть нюанс: указанные здесь исправления в студии будут расположены в алфавитном порядке по имени, указанному в .name()
, а не в порядке их следования в массиве. Чтобы избежать ошибок в тестах, лучше в .composite()
сразу располагать исправления в алфавитном порядке.
Частичный анализ
У lint есть концепция, позволяющая настраивать автоматический пропуск правил по каким-то критериям, заранее заданным в детекторе.
Если повнимательнее рассмотреть методы report
в Context
, от которого наследуется JavaContext
, то можно увидеть любопытную сигнатуру report(incident: Incident, constraint: Constraint)
. Чуть покопавшись, выясняем, что можно задавать какие-нибудь условия для работы наших проверок, в зависимости от свойств и настроек проекта или его окружения. Типичный пример: нет смысла запускать какую-нибудь проверку и показывать инспекцию для какой-то проблемы для 14-й версии SDK, если у вас minSdkVersion = 16
(условно). Выглядеть такой отчёт в классе детектора будет примерно так:
val constraint = minSdkAtLeast(14) and notLibraryProject()
context.report(Incident(ISSUE, node, context.getLocation(property), MESSAGE), constraint)
Incident
здесь — это просто обёртка, в которую заворачиваются все параметры, что мы раньше передавали в report для JavaContext
(там также есть билдер). В классе Constraint
описано некоторое количество расширений, как в примере. К сожалению, этот класс sealed, что не позволяет нам удобно добавлять какие‑то свои ограничения, а тот небольшой имеющийся набор более актуален разработчикам SDK.
Однако, возможность исключать некоторые проверки всё же есть, для этого в классе детектора необходимо переопределить метод filterIncident
. Просто проверяем какие‑то условия и возвращаем boolean. У этого метода в качестве аргумента есть специальная мапа — LintMap, она позволяет записать в методе report
и использовать потом какие‑то значения (не рекомендуется использовать для этого сам класс детектора, так как его экземпляр всегда будет новый для других модулей или сборок).
Предположим, нам нужно проверять использование кастомных ресурсов в проекте (то есть они определены в фичёвом модуле, а не в модуле или библиотеке дизайн-системы). Загвоздка в том, что Gradle-модули у нас, как правило, изолированы от остального проекта в процессе анализа. Обычно нам это никак не мешает, если мы просто ищем ошибки в коде (наоборот, зачем нам анализировать весь большой проект, если мы работаем только в нашем модуле). С другой стороны, в нашем примере мы никак не сможем узнать, существует ли ссылка на ресурс, если он находится в другом модуле. В таком случае можно использовать связку getPartialResults
и checkPartialResults
.
override fun afterCheckRootProject(context: Context) {
context.getPartialResults(ISSUE).map().put("KEY", "serializedPaths")
}
override fun checkPartialResults(context: Context, partialResults: PartialResult) {
partialResults
.asSequence()
.mapNotNull { (_, map) -> map.getString("KEY", "") }
}
Метод getPartialResults
служит для записи и удержания вспомогательных данных для конкретного Issue, ну а в checkPartialResults
(вызывается на этапе формирования отчёта) эти данные десериализуются из мапы. Мы могли бы пройтись и записать ссылки на доверенные ресурсы (из дизайн-системы), а затем проверить, существует ли у текущего ресурса такая ссылка в мапе. Таким образом, через механизм Partial можно собирать данные, относящиеся к разным модулям, а затем в конце, при публикации отчёта собрать эти данные, проверить и добавить ещё один дополнительный report в общий отчёт. У такого подхода есть очевидное ограничение: инспекции в студии работать не будут, так как для анализа в моменте будет нужен весь проект. Но это годится для проверок на CI при сборках для code review. По этой же схеме работает фича в студии по поиску неиспользуемых ресурсов.
Data Flow Analyzer
В документации также описана ещё одна возможность для линтера, хотя и не часто используемая. С помощью DataFlowAnalyzer
мы можем проверить, был ли вызов какого‑то узла (например, UCallExpression
) для экземпляра какого‑либо класса. Типичный пример — это линт на старый добрый toast
, когда все постоянно забывали писать в конце show()
. Для этого предлагается использовать TargetMethodDataFlowAnalyzer
:
val targets = mapOf("show" to listOf("android.widget.Toast",
"com.google.android.material.snackbar.Snackbar")
val analyzer = TargetMethodDataFlowAnalyzer.create(node, targets)
if (method.isMissingTarget(analyzer)) {
context.report(...)
}
В DataFlowAnalyzer
скрывается большой пласт логики, но всё сводится к обычному поиску через паттерн visitor, что мы могли бы и сами сделать. Оба эти класса открытые и позволяют существенно переопределить логику, но вряд ли это будет нужно, наиболее интересные методы (для них даже отдельный класс выделен — EscapeCheckingDataFlowAnalyzer
):
var escaped: Boolean = false
override fun field(field: UElement) {
escaped = true
}
override fun argument(call: UCallExpression, reference: UElement) {
escaped = true
}
override fun returns(expression: UReturnExpression) {
escaped = true
}
override fun array(array: UArrayAccessExpression) {
escaped = true
}
Как можно догадаться из названий, они служат для обработки случаев, когда экземпляр, на котором анализируется вызов метода, передаётся как аргумент, возвращается из другого метода и т. д.
ВTargetMethodDataFlowAnalyzer
ключевые методы — isTargetMethodName
и isTargetMethod
, где и сопоставляется вызов метода для DataFlowAnalyzer
.
Заключение
Рассмотрели ещё несколько возможностей, которые нам предоставляет API линта для написания более сложных проверок более простыми (по мнению разработчиков из гугла) механизмами. Немного потрогали Kotlin Analysis API, выглядит многообещающе, но мы пока не форсируем переход на него, ждём стабилизации API. Остальные описанные функции могут показаться узконаправленными, но бывают задачи, которые по‑другому никак не сделать, или это очень сложно.
В качестве бонуса несколько заметок:
В Google завели репозиторий, где пишут различные детекторы на тему безопасности. Идея хорошая, но реализация нам показалась пока ещё сыроватой, а репозиторий — каким‑то учебным проектом. Там много неточностей, хардкода, неактуальная версия Gradle-плагина. Однако, какие‑то отдельные части брать можно и обустраивать для своих целей, либо подождать, пока владельцы доведут проект до ума. Кстати, там есть
UnsafeFilenameDetector
, где можно посмотреть пример использованияDataFlowAnalyzer
для методов классаCursor
.Разбирая код из предыдущего пункта, заметили несколько любопытных вещей. Например, есть такой чудесный класс
SdkConstants
, в котором содержится огромное количество всевозможных констант, папки, пакеты классов SDK, атрибуты XML и много чего ещё. Сложно пользоваться, так как не знаешь, что там есть, но часть констант всё же полезна.Как уже упоминалось ранее, в стандартном lint есть категория проблем с
Severity.Fatal
, для которых при невыполнении проверки будет падать релизная сборка. В дополнение к этому есть ещё такойCommentDetector
, который проверяет в коде наличие комментария//STOPSHIP
и тоже треггерит падение релизной сборки. Это может быть полезно, если вы разрабатываете какие‑нибудь debug tools и не хотите, чтобы они случайно попали в production.У JetBrains есть отдельная страничка про PSI, какие‑то вещи можно оттуда почерпнуть. Наиболее интересные, по нашему мнению, это про производительность в PSI и UAST и заметка про литералы в разделе об UAST.
Помимо упомянутых ранее
JavaEvaluator
иTypeEvaluator
в стандартных детекторах чаще всего встречается иConstantEvaluator
. В основном используется для быстрого получения значения или литерала какого‑либо узла (например, значение, присвоенное константе).В какой‑то момент столкнулись с тем, что в
context.project.testSourceFolders
возвращались пустыми, хотя сами директории существуют. Перешли наcontext.project.dir?.listFiles()
, где ищем тестовые директории вручную.