Files
obsidian/Kotlin/Инструментарий/Линтеры и формат.md
T

22 KiB
Raw Blame History

title, description, tags
title description tags
Линтеры и формат ktlint и detekt — автоформатирование, статический анализ, конфигурация и интеграция в CI
kotlin
tooling
quality

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

ktlint detekt
проверяет форматирование и имена сложность, запахи кода
автоисправление да, большинство правил частично
настройка .editorconfig, минимум правил detekt.yml, ~200 правил
нужен ли compile classpath нет нет (есть опциональный type resolution)
скорость быстро медленнее
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.0"
    id("org.jlleitschuh.gradle.ktlint") version "12.1.2"
    id("io.gitlab.arturbosch.detekt") version "1.23.8"
}

repositories { mavenCentral() }

detekt {
    buildUponDefaultConfig = true
    allRules = false
}

ktlint

Реализует официальный Kotlin coding conventions. Настроек почти нет - это и есть идея: не спорить о стиле.

На намеренно плохом файле:

// неформатированный код
// class bad_name {
//     fun TooComplex( a:Int,b:Int ) : Int {
//         var r = 0
//         if (a>0) { if (b>0) { if (a>b) { r=1 } else { r=2 } } else { r=3 } } else { r=4 }
//         return r
//     }
// }

fun main() {
    println("пример неформатированного кода выше")
}
$ ./gradlew ktlintMainSourceSetCheck
Bad.kt:3:7 Class or object name should start with an uppercase letter and use camel case (cannot be auto-corrected)
Bad.kt:4:9 Function name should start with a lowercase letter (except factory methods) and use camel case (cannot be auto-corrected)
Bad.kt:4:20 Unexpected spacing after "("
Bad.kt:4:23 Missing spacing after ":"
Bad.kt:4:27 Missing spacing after ","
Bad.kt:6:14 Missing spacing around ">"
Bad.kt:6:18 A single line if-statement should be kept simple. The 'THEN' may not be wrapped in a block. (cannot be auto-corrected)
Bad.kt:6:20 Missing newline after '{'

./gradlew ktlintFormat исправляет всё, что помечено как исправимое:

// после ktlintFormat
class BadName {
    fun tooComplex(
        a: Int,
        b: Int,
    ): Int {
        var r = 0
        if (a > 0) {
            if (b > 0) {
                r = if (a > b) 1 else 2
            } else {
                r = 3
            }
        } else {
            r = 4
        }
        return r
    }
}

fun main() {
    println(BadName().tooComplex(1, 2))        // 2
}

Пометка «cannot be auto-corrected» означает, что правило требует решения человека: переименование класса меняет API, разворот if в блок - семантику читаемости.

Настройка ktlint

Через .editorconfig в корне проекта:

root = true

[*.{kt,kts}]
indent_size = 4
indent_style = space
max_line_length = 120
insert_final_newline = true
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_function-naming = disabled
ktlint_code_style = ktlint_official
Параметр Значение
ktlint_code_style ktlint_official, intellij_idea, android_studio
max_line_length длина строки (off отключает)
ktlint_standard_<rule> = disabled отключить конкретное правило
ij_kotlin_allow_trailing_comma разрешить висячую запятую

Важно, что .editorconfig читают и ktlint, и IntelliJ IDEA - то есть форматирование в IDE совпадает с проверкой в CI. Это главный аргумент за .editorconfig вместо настроек плагина.

Отключение правила в коде:

@Suppress("ktlint:standard:max-line-length")
val longLine = "очень длинная строка, которую нельзя разбить по смыслу, например регулярное выражение"

fun main() {
    println(longLine.length)        // 85
}

detekt

Около 200 правил, разбитых на наборы. Запуск на том же плохом файле:

$ ./gradlew detekt
Bad.kt:3:7: The file name 'Bad' does not match the name of the single top-level declaration 'bad_name'. [MatchingDeclarationName]
Bad.kt:1:1: The package declaration does not match the actual file location. [InvalidPackageDeclaration]
Bad.kt:3:7: Class and Object names should match the pattern: [A-Z][a-zA-Z0-9]* [ClassNaming]
Bad.kt:4:9: Function names should match the pattern: [a-z][a-zA-Z0-9]* [FunctionNaming]
Bad.kt:6:72: This expression contains a magic number. [MagicNumber]
Bad.kt:9:24: Private property `x` is unused. [UnusedPrivateProperty]

Обратите внимание на отличие от ktlint: detekt нашёл несовпадение имени файла с объявлением, магические числа и неиспользуемое свойство - это вопросы смысла, а не форматирования.

Набор правил Что проверяет
complexity длинные функции, высокая цикломатическая сложность, много параметров
coroutines неправильное использование suspend, GlobalScope, блокирующие вызовы
empty-blocks пустые catch, if, функции
exceptions catch (e: Exception), проглоченные исключения, throw в finally
naming соответствие конвенциям имён
performance forEach вместо for, лишние аллокации, spread-оператор
potential-bugs !!, небезопасные приведения, игнорируемый результат
style длинные строки, wildcard-импорты, магические числа

Наиболее ценные на практике - coroutines и potential-bugs: они находят реальные ошибки, а не стилистические отклонения.

Конфигурация detekt

./gradlew detektGenerateConfig        # создаст config/detekt/detekt.yml
# config/detekt/detekt.yml
build:
  maxIssues: 0
  excludeCorrectable: false

complexity:
  LongMethod:
    active: true
    threshold: 60
  LongParameterList:
    active: true
    functionThreshold: 6
    ignoreDefaultParameters: true
  CyclomaticComplexMethod:
    active: true
    threshold: 15

style:
  MagicNumber:
    active: true
    ignoreNumbers: ['-1', '0', '1', '2']
    ignoreHashCodeFunction: true
    excludes: ['**/test/**']
  MaxLineLength:
    maxLineLength: 120
  WildcardImport:
    active: true

potential-bugs:
  UnsafeCallOnNullableType:
    active: true

coroutines:
  GlobalCoroutineUsage:
    active: true
  InjectDispatcher:
    active: true
// build.gradle.kts
detekt {
    buildUponDefaultConfig = true                      // своя конфигурация дополняет дефолтную
    config.setFrom("$projectDir/config/detekt/detekt.yml")
    baseline = file("$projectDir/config/detekt/baseline.xml")
    allRules = false
    parallel = true
}

buildUponDefaultConfig = true критично: без него в detekt.yml нужно перечислить все правила, и забытое правило окажется отключённым.

Baseline для существующего проекта

Включение detekt на большой кодовой базе даёт сотни замечаний. Baseline фиксирует существующие нарушения, чтобы проверять только новый код:

./gradlew detektBaseline        # создаст baseline.xml
<?xml version="1.0" ?>
<SmellBaseline>
  <ManuallySuppressedIssues/>
  <CurrentIssues>
    <ID>MagicNumber:Bad.kt$6:72</ID>
    <ID>UnusedPrivateProperty:Bad.kt$private val x</ID>
  </CurrentIssues>
</SmellBaseline>

Новые нарушения ломают сборку, старые игнорируются. Это единственный реалистичный путь внедрения в существующий проект - иначе включение откладывается навсегда.

Подавление правил

@Suppress("MagicNumber")
fun httpTimeout() = 30_000

class Legacy {
    @Suppress("TooGenericExceptionCaught", "SwallowedException")
    fun risky() {
        try {
            error("сбой")
        } catch (e: Exception) {
            // осознанно проглочено: вызывающий код не должен знать о сбое
        }
    }
}

fun main() {
    println(httpTimeout())        // 30000
    Legacy().risky()
}

Подавление - нормальная практика, когда правило не применимо к конкретному случаю. Плохая практика - подавлять правило целиком в detekt.yml, если оно нарушается в двух местах из тысячи.

Type resolution

detekt может работать с информацией о типах - тогда доступны правила, требующие знания classpath:

// build.gradle.kts
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
    jvmTarget = "21"
}

tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektWithTypes") {
    description = "detekt с разрешением типов"
    setSource(files("src/main/kotlin"))
    classpath.setFrom(
        configurations.getByName("detekt"),
        sourceSets["main"].compileClasspath,
    )
}

Правила вроде UnnecessaryNotNullOperator или RedundantSuspendModifier без classpath работать не могут. Цена - заметно медленнее.

Интеграция в CI

# .github/workflows/check.yml
name: check
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew ktlintCheck detekt --continue
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports
          path: |
            build/reports/detekt/
            build/reports/ktlint/

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

Git-хук

# .git/hooks/pre-commit
#!/bin/sh
./gradlew ktlintFormat --daemon -q || exit 1
git add -u

Автоформат перед коммитом избавляет от «правок форматирования» в diff. detekt в хук обычно не ставят - он слишком медленный для каждого коммита.

Плагин ktlint умеет установить хук сам:

./gradlew addKtlintFormatGitPreCommitHook

Таски

Таск Что делает
ktlintCheck проверка всех source sets
ktlintFormat автоисправление
ktlintMainSourceSetCheck только main
detekt анализ
detektBaseline создать baseline
detektGenerateConfig создать detekt.yml
detektMain только main (при buildUponDefaultConfig)

Альтернативы и дополнения

Инструмент Задача
Spotless единая обёртка для форматирования (включая ktlint)
Kover покрытие тестами
binary-compatibility-validator контроль публичного ABI
-Xexplicit-api требовать явные типы в публичном API
Qodana / SonarQube анализ на стороне сервера

-Xexplicit-api - встроенная в компилятор проверка, дополняющая линтеры: она требует явно указывать видимость и типы возврата для публичных объявлений. Подробнее: Компилятор K2

Разумный минимум

  1. .editorconfig с ktlint_official - согласованный стиль в IDE и CI.
  2. ktlintFormat в pre-commit хуке - форматирование не попадает в diff.
  3. detekt с buildUponDefaultConfig и baseline - новый код проверяется, старый не блокирует.
  4. ktlintCheck detekt --continue в CI с публикацией отчётов.
  5. -Xexplicit-api=strict для библиотек.

Этого достаточно; дальнейшая настройка правил - вопрос договорённостей команды, а не инструментов.

[!info] Отличия от Java

Java Kotlin
Checkstyle ktlint
SpotBugs, PMD, ErrorProne detekt
google-java-format, Spotless ktlint (или Spotless + ktlint)
стиль - вопрос договорённости официальный coding conventions
конфигурация XML .editorconfig + YAML
нет аналога -Xexplicit-api в компиляторе

Официальный style guide меняет роль форматтера. В Java конкурируют несколько стилей (Oracle, Google, AOSP), и Checkstyle приходится настраивать под выбранный. Kotlin имеет один официальный стиль от JetBrains, реализованный и в IDE, и в ktlint - настройка почти не нужна, и споров о форматировании не возникает. Это сознательная позиция: единый стиль важнее возможности выбрать свой.

.editorconfig как единый источник правды - важнее, чем кажется. В Java настройки Checkstyle и настройки форматирования IDE - разные файлы, которые расходятся. Файл .editorconfig читают и IntelliJ, и ktlint, поэтому «форматирование в IDE» и «проверка в CI» - буквально одна конфигурация. Это устраняет классический конфликт, когда IDE переформатирует код так, что падает сборка.

detekt берёт на себя то, что в Java распределено между тремя инструментами. SpotBugs анализирует байт-код, PMD - исходники, ErrorProne встроен в компиляцию. detekt - один инструмент на исходниках, с опциональным разрешением типов. Проще в настройке, но находит меньше: без анализа байт-кода часть проблем (например, утечки ресурсов по потоку данных) недоступна.

Правила для корутин - специфика, которой нет аналога. Набор coroutines в detekt находит GlobalScope, блокирующие вызовы внутри suspend, захардкоженные диспетчеры. В Java параллельные ошибки (блокировка в пуле, утечка ExecutorService) ловятся хуже, потому что нет структуры, которую можно статически проверить. Структурированная конкурентность Kotlin даёт линтеру опору. Подробнее: Структурированная конкурентность

Сравнить: javac

[!warning] Подводные камни buildUponDefaultConfig = false (по умолчанию) отключает все неперечисленные правила. Свой detekt.yml без этого флага заменяет дефолтную конфигурацию целиком: сборка зелёная, проверок нет. Это самая частая ошибка настройки detekt.

detekt падает на слишком новом JDK. Версия 1.23.8 на JDK 26 завершается с GradleException: 26.0.1 - встроенный парсер не знает такой версии байт-кода. Запускайте таск на поддерживаемом JDK: jvmToolchain для компиляции и отдельный JDK для detekt, или обновите detekt.

ktlint и detekt могут конфликтовать по правилам. Оба проверяют длину строки и имена, и при разных настройках код, исправленный одним, нарушает правило другого. Задавайте max_line_length в .editorconfig, а в detekt - MaxLineLength.maxLineLength с тем же значением.

ktlintFormat может изменить семантику в редких случаях. Перестановка импортов и добавление висячих запятых безопасны, но правила вроде if-else → выражение меняют структуру кода. После массового автоформата запускайте тесты.

Baseline легко превращается в свалку. Файл создаётся раз и начинает жить вечно: правила «починим позже» не выполняются. Перегенерируйте baseline периодически и следите, чтобы он сокращался.

Правила с type resolution требуют classpath и замедляют анализ. Без classpath.setFrom(...) правила вроде UnnecessaryNotNullOperator молча не работают. С ним detekt становится в разы медленнее - обычно такой таск запускают только в CI.

MagicNumber шумит в тестах. Тесты полны литералов, и правило даёт десятки замечаний. Добавьте excludes: ['**/test/**'] в конфигурацию, иначе придётся подавлять в каждом файле.

Висячие запятые требуют согласованности. ij_kotlin_allow_trailing_comma = true в .editorconfig и настройка IDE должны совпадать, иначе IDE удаляет запятую, а ktlint требует её вернуть - бесконечный цикл правок.

Версия плагина ktlint и версия самого ktlint - разные числа. org.jlleitschuh.gradle.ktlint версии 12.x по умолчанию использует ktlint 1.x; при необходимости версия задаётся через ktlint { version.set("1.5.0") }. Несогласованность приводит к разным результатам локально и в CI.

Проверка в CI без --continue скрывает часть проблем. Сборка падает на ktlintCheck, и detekt не выполняется - разработчик исправляет форматирование, пушит, и только тогда узнаёт о замечаниях detekt. Два цикла вместо одного.

Git-хук с detekt делает коммиты медленными. Полный анализ среднего проекта - десятки секунд. В хук ставьте только ktlintFormat, detekt оставьте для CI.

@Suppress с неверным именем правила молча не работает. Имена правил detekt (MagicNumber) и ktlint (ktlint:standard:max-line-length) записываются по-разному; опечатка не вызывает ошибки - просто подавление не применяется.

Далее: Тестирование