深入探索 Kotlin 编译器:FIR 与 IR 插件测试

本文深入探讨 Kotlin 编译器插件的测试机制,核心围绕两种测试类型:用于验证编译时错误的 “诊断测试 (Diagnostic Test)”,和用于验证运行时行为的 “黑盒代码生成测试 (Box Test)”。我们将以官方的 Kotlin/compiler-plugin-template 项目为例,揭示测试指令(Test Directives)如何驱动复杂的测试流程,并阐明测试类的继承关系如何决定测试的行为与验证逻辑。

核心测试类型

在 Kotlin 编译器插件模板项目中,主要通过以下两种测试类型来确保插件的正确性:

  1. 诊断测试 (Diagnostic Test)
    此测试的核心目标是验证编译器在特定阶段能否正确报告预期的编译错误或警告。它通过分析源码,在编译流程的某个指定阶段(如 FRONTEND)停下来,检查编译器输出的诊断信息是否与预期完全一致。这种测试不关心代码能否最终运行,只关心编译过程的正确性。

  2. 黑盒代码生成测试 (Box Test)
    黑盒测试关注的是“输入”与“最终输出”的对应关系,而不关心编译器的内部实现细节。测试流程会将一段合法的 Kotlin 源码作为输入,完整地执行编译、代码生成和运行,最后验证程序的运行时输出是否符合预期。这种测试用于确保编译器插件在修改代码后,其运行时行为仍然正确。

项目结构解析

分析的源码基于官方的 Kotlin/compiler-plugin-template。该模板项目为开发编译器插件提供了标准的结构和测试框架。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
├───.github
├───.gradle
├───.idea
├───.kotlin
├───build
├───compiler-plugin
│ ├───src
│ │ └───org/jetbrains/kotlin/compiler/plugin/template
│ │ ├───fir
│ │ └───ir
│ ├───test-fixtures
│ │ └───org/jetbrains/kotlin/compiler/plugin/template
│ │ ├───runners
│ │ └───services
│ ├───test-gen
│ │ └───org/jetbrains/kotlin/compiler/plugin/template
│ │ └───runners
│ └───testData
│ ├───box
│ └───diagnostics
├───gradle
├───gradle-plugin
├───kotlin-js-store
└───plugin-annotations
└───src

其中与测试直接相关的关键目录如下:

  • compiler-plugin/testData: 存放测试用例源文件(.kt),按测试类型分为 boxdiagnostics
  • compiler-plugin/test-fixtures: 包含测试辅助工具类,例如自定义的测试运行器基类和配置器。
  • compiler-plugin/test-gen: 存放由测试框架根据 testData 自动生成的测试类代码。

黑盒代码生成测试 (Box Test) 实现

黑盒测试验证从 FIR 到 IR 再到最终代码生成的完整流程。

测试基类 AbstractJvmBoxTest

所有 JVM 平台的黑盒测试都继承自 AbstractJvmBoxTest。此类负责配置测试环境、编译器指令和插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package org.jetbrains.kotlin.compiler.plugin.template.runners

import org.jetbrains.kotlin.compiler.plugin.template.services.ExtensionRegistrarConfigurator
import org.jetbrains.kotlin.compiler.plugin.template.services.PluginAnnotationsProvider
import org.jetbrains.kotlin.test.FirParser
import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder
import org.jetbrains.kotlin.test.directives.CodegenTestDirectives
import org.jetbrains.kotlin.test.directives.FirDiagnosticsDirectives
import org.jetbrains.kotlin.test.directives.JvmEnvironmentConfigurationDirectives
import org.jetbrains.kotlin.test.runners.codegen.AbstractFirBlackBoxCodegenTestBase
import org.jetbrains.kotlin.test.services.EnvironmentBasedStandardLibrariesPathProvider
import org.jetbrains.kotlin.test.services.KotlinStandardLibrariesPathProvider

open class AbstractJvmBoxTest : AbstractFirBlackBoxCodegenTestBase(FirParser.LightTree) {
override fun createKotlinStandardLibrariesPathProvider(): KotlinStandardLibrariesPathProvider {
return EnvironmentBasedStandardLibrariesPathProvider
}

override fun configure(builder: TestConfigurationBuilder) {
super.configure(builder)

with(builder) {
// 设置所有测试默认开启的编译器指令
defaultDirectives {
+CodegenTestDirectives.DUMP_IR // 输出生成的 IR
+FirDiagnosticsDirectives.FIR_DUMP // 输出 FIR 树
+JvmEnvironmentConfigurationDirectives.FULL_JDK
+CodegenTestDirectives.IGNORE_DEXING
}

// 注册插件服务
useConfigurators(
::PluginAnnotationsProvider,
::ExtensionRegistrarConfigurator
)
}
}
}

自动生成的测试类

JvmBoxTestGenerated 是一个由测试框架自动生成的类,它会扫描 compiler-plugin/testData/box 目录下的所有 .kt 文件,并为每个文件生成一个对应的 @Test 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package org.jetbrains.kotlin.compiler.plugin.template.runners;

import com.intellij.testFramework.TestDataPath;
import org.jetbrains.kotlin.test.util.KtTestUtil;
import org.jetbrains.kotlin.test.TargetBackend;
import org.jetbrains.kotlin.test.TestMetadata;
import org.junit.jupiter.api.Test;

import java.io.File;
import java.util.regex.Pattern;

/** This class is generated by {@link org.jetbrains.kotlin.compiler.plugin.template.GenerateTestsKt}. DO NOT MODIFY MANUALLY */
@SuppressWarnings("all")
@TestMetadata("compiler-plugin/testData/box")
@TestDataPath("$PROJECT_ROOT")
public class JvmBoxTestGenerated extends AbstractJvmBoxTest {
// ... (方法为每个 testData/box 下的 .kt 文件自动生成)

@Test
@TestMetadata("simple.kt")
public void testSimple() {
runTest("compiler-plugin/testData/box/simple.kt");
}
}

诊断测试 (Diagnostic Test) 实现

诊断测试仅关心编译器的某个特定阶段是否按预期工作,通过 RUN_PIPELINE_TILL 指令来控制编译流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// RUN_PIPELINE_TILL: FRONTEND
/*
* 该指令告诉测试框架,编译流程在 FRONTEND 阶段结束后就应停止并进行验证。
*
* 如果测试用例中的代码在 FRONTEND 阶段之后才能发现问题(或没有问题),
* 例如,移除下面的错误行 s.<!UNRESOLVED_REFERENCE!>inc<!>(),代码本身是合法的。
* 此时测试框架会因编译成功通过了 FRONTEND 阶段而抛出异常:
* "Phase FRONTEND could be promoted to BACKEND",
* 建议将指令调整到更靠后的阶段,如 FIR2IR 或 BACKEND。
*/
package foo.bar

import org.jetbrains.kotlin.compiler.plugin.template.SomeAnnotation

fun test() {
val s = MyClass().foo()
s.<!UNRESOLVED_REFERENCE!>inc<!>() // 预期此处产生一个 "UNRESOLVED_REFERENCE" 错误
}

编译器各阶段的顺序定义在 org.jetbrains.kotlin.test.services.TestPhase 中:

  1. FRONTEND
  2. FIR2IR
  3. KLIB
  4. BACKEND

FIR 与 IR 在插件开发中的应用

对编译器插件开发者而言,理解 FIR 和 IR 的角色至关重要。

  • FIR (Frontend Intermediate Representation): 是一个更接近 Kotlin 源码的中间表示。主要用于语法分析、语义分析和诊断。详细信息请参考 FIR 官方文档
  • IR (Intermediate Representation): 是一个更底层的、与具体平台无关的中间表示。它是连接前端和后端的桥梁,大多数代码生成和转换插件(如添加/修改函数、类)都在 IR 阶段进行。详细信息请参考 Compiler Plugins 官方文档

实际案例:Zipline IR 插件

Zipline 项目通过 IR 插件实现了跨平台(Kotlin/JS, Kotlin/JVM)的 RPC 调用。其核心原理是在编译期动态修改实现了 ZiplineService 接口的代码。

第一步:开发者编写的代码
开发者仅需定义一个简单的服务接口。

1
2
3
4
5
6
7
// EchoService.kt
package com.example
import app.cash.zipline.ZiplineService

interface EchoService : ZiplineService {
fun echo(request: String): String
}

第二步:Zipline IR 插件在编译时的工作
在编译器的 IR 阶段,ZiplineIrGenerationExtension 插件被激活。它扫描所有实现了 ZiplineService 的接口,并执行以下内存中的 IR 变换:

  1. 创建伴生对象:如果接口没有 companion object,则为其创建一个。
  2. 生成 Adapter 类:在伴生对象内部,生成一个名为 Adapter 的嵌套类,该类实现了 ZiplineServiceAdapter,用于桥接 Zipline 的底层通信。
  3. 生成代理类:在 Adapter 内部,再生成一个私有的代理类(如 GeneratedOutboundService),该类实现了原始的 EchoService 接口,并将所有方法调用转发给 Zipline 的 RPC callHandler

第三步:编译器后端看到的最终 IR 结构
插件处理完成后,编译器后端看到的 EchoService 的 IR 结构在逻辑上等同于以下代码。这份代码并未出现在源文件中,而是由插件在内存中动态构建,并直接用于生成最终的目标平台代码(如 JVM 字节码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example

import app.cash.zipline.ZiplineService
import app.cash.zipline.internal.bridge.ZiplineServiceAdapter
import kotlinx.serialization.KSerializer

interface EchoService : ZiplineService {
fun echo(request: String): String

// --- START: CODE GENERATED BY ZIPLINE IR PLUGIN ---
companion object {
internal class Adapter(...) : ZiplineServiceAdapter<EchoService>, KSerializer<EchoService> {
// ... 自动实现 ziplineFunctions()
// ... 自动实现 outboundService()

private class GeneratedOutboundService(
private val callHandler: OutboundCall.Handler
) : EchoService { // 代理类实现了原始接口
override fun echo(request: String): String {
// 将调用转发给 Zipline 底层通信机制
return callHandler.call(this, 0, request) as String
}
}
}
}
// --- END: CODE GENERATED BY ZIPLINE IR PLUGIN ---
}

总结

通过分析 Kotlin 编译器插件的测试框架,我们可以看到:

  • 诊断测试黑盒测试 是保障插件质量的两种核心手段,分别关注编译过程的正确性和最终代码的运行时行为。
  • 测试指令(如 RUN_PIPELINE_TILL)提供了精细控制编译流程的能力。
  • IR 变换 是编译器插件实现功能强大的代码织入与修改的关键,Zipline 等项目充分展示了其在实际应用中的巨大潜力。

参考链接