ASTでKotlinに入門する
この記事はKotlin Advent Calendar 2024 6日目の記事です。
おはようございます。今年の10月にKotlinに入門したinouehiといいます、よろしくお願いします!
Kotlinに慣れるためにとにかく手を動かしてみようということで、ASTを操作する簡単なツールを作ってみることにしました。ASTを観察することで言語仕様が垣間見えることも期待しています。
想定読者
- KotlinでASTを操作したい方
- 静的解析に興味がある方
実装方法の調査・検討
右も左もわからない、KotlinもGradleもあんまりわからない状態だったのでひとまずChatGPTに聞いてみることにしました。
そこで出てきたキーワードがKotlinPoet, kotlinx.serialization, Kotlin Symbol Processing(KSP)やKotlin Compilerでした。
KotlinPoet, kotlinx.serializationはドキュメントに簡単に目を通して用途に合わなさそうだったので深入りは控えておきました。Kotlin Symbol Processingは簡単なcompiler pluginsを作るのに適しているようで、なんとなくコンパイラならいけそうかもという期待と、面白そうでもあったので試してみました。結果としてはKSPでASTをいじることが(僕には)できませんでした。しかしながら、KSPよりも自由度の高いKotlin Compiler Pluginというものがあることがわかり、試してみることにしました。
ドキュメントがほとんど見つけられずかなり苦しい中お世話になったのがこの記事でした。この記事を起点にして調査し、試行錯誤した結果、Kotlin Compiler PluginでASTをいじることに成功しました。また、ここに至る調査の過程でIntelliJプラグインとして実装することが可能であることもわかりました。
ChatGPTの助言は一通り試したので、次に静的解析ツールを参考にしてみようと思いました。そこでDetektに目をつけました。
Detektのコードを読み、参考にして書いたのが以下のコードです。
実験的実装
import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.com.intellij.openapi.Disposable
import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer
import org.jetbrains.kotlin.com.intellij.psi.util.PsiUtilCore
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.psi.KtFile
import kotlin.system.exitProcess
fun main() {
// 1. KotlinのコードからKtFileを生成する
val environment = createKotlinCoreEnvironment()
val virtualFile = requireNotNull(environment.findLocalFile("/path/to/IWannaBeAst.kt"))
val psiFile = PsiUtilCore.getPsiFile(environment.project, virtualFile)
if (psiFile !is KtFile) {
exitProcess(1)
}
// 2. KtFileをトラバースする
val visitor = CustomKtVisitor()
psiFile.accept(visitor)
}
fun createKotlinCoreEnvironment(): KotlinCoreEnvironment {
val configuration = CompilerConfiguration()
val disposable: Disposable = Disposer.newDisposable()
return KotlinCoreEnvironment.createForProduction(
disposable,
configuration,
EnvironmentConfigFiles.JVM_CONFIG_FILES
)
}
要点は、KtFile型のオブジェクトを入手することと、KtTreeVisitorVoidを拡張してvisitorを作ることです。visitorについては後述します。
KotlinのコードからKtFileを入手できます。
KtFileはKotlinのコードをノードの木として表現したものと見做せそうです。
KtFileにはacceptというメソッドがあり、これにvisitorを渡すことでノードの木を辿ることができました。
visitorにはノードに対して行いたい処理を宣言します。例えば、ノードのクラス名を表示する場合はこんな感じです。
import org.jetbrains.kotlin.psi.KtTreeVisitorVoid
import org.jetbrains.kotlin.psi.KtElement
class CustomKtVisitor : KtTreeVisitorVoid() {
private var indent = 0
override fun visitKtElement(element: KtElement) {
printIndent()
println(element::class.simpleName)
indent++
element.children.forEach { it.accept(this) }
indent--
}
private fun printIndent() {
repeat(indent) { print(" ") }
}
}
build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
id("application")
}
group = "org.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
api("org.jetbrains.kotlin:kotlin-compiler-embeddable")
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(17)
}
application {
mainClass.set("org.example.MainKt")
}
それでは実行してみます。
まず、解析対象のコードはこちら↓(GPTに雑にお願いして作ってもらいました)
package org.example
import kotlin.math.sqrt
fun calculateSquareRoot(number: Double): Double {
if (number < 0) {
throw IllegalArgumentException("invalid")
}
return sqrt(number)
}
fun main() {
val number = 16.0
val result = calculateSquareRoot(number)
println("The square root of $number is $result")
}
結果はこちら
KtPackageDirective
KtDotQualifiedExpression
KtNameReferenceExpression
KtNameReferenceExpression
KtImportList
KtImportDirective
KtDotQualifiedExpression
KtDotQualifiedExpression
KtNameReferenceExpression
KtNameReferenceExpression
KtNameReferenceExpression
KtNamedFunction
KtParameterList
KtParameter
KtTypeReference
KtUserType
KtNameReferenceExpression
KtTypeReference
KtUserType
KtNameReferenceExpression
KtBlockExpression
KtIfExpression
KtContainerNode
KtBinaryExpression
KtNameReferenceExpression
KtOperationReferenceExpression
KtConstantExpression
KtContainerNodeForControlStructureBody
KtBlockExpression
KtThrowExpression
KtCallExpression
KtNameReferenceExpression
KtValueArgumentList
KtValueArgument
KtStringTemplateExpression
KtLiteralStringTemplateEntry
KtReturnExpression
KtCallExpression
KtNameReferenceExpression
KtValueArgumentList
KtValueArgument
KtNameReferenceExpression
KtNamedFunction
KtParameterList
KtBlockExpression
KtProperty
KtConstantExpression
KtProperty
KtCallExpression
KtNameReferenceExpression
KtValueArgumentList
KtValueArgument
KtNameReferenceExpression
KtCallExpression
KtNameReferenceExpression
KtValueArgumentList
KtValueArgument
KtStringTemplateExpression
KtLiteralStringTemplateEntry
KtSimpleNameStringTemplateEntry
KtNameReferenceExpression
KtLiteralStringTemplateEntry
KtSimpleNameStringTemplateEntry
KtNameReferenceExpression
整然と並ぶノードがとても壮観ですね。
ただ、コードとの対応関係が少しわかりにくいです。 もう少しわかり易くしてみましょう。
コードとの対応関係を示すようにvisitorを作り変えて実行すると、、、
結果はこちら
KtPackageDirective
KtDotQualifiedExpression
KtNameReferenceExpression
└ org
KtNameReferenceExpression
└ example
KtImportList
KtImportDirective
KtDotQualifiedExpression
KtDotQualifiedExpression
KtNameReferenceExpression
└ kotlin
KtNameReferenceExpression
└ math
KtNameReferenceExpression
└ sqrt
KtNamedFunction
└ calculateSquareRoot
KtParameterList
KtParameter
└ number
KtTypeReference
KtUserType
KtNameReferenceExpression
└ Double
KtTypeReference
KtUserType
KtNameReferenceExpression
└ Double
KtBlockExpression
KtIfExpression
KtContainerNode
KtBinaryExpression
KtNameReferenceExpression
└ number
KtOperationReferenceExpression
└ PsiElement(LT)
KtConstantExpression
└ 0
KtContainerNodeForControlStructureBody
KtBlockExpression
KtThrowExpression
KtCallExpression
KtNameReferenceExpression
└ IllegalArgumentException
KtValueArgumentList
KtValueArgument
KtStringTemplateExpression
KtLiteralStringTemplateEntry
└ invalid
KtReturnExpression
KtCallExpression
KtNameReferenceExpression
└ sqrt
KtValueArgumentList
KtValueArgument
KtNameReferenceExpression
└ number
KtNamedFunction
└ main
KtParameterList
KtBlockExpression
KtProperty
└ number
KtConstantExpression
└ 16.0
KtProperty
└ result
KtCallExpression
KtNameReferenceExpression
└ calculateSquareRoot
KtValueArgumentList
KtValueArgument
KtNameReferenceExpression
└ number
KtCallExpression
KtNameReferenceExpression
└ println
KtValueArgumentList
KtValueArgument
KtStringTemplateExpression
KtLiteralStringTemplateEntry
└ The square root of
KtSimpleNameStringTemplateEntry
KtNameReferenceExpression
└ number
KtLiteralStringTemplateEntry
└ is
KtSimpleNameStringTemplateEntry
KtNameReferenceExpression
└ result
まとめ
Kotlin Compiler PluginやDetektではorg.jetbrains.kotlin.psi.*というKotlin用のASTを操作するためのクラスが使われるようでした。
一方、IntelliJプラグインでは com.intellij.psi.*やcom.intellij.lang.ASTNodeというKotlinに限らず汎用的に利用できるクラスが使われるようでした。
始め、ASTを簡単にいじってみたいだけなのにKotlin Compiler Pluginを実装しなければならないのかと、なんて重厚なんだと圧倒されかけましたが、最終的にはとてもシンプルなコードに落ち着きました。
以上、お読みいただきありがとうございましたmm
Discussion