🔧

detekt カスタムルールでコーディング規約を運用する

2024/10/14に公開

はじめに

detektとは

コードの品質を保つために、Linterは欠かせません。Kotlinプロジェクトではそのうちの一つとしてdetektが広く利用されています。detektはKotlinのベストプラクティスに基づいて多数のルールセットを提供する強力なツールです。
更にCIを利用してdangerreviewdogのようなフィードバックツールにdetektの解析結果を連携することで、Pull Requestに解析結果を自動でコメントしてコードレビューの負荷を大きく軽減することも可能です。

コーディング規約は運用が難しい

チーム開発をするうえではコーディング規約(命名規則、インデント、コメントの付け方、コードスタイルの統一など)を決めることが多くあります。コーディング規約を決めることで、コードの一貫性、保守性を向上することができます。
ただ、コーディング規約を守るためには多大な努力が必要であり、形骸化するリスクに立ち向かう必要があります。
コーディング規約の運用を困難にするのは、以下のような理由が考えられます。

  • ルールが厳密に明文化されていない
  • 強制力がない
  • 新規参画メンバーがコーディング規約の存在を認知できていない
  • コーディング規約を指摘する古参メンバーがいなくなることで、忘れ去られる

detekt カスタムルールの有用性

コーディング規約を守るためには「ルールが厳密に明文化された」うえでCI等で「強制力」をもって日々チェックすることが望ましいです。ただプロジェクトによってコーディング規約は大きく異なるため、linterが提供する標準のルールではカバーできないことが多くあります。

嬉しいことにdetektにはカスタムルールを作成する機能が備わっています。慣れ親しんだkotlinのコードでコーディング規約を記述して、detektの解析ルールに追加することができます。

この記事の対象

この記事では、detektでカスタムルールを作成する方法について解説します。

  • この記事で扱わないこと
    • detektの初期導入方法
    • detektとCIの連携方法
  • 対象読者
    • detektを既にプロジェクトに導入している
    • detektでコーディング規約も守りたい
  • この記事のゴール
    • detektでカスタムルールを作成できる
    • 作成したカスタムルールのテストコードも作成できる

以降の内容は公式templateをベースに既存プロジェクトへ導入する手順を解説していきます。
https://github.com/detekt/detekt-custom-rule-template

detektモジュールの導入

detekt モジュールの作成

detekt custome rulesは専用のbuild.gradle.ktsが必要になります。既存プロジェクトに導入する場合、:detektのモジュールを作成していくことにします。

下記の手順でdetektモジュールを追加します。
(Android Studioの場合)

  • File > New > New Module
  • Java or Kotlin Library
  • Library name = detekt

ライブラリの追加

detektカスタムルールに必要な依存を追加していきます。

  • plugin
    • org.jetbrains.kotlin.jvm
  • library
    • io.gitlab.arturbosch.detekt:detekt-api
    • io.gitlab.arturbosch.detekt:detekt-test

(version catalogの場合)

libs.versions.toml
[versions]
detekt = "1.23.7"
jetbrainsKotlinJvm = "1.9.0"

[libraries]
detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }

[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

root projectのbuild.gradle.ktsに以下の変更を追加します。

build.gradle.kts(root-project)
import io.gitlab.arturbosch.detekt.Detekt
plugins {
    alias(libs.plugins.detekt)
    // kotlin jvmを追加
+    alias(libs.plugins.jetbrainsKotlinJvm) apply false
}

dependencies {
    // detektモジュールをdetekt pluginとして読み込む
+    detektPlugins(project(":detekt"))
}

// detektモジュールのカスタムルールがビルドが完了してから、detekt解析を実行する
+ tasks.withType<Detekt> { dependsOn(":detekt:assemble") }

detektモジュールのbuild.gradle.ktsに以下の変更を追加します。

build.gradle.kts(detekt)
plugins {
    alias(libs.plugins.jetbrainsKotlinJvm)
}

dependencies {
    compileOnly(libs.detekt.api) // "io.gitlab.arturbosch.detekt:detekt-api"

    testImplementation(libs.detekt.test) // "io.gitlab.arturbosch.detekt:detekt-test"
    // (任意)この辺はお好みのテストライブラリ
    testImplementation(libs.kotlin.test) 
    testImplementation(libs.junit)
}

kotlin {
    jvmToolchain(8)
}

// detektルールをテストするための設定
tasks.withType<Test>().configureEach {
    useJUnitPlatform()
    systemProperty("junit.jupiter.testinstance.lifecycle.default", "per_class")
    systemProperty("compile-snippet-tests", project.hasProperty("compile-test-snippets"))
}

カスタムルールの作成

カスタムルールの仕組み

detektカスタムルールは以下の3つから構成されます

  • Rule
    • detektのルールを定義するクラス
    • これを継承してカスタムルールを作成する
  • RuleSetProvider
    • 作成したRuleを登録するクラス
  • META-INF/services
    • 作成したRuleSetProviderをdetektプラグインに連携するためのファイル

detektモジュールの構成は下記のようなイメージです。

.
└── src
    ├── main
    │   ├── kotlin
    │   │   └── $package
    │   │       ├── MyRuleSetProvider.kt
    │   │       └── MyRule.kt
    │   └── resources
    │       ├── META-INF
    │       │   └── services
    │       │       └── io.gitlab.arturbosch.detekt.api.RuleSetProvider
    │       └── config
    │           └── detekt.yml
    └── test
        └── kotlin
            └── $package
                └── MyRuleTest.kt

Ruleの作成(本編)

Ruleクラスを継承してカスタムルールを作成します。
必要な実装は以下の内容です。

  • issue
    • detekt.ymlの設定ファイルで指定する内容を定義
  • visitXxxメソッド
    • kotlinコード解析時に呼び出されるメソッド
      • 例: visitClass(), visitProperty(), visitNamedFunction(), etc...
    • コードの構造がKtClassKtNamedFunctionのようなPSI(Program Structure Interface)として取得できる
      • これをチェックしてカスタムルールに違反しているかを判断する
      • コードがルールに違反している場合、reportメソッドでissueを出力する
  • reportメソッド
    • detektの解析実行時、issueを出力する

命名ルールやメソッドの制限など、好みのコーディング規約を実装することができます。
今回は例として「Repositoryが公開するメソッドはsuspendであること」というルールを作ることにします。(Repositoryにある処理がmainスレッドを要求するのは、何か特別な事情か怪しい実装をしているかもしれないねの思い)
RepositorySuspendRule.ktをdetektモジュールのmain配下に作成します。

RepositorySuspendRule
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.psiUtil.isPublic

// Ruleクラスを継承することで、detekt カスタムルールとなる
class RepositorySuspendRule(config: Config) : Rule(config) {
    // ymlで設定する項目の定義
    override val issue = Issue(
        id = javaClass.simpleName, // ymlで設定するルール名 今回はクラス名にする
        severity = Severity.CodeSmell,
        description = "Public function in repository should be suspend.",
        debt = Debt.FIVE_MINS,
    )

    // classを解析時に呼び出される処理
    override fun visitClass(klass: KtClass) {
        super.visitClass(klass)
        // クラス名に"Repository"が含まれていれば検査する
        if (klass.name?.contains("Repository") == true) {
            // 各関数について検査する
            klass.body?.functions?.forEach { function ->
                if (function.isPublic && !function.hasModifier(KtTokens.SUSPEND_KEYWORD)) {
                    // suspendのついていない公開された関数があればissueとして出力する
                    sendReport(function)
                }
            }
        }
    }

    private fun sendReport(function: KtNamedFunction) {
        report(
            finding = CodeSmell(
                issue = issue,
                entity = Entity.from(function), // 出力される指摘箇所を指定する
                message = "Public function in repository should be suspend.", // 出力されるメッセージ
            )
        )
    }
}

RuleSetProviderの作成

作成したRuleをdetektに登録する必要があります。
Ruleの登録はRuleSetProviderを利用します。
MyRuleSetProvider.kt(命名は任意)をdetektモジュールのmain配下に作成します。

MyRuleSetProvider
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.RuleSet
import io.gitlab.arturbosch.detekt.api.RuleSetProvider

class MyRuleSetProvider : RuleSetProvider {
    override val ruleSetId: String = "my-rule" // ymlで設定するトップレベルのルール名

    override fun instance(config: Config): RuleSet {
        return RuleSet(
            id = ruleSetId,
            rules = listOf(
                RepositorySuspendRule(config), // 作成したルールを任意の数追加できる
            ),
        )
    }
}

META-INF/servicesの作成

RuleSetProviderを作成したら、それをdetektに連携する必要があります。
/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProviderを作成し、作成したRuleSetProviderのパスを記述します。

.
└── src
    └── main
        ├── kotlin
        │   └── com
        │       └── example
        │           ├── MyRuleSetProvider.kt
        │           └── RepositorySuspendRule.kt
        └── resources
            └── META-INF
                └── services
                    └── io.gitlab.arturbosch.detekt.api.RuleSetProvider

io.gitlab.arturbosch.detekt.api.RuleSetProvider
com.example.MyRuleSetProvider

これでdetekt.ymlからRepositorySuspendRuleを指定できるようになりました。

作成したルールをactiveにする

detektのルールはデフォルトでOFFになっています。
ルールを作成しただけではdetektで実行されないためdetekt.ymlで指定する必要があります。

detekt.yml
my-rule: 
  RepositorySuspendRule:
    active: true

これでカスタムルールの追加は完了です🎉
./gradlew detektを実行すると追加したルールが反映されていることを確認できます。

カスタムルールのテスト

作成したルールが正しく動作しているかを確認するのに、実際のコードベースを変更するのは非効率です。detekt カスタムルールはテストコードにも対応しているため、ぜひ活用しましょう。
detekt/src/test/kotlin/io/github/kabos/bus/detekt/RepositorySuspendRuleTest.ktを作成します。
以下は「Repositoryが公開するメソッドはsuspendであること」のテストの例です。

RepositorySuspendRuleTst
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

@Suppress("NonAsciiCharacters")
@KotlinCoreEnvironmentTest
internal class RepositorySuspendRuleTest(private val env: KotlinCoreEnvironment) {

    @Test
    fun `公開された関数がsuspendになっている場合、Issueを検知しない`() {
        val code = """
        class FooRepository {
            suspend fun get() {
                // suspendなのでok
            }
            private fun save() {
                // privateなのでsuspendがなくてもok
            }
        }
        """.trim()
        val result = RepositorySuspendRule(Config.empty).compileAndLintWithContext(env, code)
        assertEquals(0, result.size)
    }


    @Test
    fun `公開された関数がsuspendになっていない場合、Issueを検知する`() {
        val code = """
        class FooRepository : Repository {
            fun get() {
                // suspendになっていないのでNG
            }
            overrider fun save() {
                // suspendになっていないのでNG
            }
        }
        """.trim()
        val result = RepositorySuspendRule(Config.empty).compileAndLintWithContext(env, code)
        assertEquals(2, result.size)
    }
}

まとめ

この記事ではdetektカスタムルールの作成方法について解説しました。慣れ親しんだkotlinのコードで直感的に様々なルールを定義することができます。
コーディング規約をdetektのルールとして明文化することで、レビュー負荷を軽減し、プロジェクトの品質を保つために役立てることができます。
detektを活用してKotlinの世界に秩序をもたらしましょう🎉

参考リンク

Discussion