ktlint のフォーマット処理をプログラム上で呼びたいなと思いました.

Go で言うところの

import "go/format"

src := goSourceCode() // []byte だか string だか
format.Source(src)

的なことです. つまり,

val src = kotlinSourceCode()
val formatted = Ktlint.format(src)

みたいなことがしたい.

前提

ktlint v0.39.0

ひとまず調査

どこかに format: (String) -> String 的な何かがあるのだろうと思いながら潜ります.

エントリーポイントとなる gradle のタスク定義から始めていきます.

task ktlint(type: JavaExec, group: LifecycleBasePlugin.VERIFICATION_GROUP) {
  description = "Check Kotlin code style."
  classpath = configurations.ktlint
  main = 'com.pinterest.ktlint.Main'
  args '*/src/**/*.kt'
}

https://github.com/pinterest/ktlint/blob/0.39.0/build.gradle#L41

タスク定義で指定されているメイン関数(のあるクラス定義).

fun main(args: Array<String>) {
    val ktlintCommand = KtlintCommandLine()
    // ...

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt#L47

format オプションが指定されたときの処理を追いかけたいので, オプションの定義を探します.

    @Option(
        names = ["--format", "-F"],
        description = ["Fix any deviations from the code style"]
    )
    private var format: Boolean = false

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt#L149

format オプションが指定されている場合の処理を探します.

        if (format) {
            val formattedFileContent = try {
                formatFile(
                    fileName,
                    fileContent,
                    ruleSetProviders.map { it.value.get() },
                    userData,
                    editorConfigPath,
                    debug
                ) { err, corrected ->
            // ...

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt#L329

ktlint コマンドの実装を抜けて, com.pinterest.ktlint.core パッケージの関数呼び出しまでたどり着きました.

internal fun formatFile(
    fileName: String,
    fileContents: String,
    ruleSets: Iterable<RuleSet>,
    userData: Map<String, String>,
    editorConfigPath: String?,
    debug: Boolean,
    cb: (e: LintError, corrected: Boolean) -> Unit
): String =
    KtLint.format(
        KtLint.Params(
            fileName = fileName,
            text = fileContents,
            ruleSets = ruleSets,
            userData = userData,
            script = !fileName.endsWith(".kt", ignoreCase = true),
            editorConfigPath = editorConfigPath,
            cb = cb,
            debug = debug
        )
    )

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint/src/main/kotlin/com/pinterest/ktlint/internal/FileUtils.kt#L70

結局, 以下の Ktlint.format を呼び出せば良さそうです.

    public fun format(params: Params): String {
        // ...

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt#L257

もうちょっと調査

実際にこの KtLint.format を呼び出してみます.

引数の Params が何者なのか確認します.

    /**
     * @param fileName path of file to lint/format
     * @param text Contents of file to lint/format
     * @param ruleSets a collection of "RuleSet"s used to validate source
     * @param userData Map of user options
     * @param cb callback invoked for each lint error
     * @param script true if this is a Kotlin script file
     * @param editorConfigPath optional path of the .editorconfig file (otherwise will use working directory)
     * @param debug True if invoked with the --debug flag
     */
    public data class Params(
        val fileName: String? = null,
        val text: String,
        val ruleSets: Iterable<RuleSet>,
        val userData: Map<String, String> = emptyMap(),
        val cb: (e: LintError, corrected: Boolean) -> Unit,
        val script: Boolean = false,
        val editorConfigPath: String? = null,
        val debug: Boolean = false
    ) {

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt#L45

ruleSets を適切に設定さえできればひとまず動かせそうに見えます.

ひとまず, formatFile の呼び出し元を戻ってみます. KtlintCommandLine.run まで戻ってきました.

    fun run() {
        failOnOldRulesetProviderUsage()

        val start = System.currentTimeMillis()

        val ruleSetProviders = rulesets.loadRulesets(experimental, debug)
        val reporter = loadReporter()
        val userData = listOfNotNull(
            "android" to android.toString(),
            if (disabledRules.isNotBlank()) "disabled_rules" to disabledRules else null
        ).toMap()

        reporter.beforeAll()
        if (stdin) {
            lintStdin(ruleSetProviders, userData, reporter)
        } else {
            lintFiles(ruleSetProviders, userData, reporter)
        }
        // ...

https://github.com/pinterest/ktlint/blob/master/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt#L222

loadRulesets をみてみると, ServiceLoader を利用して RuleSetProvider をロードしていることがわかります.

    private fun loadRulesets(externalRulesetsJarPaths: List<String>) = ServiceLoader
        .load(
            RuleSetProvider::class.java,
            URLClassLoader(externalRulesetsJarPaths.toFilesURIList().toTypedArray())
        )
        // ...

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint/src/main/kotlin/com/pinterest/ktlint/Main.kt#L544

RuleSetProvider の実態はどこで定義されているんだと探してみると, ktlint-ruleset-standard プロジェクト配下にいます.

class StandardRuleSetProvider : RuleSetProvider {
    // ...

https://github.com/pinterest/ktlint/blob/0.39.0/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt

実行してみる

この RuleSetProvider を利用すればひとまず最低限のフォーマットが動作するのではないかと期待して試しに実行してみます.

package com.example

import com.pinterest.ktlint.core.KtLint
import com.pinterest.ktlint.ruleset.standard.StandardRuleSetProvider

fun main() {
    val code = """
        class Foo(
        val id : String, val age : Int,
        val name:String )
        {
            fun foo() : Boolean { return name.isEmpty()
        }
        }
    """.trimIndent()

    val ruleSets = setOf(
        StandardRuleSetProvider().get()
    )
    val formatted = KtLint.format(
        KtLint.Params(
            fileName = null,
            text = code,
            ruleSets = ruleSets,
            userData = emptyMap(),
            cb = { _, _ -> run {} },
            script = false,
            editorConfigPath = null,
            debug = false
        )
    )

    println(formatted)
}

// Output:
//
// class Foo(
//     val id: String,
//     val age: Int,
//     val name: String
// ) {
//     fun foo(): Boolean {
//         return name.isEmpty()
//     }
// }

動きました. めでたしめでたし.

まとめ

とりあえず ktlint をプログラム上で実行してコードをフォーマットすることはできた.

サンプルコード全体: https://github.com/lusingander/ktlint-format-example