Android
Kotlin
lint
kotlint

ktlintで独自ルールを追加する

ktlintとは

ktlintはkotlinの静的コード解析ツールです。
同じようなものに、detektというものもあります。
今回は、ktlintについて書いていきます。

ktlintはkotlinのコードにlintをかけるだけでなく、lintで検出した部分にフォーマットをかけることもできます。

今回は、ktlintを使って独自のlintルールを追加する方法を書いていきます。

独自ルールの追加

ktlintには、デフォルトでいくつかのルールが既に定義されています。
そのため、独自のルールを追加しなくても、ある程度のlintはかけることができます。

デフォルトルール一覧

しかし、チーム内で決めたkotlinの書き方などを統一したいことがあります。そのような場合には、独自のlintルールを定義することによって、lintチェックすることができます。

ルールの定義

ktlintが提供しているRuleというインターフェイスを実装することによって、独自のルールを定義していきます。

class YourCustomRule : Rule("your-custom-rule") {

  override fun visit(
      node: ASTNode,
      autoCorrect: Boolean,
      emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
  ) {
    // write your own rule
  }
}

独自のルールを定義していくにはnode: ASTNodeがなにかを理解している必要があります。

AST

ASTとは、AbstractSyntaxTreeの略で、ktlin以外にも様々な言語で出てくる概念です。
全ての記述はこのASTNodeの木構造として表すことができます。

スクリーンショット 2018-03-11 17.57.02.png

例えば、↓の関数は KtNamedFunction というASTNodeになります。

fun add(a: Int, b: Int): Int {
  return a + b
}

また、更に、そのNodeを細かく分解することができます。
このようにして、全ての記述を木構造のように取り扱うことができます。

Node Node名
fun KtKeywordToken.fun
add KtToken.IDENTIFIER
(a: Int, b: Int) KtParameterList
: KtSingleValueToken.COLON
Int KtTypeReference
{ return a + b } KtBlockExpression
Node Node名
{ KtSingleValueToken.LBRACE
return a + b KtReturnExpression
} KtSingleValueToken.RBRACE
Node Node名
return KtKeywordToken.return
a KtToken.IDENTIFIER
+ KtSingleValueToken.PLUS
b KtToken.IDENTIFIER

それぞれがどのASTノードに当たるかは、--print-astのオプションで調べることができます。

Hoge.kt
package com.takusemba

fun add(a: Int, b: Int): Int {
  return a + b
}
ktlint **/Hoge.kt --print-ast
1: ~.psi.KtFile (~.psi.stubs.elements.KtFileElementType.kotlin.FILE)
1:   ~.psi.KtPackageDirective (~.psi.stubs.elements.KtPlaceHolderStubElementType.PACKAGE_DIRECTIVE)
1:     ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtKeywordToken.package) "package"
1:     ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
1:     ~.psi.KtDotQualifiedExpression (~.psi.stubs.elements.KtDotQualifiedExpressionElementType.DOT_QUALIFIED_EXPRESSION)
1:       ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
1:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "com"
1:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.DOT) "."
1:       ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
1:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "takusemba"
1:   ~.psi.KtImportList (~.psi.stubs.elements.KtPlaceHolderStubElementType.IMPORT_LIST) ""
1:   ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) "\n\n"
3:   ~.psi.KtNamedFunction (~.psi.stubs.elements.KtFunctionElementType.FUN)
3:     ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtKeywordToken.fun) "fun"
3:     ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:     ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "add"
3:     ~.psi.KtParameterList (~.psi.stubs.elements.KtPlaceHolderStubElementType.VALUE_PARAMETER_LIST)
3:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.LPAR) "("
3:       ~.psi.KtParameter (~.psi.stubs.elements.KtParameterElementType.VALUE_PARAMETER)
3:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "a"
3:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.COLON) ":"
3:         ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:         ~.psi.KtTypeReference (~.psi.stubs.elements.KtPlaceHolderStubElementType.TYPE_REFERENCE)
3:           ~.psi.KtUserType (~.psi.stubs.elements.KtUserTypeElementType.USER_TYPE)
3:             ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
3:               ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "Int"
3:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.COMMA) ","
3:       ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:       ~.psi.KtParameter (~.psi.stubs.elements.KtParameterElementType.VALUE_PARAMETER)
3:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "b"
3:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.COLON) ":"
3:         ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:         ~.psi.KtTypeReference (~.psi.stubs.elements.KtPlaceHolderStubElementType.TYPE_REFERENCE)
3:           ~.psi.KtUserType (~.psi.stubs.elements.KtUserTypeElementType.USER_TYPE)
3:             ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
3:               ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "Int"
3:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.RPAR) ")"
3:     ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.COLON) ":"
3:     ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:     ~.psi.KtTypeReference (~.psi.stubs.elements.KtPlaceHolderStubElementType.TYPE_REFERENCE)
3:       ~.psi.KtUserType (~.psi.stubs.elements.KtUserTypeElementType.USER_TYPE)
3:         ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
3:           ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "Int"
3:     ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
3:     ~.psi.KtBlockExpression (~.KtNodeType.BLOCK)
3:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.LBRACE) "{"
3:       ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) "\n  "
4:       ~.psi.KtReturnExpression (~.KtNodeType.RETURN)
4:         ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtKeywordToken.return) "return"
4:         ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
4:         ~.psi.KtBinaryExpression (~.KtNodeType.BINARY_EXPRESSION)
4:           ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
4:             ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "a"
4:           ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
4:           ~.psi.KtOperationReferenceExpression (~.KtNodeType.OPERATION_REFERENCE)
4:             ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.PLUS) "+"
4:           ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) " "
4:           ~.psi.KtNameReferenceExpression (~.psi.stubs.elements.KtNameReferenceExpressionElementType.REFERENCE_EXPRESSION)
4:             ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtToken.IDENTIFIER) "b"
4:       ~.c.i.p.impl.source.tree.PsiWhiteSpaceImpl (~.c.i.p.tree.IElementType.WHITE_SPACE) "\n"
5:       ~.c.i.p.impl.source.tree.LeafPsiElement (~.lexer.KtSingleValueToken.RBRACE) "}"

   format: <line_number:> <node.psi::class> (<node.elementType>) "<node.text>"
   legend: ~ = org.jetbrains.kotlin, c.i.p = com.intellij.psi

実際に追加する

では、実際にktlintを使って独自のlintを追加するまでをやってみたいと思います。

今回は、一行のEqualで書いた関数(Expression Function)には、戻り値をつけるというLintルールを定義してみたいと思います。

NG

fun add(a: Int, b: Int) = a + b

OK

fun add(a: Int, b: Int): Int = a + b

まずは、Ruleを実装したクラスを作っていきます。

class ExpressionFunctionRule : Rule("expression-function-rule") {

  override fun visit(
      node: ASTNode,
      autoCorrect: Boolean,
      emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
  ) {
    if (node.elementType == KtStubElementTypes.FUNCTION
        && !node.text.contains('\n')) {
      val child = node.findChildByType(KtNodeTypes.VALUE_PARAMETER_LIST) ?: return
      if (child.treeNext.elementType == KtTokens.WHITE_SPACE
          && child.treeNext.treeNext.elementType == KtTokens.EQ) {
        emit(node.startOffset, "need return type!!!", false)
      }
    }
  }
}

まず、ASTノードのタイプがKtStubElementTypes.FUNCTIONかつ、一行のものを取得します。
取得したASTノードから、KtNodeTypes.VALUE_PARAMETER_LISTの子を探し出し、treeNextで正しい並びになっているかを見ていきます。

KtNodeTypes.VALUE_PARAMETER_LISTの後に、KtTokens.WHITE_SPACE, KtTokens.EQと続くと、返り値が定義されていないことがわかるので、"need return type!!!"というlintのエラー文言を吐き出しています。

次に、先程作った独自ルールをProviderにセットしていきます。

class CustomRuleSetProvider : RuleSetProvider {

  override fun get(): RuleSet = RuleSet("custom-rule", ExpressionFunctionRule())

}

そして、このCustomRuleSetProvidercom.github.shyiko.ktlint.core.RuleSetProvider内に追加します。

スクリーンショット 2018-03-13 22.34.20.png

スクリーンショット 2018-03-13 22.39.06.png

これで、独自のLintがKtlint内で使用できるようになります。
実際に、lintをかけるにはjarを生成し、ktlintのオプションとしてjarを指定します。

./gradlew jar
build.gradle
task sourcesJar(type: Jar, dependsOn: classes) {
  classifier = 'sources'
  from sourceSets.main.allSource
}

artifacts {
  archives sourcesJar
}
./gradlew ktlint
build.gradle
task ktlint(type: JavaExec, dependsOn: classes) {
  args '--debug', 'src/**/*.kt', '-R', 'path/to/your.jar'
  main = 'com.github.shyiko.ktlint.Main'
  classpath = configurations.ktlint + sourceSets.main.output
  ignoreExitValue = true
}

テストコード

定義したLintのテストコードを書いてみます。
ruleに対して、lint()を呼ぶことでコードからlintをかけることができます。

ExpressionFunctionTest.kt
@RunWith(JUnitPlatform::class)
class ExpressionFunctionTest : Spek({

  describe("one line function rule") {

    it("have to specify the return type") {

      val rule = ExpressionFunctionRule()
      assertThat(rule.lint(
          """
            fun add(a: Int, b: Int): Int = a + b
          """
              .trimIndent())
      ).isEqualTo(emptyList<LintError>())

      assertThat(rule.lint(
          """
            fun add(a: Int, b: Int) = a + b
          """
              .trimIndent())
      ).isEqualTo(listOf(LintError(1, 1, "one-line-function", "need return type!!!")))

    }
  }
})

テスト結果

スクリーンショット 2018-03-13 22.46.57.png

CIとの連携

毎回手動でlintを回すのは大変なので、CI上でlintをチェックするのがいいと思います。
Dangerを使うと、PullRequest作成時に、DangerがLintエラーを指摘したりすることができます。

# ktlint
checkstyle_format.base_path = Dir.pwd
checkstyle_format.report 'your/ktlint/output.xml'

image.png

最後に

今回はKtlintを使った独自ルールの追加方法を紹介しました。
基本的はktlintの使い方はこちらの記事を書きましたので、見てみてください。
https://qiita.com/takusemba/items/04c9ad7d28c4b91dc1a4