386 lines
22 KiB
Markdown
386 lines
22 KiB
Markdown
---
|
||
title: Линтеры и формат
|
||
description: ktlint и detekt — автоформатирование, статический анализ, конфигурация и интеграция в CI
|
||
tags:
|
||
- kotlin
|
||
- tooling
|
||
- quality
|
||
---
|
||
|
||
Два инструмента с разными задачами: **ktlint** приводит код к официальному стилю и умеет исправлять сам, **detekt** ищет проблемы смысла - сложные функции, мёртвый код, подозрительные конструкции.
|
||
|
||
| | ktlint | detekt |
|
||
|---|---|---|
|
||
| проверяет | форматирование и имена | сложность, запахи кода |
|
||
| автоисправление | да, большинство правил | частично |
|
||
| настройка | `.editorconfig`, минимум правил | `detekt.yml`, ~200 правил |
|
||
| нужен ли compile classpath | нет | нет (есть опциональный type resolution) |
|
||
| скорость | быстро | медленнее |
|
||
|
||
```kotlin
|
||
// 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. Настроек почти нет - это и есть идея: не спорить о стиле.
|
||
|
||
На намеренно плохом файле:
|
||
```kotlin
|
||
// неформатированный код
|
||
// 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` исправляет всё, что помечено как исправимое:
|
||
```kotlin
|
||
// после 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` вместо настроек плагина.
|
||
|
||
Отключение правила в коде:
|
||
```kotlin
|
||
@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
|
||
```
|
||
```yaml
|
||
# 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
|
||
```
|
||
```kotlin
|
||
// 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
|
||
<?xml version="1.0" ?>
|
||
<SmellBaseline>
|
||
<ManuallySuppressedIssues/>
|
||
<CurrentIssues>
|
||
<ID>MagicNumber:Bad.kt$6:72</ID>
|
||
<ID>UnusedPrivateProperty:Bad.kt$private val x</ID>
|
||
</CurrentIssues>
|
||
</SmellBaseline>
|
||
```
|
||
Новые нарушения ломают сборку, старые игнорируются. Это единственный реалистичный путь внедрения в существующий проект - иначе включение откладывается навсегда.
|
||
|
||
## Подавление правил
|
||
```kotlin
|
||
@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:
|
||
```kotlin
|
||
// 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
|
||
```yaml
|
||
# .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-хук
|
||
```bash
|
||
# .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](Компилятор%20K2.md)
|
||
|
||
## Разумный минимум
|
||
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 даёт линтеру опору. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md)
|
||
>
|
||
> Сравнить: [javac](../../Программирование%20на%20Java/JDK/javac.md)
|
||
|
||
> [!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`) записываются по-разному; опечатка не вызывает ошибки - просто подавление не применяется.
|
||
|
||
Далее: [Тестирование](../Тестирование/Тестирование.md)
|