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

386 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)