虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Kotlinの静的コード解析ツール「detekt」に独自ルールを追加しよう!

こんにちは!虎の穴ラボの鷺山です。

前回はKotlinの静的コード解析ツール「detekt」でコードをキレイに保とう!という内容で、detektの導入方法や基本的な使い方をご紹介しました。

今回はさらに踏み込んで、detektに独自のカスタムルールを追加する方法をご紹介します!

カスタムルールを使うと、そのプロジェクト独自のルールに基づいてKotlinのコードを自動でチェックできます。これにより、人の手で確認していたチェック作業を省力化できたり、コードレビューでの問題の見逃しを減らせるかもしれません。

目次

前提環境

環境 使用バージョン
JDK Amazon Corretto 21
Gradle Gradle Wrapper 8.5
Kotlin 2.0.0
detekt 1.23.8

準備: detektのセットアップ

まずは前回の内容と同様に、detektプラグインをGradleプロジェクトに導入します。

plugins {
    ...
    id("io.gitlab.arturbosch.detekt") version "1.23.8"
}

ステップ1: カスタムルール用のGradleサブプロジェクトを作成する

detektのカスタムルールを作成・管理するための新しいGradleサブプロジェクト (モジュール) を作成します。
サブプロジェクト名はdetekt-rulesとします。

まず、Gradleのプロジェクト設定ファイルsettings.gradle.ktsに以下を追記し、Gradleに新しいサブプロジェクトを認識させます。

settings.gradle.kts

include("detekt-rules")

次に、サブプロジェクト用のディレクトリdetekt-rulesを作成し、その中にビルド設定ファイルbuild.gradle.ktsを作成します。

detekt-rules/build.gradle.kts

plugins {
    kotlin("jvm")
}

repositories {
    mavenCentral()
}

dependencies {
    compileOnly("io.gitlab.arturbosch.detekt:detekt-api:1.23.8")
    testImplementation("io.gitlab.arturbosch.detekt:detekt-test:1.23.8")
}
  • カスタムルールを定義するにはdetekt-apiライブラリの追加が必要です。
  • また、作成したカスタムルールのユニットテストのためにdetekt-testもテストの依存に追加します。

ステップ2: カスタムルール用のファイルを登録する

サブプロジェクトを作成したら、カスタムルールの構成に必要な一連のファイルを登録します。
今回のdetekt-rulesサブプロジェクトのパッケージ名はcom.example.demoapp.detektrulesとします。

サブプロジェクト全体としては次のようなファイル構成になります。

detekt-rules/
├── build.gradle.kts
└── src/
    └── main/
        ├── kotlin/
        │   └── com.example.demoapp.detektrules/
        │       ├── CustomRuleSetProvider.kt
        │       └── MyCustomRule.kt
        └── resources/
            ├── config/
            │   └── config.yml
            └── META-INF/
                └── services/
                    └── io.gitlab.arturbosch.detekt.api.RuleSetProvider

それぞれのファイルについて解説します。

CustomRuleSetProvider.kt

package com.example.demoapp.detektrules

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

/**
 * カスタムルールを取りまとめるクラス
 */
class CustomRuleSetProvider : RuleSetProvider {
    override val ruleSetId = "CustomRuleSet"

    override fun instance(config: Config): RuleSet {
        val rules = listOf(
            // ここにカスタムルールを記述
            MyCustomRule(config),
        )

        return RuleSet(ruleSetId, rules)
    }
}

CustomRuleSetProviderは、detektが提供するRuleSetProviderクラスを継承したもので、各カスタムルールを取りまとめてdetektに登録する役割を果たします。

  • RuleSetProviderを継承したクラスはinstance()メソッドをオーバーライドする必要があります。このメソッドはruleSetId: Stringrules: List<BaseRule>で構成されるRuleSetを返す必要あります。
    • ruleSetIdは、detektの各ルールセットを識別するためのIDです。この例ではCustomRuleSetとしています。
    • rulesには作成したカスタムルールのリストを指定します。この例では次のセクションで作成するMyCustomRuleを渡しています。

MyCustomRule.kt

package com.example.demoapp.detektrules

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.psi.KtProperty

/**
 * カスタムルールのサンプルクラス
 */
class MyCustomRule(config: Config) : Rule(config) {
    override val issue = Issue(
        id = javaClass.simpleName,
        severity = Severity.Style,
        description = "変数名に'temp'を使わないでください",
        debt = Debt.FIVE_MINS
    )

    override fun visitProperty(property: KtProperty) {
        super.visitProperty(property)

        val propertyName = property.name ?: return
        if ("temp" in propertyName.lowercase()) {
            val message = "変数名に'temp'が含まれています: '$propertyName'"

            report(CodeSmell(issue, Entity.from(property), message))
        }
    }
}

カスタムルールのサンプルとしてMyCustomRuleを作成します。
今回は分かりやすい例として、変数名にtempという文字列が含まれている場合にエラーとするルールを作ります。

各カスタムルールはdetektのRuleクラスを継承して作成します。
Ruleクラスはメンバー変数issueをオーバーライドする必要があります。issueは以下の属性で構成されます。

属性 説明
id ルールを識別するためのIDです。
クラス名をそのまま設定すると分かりやすいです。
severity 問題の重要度や分類を示します。
Severity.Styleはコーディングスタイルに関する指摘であることを示します。
description ルールの内容や問題点の説明文です。
debt この問題の修正に必要な時間(技術的負債)です。


カスタムルールを実装するには、detektがKotlinのコードを巡回する際に呼び出されるvisitXXX()メソッドをオーバーライドします。 visitXXX()には、「関数を実行するコード」の訪問時に呼び出されるvisitCallExpression()や、コメントを訪問するvisitComment()など様々な種類があります。

今回は変数定義の訪問時に呼ばれるvisitProperty()をオーバーライドし、その中でルール違反をチェックするコードを書きます。具体的にはproperty.nameで変数名を取り出し、それに"temp"という文字列が含まれているかを確認しています。

        val propertyName = property.name ?: return
        if ("temp" in propertyName.lowercase()) {

ルール違反を見つけたらreport()メソッドを呼び出します。これによりdetektのエラーとして報告されます。

            val message = "変数名に'temp'が含まれています: '$propertyName'"

            report(CodeSmell(issue, Entity.from(property), message))

なお、カスタムルールを作成する際はdetektの標準ルールのソースコードが参考になります。自分が作成したいルールに近いものを見つけて、真似しながら作成すると良いと思います。

config.yml

detekt-rules/src/main/resources/config/config.yml

CustomRuleSet:
  MyCustomRule:
    active: true

作成したルールセットの各ルールを管理する設定ファイルです。

各ルールは個別にactive: trueを指定することで有効化されます。

また、独自のパラメータ (しきい値thresholdなど) を定義し、それを各ルールで参照することもできます。

CustomRuleSet:
  MyCustomRule:
    active: true
    threshold: 100 # 独自のパラメータ (しきい値など)

MyCustomRule.kt

class MyCustomRule(config: Config) : Rule(config) {

    private val threshold: Int = config
        .subConfig(javaClass.simpleName)
        .valueOrDefault("threshold", 0)

io.gitlab.arturbosch.detekt.api.RuleSetProvider

detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider

com.example.demoapp.detektrules.CustomRuleSetProvider

detektがカスタムルールセットを認識できるようにするためのファイルです。
このファイルの中身にはCustomRuleSetProviderクラスの完全修飾名のみを記述します。

このファイルを置くことで、サービスローダーがこのファイルを検出してCustomRuleSetProviderを自動的に読み込みます。これにより、detektの実行時に作成したカスタムルールセットが利用可能になります。

ステップ3: カスタムルールによるチェックを実行する

プロダクションコードに対してカスタムルールによるチェックを実行するためには、これまでのステップで作成したdetekt-rulesサブプロジェクトをプロダクションコードのdependenciesdetektの依存設定として追加します。

build.gradle.kts

dependencies {
    ...
    detekt("io.gitlab.arturbosch.detekt:detekt-cli:1.23.8")
    detekt(project(":detekt-rules"))
}

また、カスタムルールを動作させるためにdetekt-cliも併せて追加します。

設定が完了したら、(わざと違反コードを仕込んだ上で)./gradlew detektコマンドを実行することで、カスタムルールが適用されていることを確認できます。

src/main/kotlin/com/example/demoapp/Main.kt:13:5: 変数名に'temp'が含まれています: 'myTempValue' [MyCustomRule]

実用的なカスタムルールの作成例

このセクションから、実際の開発で役立ちそうな次の2つのカスタムルールの作成例をご紹介します。

  • TODOコメントを書く際に課題番号の記載を必須とするTodoShouldReferToIssueNumberルール
  • 現在日時を取得するnow()メソッドを引数なしで使用することを禁止するImplicitDefaultTimeZoneルール

TodoShouldReferToIssueNumberルール

コードにTODOコメントを書く際、そのタスクを忘れずに実施するようにJiraやBacklogなどの課題番号を記載することを必須にするルールです。

package com.example.demoapp.detektrules

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.com.intellij.psi.PsiComment
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import org.jetbrains.kotlin.kdoc.psi.impl.KDocSection
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType

/**
 * TODOコメントに ISSUE-1111 などの課題番号を記載することを必須にするルール
 */
class TodoShouldReferToIssueNumber(config: Config) : Rule(config) {
    private val description =
        "TODOコメントには課題番号 (ISSUE-1111 など) を併記してください。"

    private val issueKeys = listOf(
        "ISSUE",
    )

    override val issue = Issue(
        id = javaClass.simpleName,
        severity = Severity.CodeSmell,
        description = description,
        debt = Debt.FIVE_MINS,
    )

    /** 通常コメントをチェックする */
    override fun visitComment(comment: PsiComment) {
        super.visitComment(comment)

        checkComment(comment.text, comment)
    }

    /** Javadocコメントをチェックする */
    override fun visitKtFile(file: KtFile) {
        super.visitKtFile(file)

        file.collectDescendantsOfType<KDocSection>()
            .forEach { checkComment(it.getContent(), it) }
    }

    /** 共通処理 */
    private fun checkComment(comment: String, element: PsiElement) {
        if ("TODO" in comment.uppercase() && issueKeys.none { it in comment }) {
            report(CodeSmell(issue, Entity.from(element), description))
        }
    }
}

ポイント:

  • issueKeysにJiraやBacklog等のプロジェクト名を指定できるようにしています。
  • コメント (Javadoc含む) をチェックするためvisitComment()visitKtFile()をオーバーライドしています。
  • 共通処理checkComment()で「TODO」と「課題番号」がペアで併記されているかチェックしています。違反していた場合はreport()します。

ImplicitDefaultTimeZoneルール

現在の日付や時間を取得するメソッドであるLocalDate.now()LocalDateTime.now()には引数にタイムゾーンを指定できますが、引数を省略するとシステムのタイムゾーンが暗黙的に適用されます。

このとき、アプリケーションをAWSなどのクラウド環境にデプロイするとタイムゾーンがUTCになり、意図しない結果を引き起こしてしまうことがあります。これを防ぐため、now()を使用する際は引数を指定 (タイムゾーンを明示) することを必須にするルールです。

package com.example.demoapp.detektrules

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.psi.KtCallExpression

/**
 * `LocalDate.now()` や `LocalDateTime.now()` が
 * 引数なし (システムのタイムゾーン) で使用されることを禁止するルール
 */
class ImplicitDefaultTimeZone(config: Config) : Rule(config) {
    private val description = "`LocalDate.now()` や `LocalDateTime.now()` を" +
        "引数なし (システムのタイムゾーン) で使用することを避けてください。"

    private val targetMethodNames = listOf(
        "LocalDate.now",
        "LocalDateTime.now",
    )

    override val issue = Issue(
        id = javaClass.simpleName,
        severity = Severity.CodeSmell,
        description = description,
        debt = Debt.FIVE_MINS,
    )

    /**
     * KtCallExpression (関数呼び出し) について、次のすべてに該当する場合にレポートする:
     * - メソッドが `LocalDateTime.now()` あるいは `LocalDate.now()`
     * - 引数が空
     */
    override fun visitCallExpression(expression: KtCallExpression) {
        super.visitCallExpression(expression)

        val isTargetMethodCall = isTargetMethodCall(expression)
        val isEmptyArgument = expression.valueArguments.isEmpty()

        if (isTargetMethodCall && isEmptyArgument) {
            report(CodeSmell(issue, Entity.from(expression), description))
        }
    }

    /** 対象のメソッド呼び出しかどうかを判定する */
    private fun isTargetMethodCall(expression: KtCallExpression): Boolean {
        val methodName = (expression.parent?.text ?: "").removeWhiteSpaces()
        return targetMethodNames.any { methodName.startsWith(it) }
    }

    /** ホワイトスペース文字を削除する */
    private fun String.removeWhiteSpaces() = replace("\\s".toRegex(), "")
}

ポイント:

  • 関数呼び出しをチェックするためにvisitCallExpression()をオーバーライドしています。
  • 次の2つの条件を両方満たす場合を違反として検出しています。
    • メソッド名がLocalDate.now()またはLocalDateTime.now()
    • 引数が空 (expression.valueArguments.isEmpty())
  • now()メソッドの式中にスペースや改行が含まれていても検出できるようにremoveWhiteSpaces()でホワイトスペース文字を削除しています。

CustomRuleSetProviderの更新

新しいルールを作成したら、それらをCustomRuleSetProviderに追加します。

CustomRuleSetProvider.kt

class CustomRuleSetProvider : RuleSetProvider {
    ...

    override fun instance(config: Config): RuleSet {
        val rules = listOf(
            // ここに作成したルールを追加
            TodoShouldReferToIssueNumber(config),
            ImplicitDefaultTimeZone(config),
        )

        return RuleSet(ruleSetId, rules)
    }
}

設定ファイルにも同様に追加して、detektに新しいルールを認識させます。

detekt-rules/src/main/resources/config/config.yml

CustomRuleSet:
  TodoShouldReferToIssueNumber:
    active: true
  ImplicitDefaultTimeZone:
    active: true

これで、新たに作成したルールでdetektのチェックが実行されるようになります。

カスタムルールのユニットテスト

detektはカスタムルール向けのユニットテストのライブラリも提供しています。
これを使うことで、自作ルールが正常に動作するかどうかを事前にJUnit等でテストできます。

以下は、前述のImplicitDefaultTimeZoneルールのユニットテストの作成例です。

ImplicitDefaultTimeZoneTest.kt

package com.example.demoapp.detektrules

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

@KotlinCoreEnvironmentTest
class ImplicitDefaultTimeZoneTest(private val env: KotlinCoreEnvironment) {
    // テスト対象
    private val sut = ImplicitDefaultTimeZone(TestConfig())

    @Test
    fun `引数なしのnow()が検出対象になる`() {
        val code = """
            fun func1() = LocalDate.now()
            fun func2() = LocalDateTime.now()
            fun func3() = LocalDateTime
                . now( ) // 改行、ホワイトスペース付き
        """.trimIndent()

        val found = sut.compileAndLintWithContext(env, code)
        assertEquals(3, found.size)
    }

    @Test
    fun `引数付きのnow()は検出対象にならない`() {
        val code = """
            fun func1() = LocalDate.now(ZoneId.of("Asia/Tokyo"))
            fun func2() = LocalDateTime.now(ZoneId.of("Asia/Tokyo"))
        """.trimIndent()

        val found = sut.compileAndLintWithContext(env, code)
        assertTrue(found.isEmpty())
    }

    @Test
    fun `対象のメソッド以外のnow()は検出対象にならない`() {
        val code = """
            fun func1() = println(OtherClass.now())
        """.trimIndent()

        val found = sut.compileAndLintWithContext(env, code)
        assertTrue(found.isEmpty())
    }
}

ポイント:

  • コードの断片を表現した文字列リテラルcodeをテスト用のLint実行メソッドcompileAndLintWithContext()に渡すことで、作成したカスタムルールをテストできます。
    • foundList<Finding>型で、違反の検出時にreport()されたルールのリストが返ります。

まとめ

この記事では、detektのカスタムルールを作成し、プロジェクトに適用する方法をご紹介しました。

プロジェクト独自のカスタムルールを導入することで、標準のdetektルールではカバーできないような「コーディング時に気をつけるべき特定の注意点」「プロジェクト固有の問題」「特定の問題への再発防止策」などのチェック作業の省力化を図ることができます。

チェックリストに新しい項目を追加する前に、まずはdetektのカスタムルールで解決できないかを検討してみてもいいかもしれません。

採用情報

虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp