第一个编译器插件:创建Gradle项目、注册编译器插件、编写简单的IR转换

好,咱们正式开始动手了。

前面聊了那么多理论,说实话,我自己写第一个Kotlin编译器插件的时候,也踩了不少坑。今天这一章,我带你从零搭建一个完整的项目。目标很明确:创建一个Gradle项目,注册一个编译器插件,再写一个最简单的IR转换——把函数调用日志打印出来。

你想想看,这就像学开车,先点火、挂挡、踩油门。咱们先把流程跑通,后面再玩花活。

1. 创建Gradle项目

首先,你得有个Gradle项目。我个人习惯用IntelliJ IDEA来创建,但用命令行也一样。

项目结构大概长这样:

my-first-plugin/
├── build.gradle.kts
├── settings.gradle.kts
├── src/
│   ├── main/
│   │   ├── kotlin/
│   │   │   └── com/example/
│   │   │       └── MyFirstPluginRegistrar.kt
│   │   └── resources/
│   │       └── META-INF/
│   │           └── services/
│   │               └── org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
│   └── test/
│       └── kotlin/
│           └── com/example/
│               └── TestCase.kt

嗯,这里要注意:META-INF/services 这个目录是Java SPI机制用的,Kotlin编译器插件也是通过它来发现你的插件。我第一次写的时候忘了加这个文件,结果插件死活不生效,折腾了半天。

2. 配置build.gradle.kts

接下来是构建脚本。这里有几个关键点:

  • 必须依赖 kotlin-compiler-embeddable,这是编译器的核心库
  • 需要 kotlin-stdlib 作为基础库
  • 插件本身要打包成JAR,然后被其他项目引用
plugins {
    kotlin("jvm") version "1.9.0"
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.0")
    implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
}

tasks.jar {
    manifest {
        attributes["Implementation-Title"] = "MyFirstPlugin"
    }
}

我建议你直接用Kotlin DSL,可读性更好。如果你用Groovy DSL,其实也行,但容易写错括号。

3. 注册编译器插件

这一步是核心。你需要实现 CompilerPluginRegistrar 接口,告诉编译器:嘿,我来了,我要干活。

package com.example

import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.config.CompilerConfiguration

@OptIn(ExperimentalCompilerApi::class)
class MyFirstPluginRegistrar : CompilerPluginRegistrar() {

    override val supportsK2: Boolean
        get() = true

    override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
        println("MyFirstPlugin: 注册成功!")
        // 这里后面会注册IR转换
    }
}

然后,在 META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar 文件中,写上你的全限定类名:

com.example.MyFirstPluginRegistrar

我曾经犯过一个低级错误:文件名拼写错了,少写了一个字母。结果编译器完全没反应,我还以为是代码写错了。所以,文件名一定要一字不差。

4. 编写简单的IR转换

好了,现在插件已经注册成功了。但光注册没意义,咱们得干点实事。

最简单的IR转换是什么?我选的是:在每个函数调用之前,插入一条打印日志的IR指令。说白了,就是给每个函数调用加个“前置通知”。

先定义一个IR转换类:

package com.example

import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid
import org.jetbrains.kotlin.ir.expressions.IrCall
import org.jetbrains.kotlin.ir.builders.*
import org.jetbrains.kotlin.ir.declarations.IrFunction
import org.jetbrains.kotlin.ir.expressions.IrExpression
import org.jetbrains.kotlin.ir.types.classOrNull
import org.jetbrains.kotlin.name.FqName

class LoggingTransformer : IrElementTransformerVoid() {

    override fun visitCall(expression: IrCall): IrExpression {
        // 先处理子节点,确保递归
        val transformed = super.visitCall(expression) as IrCall

        // 获取被调用的函数名
        val calleeName = transformed.symbol.owner.name.asString()

        // 构建一个打印日志的IR
        val irBuilder = context.createIrBuilder(transformed.symbol)
        val logCall = irBuilder.irCall(
            symbol = context.referenceFunctions(
                FqName("kotlin.io.println")
            ).first()
        ).apply {
            putValueArgument(0, irBuilder.irString("调用函数: $calleeName"))
        }

        // 返回一个包含日志和原始调用的复合表达式
        return irBuilder.irBlock {
            +logCall
            +transformed
        }
    }
}

你可能会问:为什么要用 IrElementTransformerVoid?因为它可以遍历整个IR树,并且允许你替换节点。我刚开始用 IrElementVisitor,结果发现只能看不能改,白忙活一场。

然后,在注册器里把这个转换挂上去:

override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
    IrGenerationExtension.registerExtension(object : IrGenerationExtension {
        override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
            moduleFragment.transform(LoggingTransformer(), null)
        }
    })
}

5. 测试你的插件

写完了怎么验证?我一般写一个简单的测试文件:

fun main() {
    sayHello()
    add(1, 2)
}

fun sayHello() {
    println("Hello!")
}

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

编译运行后,你应该能看到类似这样的输出:

调用函数: sayHello
Hello!
调用函数: add
3

注意:这里有个坑。如果你的插件没有生效,先检查 META-INF/services 文件是否正确。我曾经把文件名写成了 CompilerPluginRegistrar 而不是全限定名,结果编译器直接忽略了我。

6. 避坑指南

⚠️ 常见问题
  • 插件不加载:检查 META-INF/services 文件路径和内容,一个字符都不能错
  • IR转换不生效:确认你注册的是 IrGenerationExtension 而不是 IrElementVisitor
  • 编译报错:确保你的Kotlin版本和编译器插件版本一致,我见过太多版本不匹配的悲剧
💡 小技巧

调试IR转换时,可以用 IrElement.dump() 打印IR树结构。这招帮我找到了很多隐藏的bug。

7. 总结

这一章我们完成了三件事:

  • 创建了Gradle项目,配置了依赖
  • 注册了编译器插件,让Kotlin编译器认识我们
  • 写了一个简单的IR转换,给每个函数调用加上了日志

说实话,这个插件还很简陋。但万丈高楼平地起,你想想看,现在你已经掌握了编译器插件的完整开发流程。后面几章,我们会在这个基础上做更复杂的转换,比如修改函数体、插入新的类、甚至生成全新的代码。

下一章,我会带你深入IR的内部结构,看看那些节点到底长什么样。到时候,你就能真正理解“元编程”的威力了。

核心要点回顾

步骤 关键文件/类 作用
1. 创建项目 build.gradle.kts 配置依赖和打包
2. 注册插件 CompilerPluginRegistrar + SPI文件 让编译器发现插件
3. 编写IR转换 IrElementTransformerVoid 修改IR树
4. 挂载转换 IrGenerationExtension 在编译阶段执行转换

好,今天就到这里。动手试试吧,遇到问题别怕,编译器插件的调试虽然麻烦,但每次成功运行时的成就感,绝对值得。