こんにちは!虎の穴ラボの鷺山です。
前回はKotlinの静的コード解析ツール「detekt」でコードをキレイに保とう!という内容で、detektの導入方法や基本的な使い方をご紹介しました。
今回はさらに踏み込んで、detektに独自のカスタムルールを追加する方法をご紹介します!
カスタムルールを使うと、そのプロジェクト独自のルールに基づいてKotlinのコードを自動でチェックできます。これにより、人の手で確認していたチェック作業を省力化できたり、コードレビューでの問題の見逃しを減らせるかもしれません。
目次
- 目次
- 前提環境
- 準備: detektのセットアップ
- ステップ1: カスタムルール用のGradleサブプロジェクトを作成する
- ステップ2: カスタムルール用のファイルを登録する
- ステップ3: カスタムルールによるチェックを実行する
- 実用的なカスタムルールの作成例
- カスタムルールのユニットテスト
- まとめ
- 採用情報
前提環境
| 環境 | 使用バージョン |
|---|---|
| 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: Stringとrules: 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サブプロジェクトをプロダクションコードのdependenciesにdetektの依存設定として追加します。
▼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()に渡すことで、作成したカスタムルールをテストできます。foundはList<Finding>型で、違反の検出時にreport()されたルールのリストが返ります。
まとめ
この記事では、detektのカスタムルールを作成し、プロジェクトに適用する方法をご紹介しました。
プロジェクト独自のカスタムルールを導入することで、標準のdetektルールではカバーできないような「コーディング時に気をつけるべき特定の注意点」「プロジェクト固有の問題」「特定の問題への再発防止策」などのチェック作業の省力化を図ることができます。
チェックリストに新しい項目を追加する前に、まずはdetektのカスタムルールで解決できないかを検討してみてもいいかもしれません。
採用情報
虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp