Share
Embed
Writing a Kotlin Compiler Plugin
Compiler plugins provide a gateway to extend the capabilities of the Kotlin compiler, allowing developers to inject custom logic into the compilation pipeline.
Without compiler plugins, Android Jetpack Compose would have never been possible. Compiler plugins enable the use of Aspect Oriented Programming to apply cross-cutting logic for use cases when Kotlin's language is not modular enough, or create your own implementation of Kotlin’s Coroutine.
In this session, we will explain the fundamentals of Kotlin's Internal Representation (IR) and explore the details of compiler phases. We will then showcase some meta-programming samples, illustrating how to inspect and modify the output of the compiler.
Xavier F. Gouchet
September 25, 2025
Transcript
70 slides
-
Xavier F. GOUCHET Staff Engineer @ Joko Android since 2009
Kotlin since 2016 -
Metaprogramming? • Code that can generate or modify code •
Can happen at runtime or compile time • Exists in Ruby, Python, Java, Typescript, Javascript… and Kotlin -
Java -> Java APT KSP Annotation Based Generate code Kotlin
-> * KCP Access everything Generate/Modify code Compatible with KMP Kotlin -> Kotlin Annotation Based Generate code Meta Programming Kotlin -> Java Annotation Based KAPT Generate code Compatible with KMP -
Why use a KCP? Logging Performances Security Transactions Serialization Validation
Cache Notification Multithreading Memoization Testing UI/UX -
Why use a KCP? Logging Performances Security Transactions Serialization Validation
Cache Notification Multithreading Memoization Testing UI/UX -
Why use a KCP? Logging Performances Security Transactions Serialization Validation
Cache Notification Multithreading Memoization Testing Coroutines Kotlinx serialization Power Assert UI/UX Compose -
A history… 2011 Project Inception 2016 Kotlin 1.0 2023 KMM
Stable 2019 Kotlin becomes preferred Android Language 2012 2017 2024 2021 Open Sourced Official on Android KMP announced K2 Compiler Stable Jetpack Compose -
JS *.js WASM *.wasm Kotlin Compilation Kotlin *.kt Kotlin Compiler
JVM *.class Native *.so -
Compiler Frontend Kotlin *.kt AST FIR AST = Abstract Syntax
Tree FIR = Frontend Internal Representation override fun toString(): String { return "…" } function name toString params return kotlin.String -
Compiler Backend FIR IR (+Lowering) JS *.js WASM *.wasm JVM
*.class Native *.so IR = Internal Representation -
class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =
true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point -
class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =
true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point -
class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =
true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point -
class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =
true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point -
class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =
true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point -
Now what ? • Create Diagnostics • Modify existing code
• Generate new code -
fun doSomething(user : User) { requireNotNull(user.phoneNumber) check(user.age > 18) }
becomes ❗ requireNotNull() can make your application crash and must be avoided ❗ check() can make your application crash and must be avoided Goal -
class CustomChecksExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers
= … override val expressionCheckers: ExpressionCheckers = … override val typeCheckers: TypeCheckers = … } Compile Time Checks (FIR side) -
class CustomChecksExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers
= … override val expressionCheckers: ExpressionCheckers = … override val typeCheckers: TypeCheckers = … } Compile Time Checks (FIR side) -
class CustomChecksExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers
= … override val expressionCheckers: ExpressionCheckers = object : ExpressionCheckers() { override val functionCallCheckers: Set<FirFunctionCallChecker> = setOf(UnsafeCallChecker(session, compileCheckAsErrors)) } override val typeCheckers: TypeCheckers = … } Compile Time Checks (FIR side) -
class UnsafeCallChecker( val session: FirSession ) : FirFunctionCallChecker(MppCheckerKind.Common) { val
invalidCallDiagnostic by error0<KtExpression>() context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirFunctionCall) { if (isInvalid(expression)) { reporter.reportOn(expression.source, invalidCallDiagnostic) } } } Compile Time Checks (FIR side) -
class UnsafeCallChecker( val session: FirSession ) : FirFunctionCallChecker(MppCheckerKind.Common) { val
invalidCallDiagnostic by error0<KtExpression>() context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirFunctionCall) { if (isInvalid(expression)) { reporter.reportOn(expression.source, invalidCallDiagnostic) } } } Compile Time Checks (FIR side) -
class UnsafeCallChecker( val session: FirSession ) : FirFunctionCallChecker(MppCheckerKind.Common) { val
invalidCallDiagnostic by error0<KtExpression>() context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirFunctionCall) { if (isInvalid(expression)) { reporter.reportOn(expression.source, invalidCallDiagnostic) } } } Compile Time Checks (FIR side) -
class UnsafeCallChecker( val session: FirSession ) : FirFunctionCallChecker(MppCheckerKind.Common) { val
invalidCallDiagnostic by error0<KtExpression>() context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirFunctionCall) { if (isInvalid(expression)) { reporter.reportOn(expression.source, invalidCallDiagnostic) } } } Compile Time Checks (FIR side) -
class UnsafeCallChecker( val session: FirSession ) : FirFunctionCallChecker(MppCheckerKind.Common) { val
invalidCallDiagnostic by error0<KtExpression>() context(context: CheckerContext, reporter: DiagnosticReporter) override fun check(expression: FirFunctionCall) { if (isInvalid(expression)) { reporter.reportOn(expression.source, invalidCallDiagnostic) } } } Compile Time Checks (FIR side) -
@LogMethod fun doSomething(i : Int, s: String) { // …
} becomes fun doSomething(i : Int, s: String) { println("⇢ doSomething(i=$i, s=$s”) // … } Goal -
class LogMethodCallVisitor( val pluginContext: IrPluginContext, val messageCollector: MessageCollector ) :
IrVisitorVoid() { // … } Modify code (IR side) -
override fun visitFunction(declaration: IrFunction) { super.visitFunction(declaration) val isAnnotated = declaration.annotations.any
{ it.isAnnotationWithEqualFqName(ANNOTATION_FQNAME) } if (isAnnotated) { declaration.body = declaration.body?.let { wrapFunction(declaration, it) } } } Modify code (IR side) -
override fun visitFunction(declaration: IrFunction) { super.visitFunction(declaration) val isAnnotated = declaration.annotations.any
{ it.isAnnotationWithEqualFqName(ANNOTATION_FQNAME) } if (isAnnotated) { declaration.body = declaration.body?.let { wrapFunction(declaration, it) } } } Modify code (IR side) -
override fun visitFunction(declaration: IrFunction) { super.visitFunction(declaration) val isAnnotated = declaration.annotations.any
{ it.isAnnotationWithEqualFqName(ANNOTATION_FQNAME) } if (isAnnotated) { declaration.body = declaration.body?.let { wrapFunction(declaration, it) } } } Modify code (IR side) -
override fun visitFunction(declaration: IrFunction) { super.visitFunction(declaration) val isAnnotated = declaration.annotations.any
{ it.isAnnotationWithEqualFqName(ANNOTATION_FQNAME) } if (isAnnotated) { declaration.body = declaration.body?.let { wrapFunction(declaration, it) } } } Modify code (IR side) -
fun wrapFunction(function: IrFunction, body: IrBody): IrBlockBody { return DeclarationIrBuilder(pluginContext, function.symbol)
.irBlockBody { +logFunStart(function) for (statement in body.statements) +statement } } Modify code (IR side) -
fun wrapFunction(function: IrFunction, body: IrBody): IrBlockBody { return DeclarationIrBuilder(pluginContext, function.symbol)
.irBlockBody { +logFunStart(function) for (statement in body.statements) +statement } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
fun IrBuilderWithScope.logFunStart(function: IrFunction): IrCall { val concat = irConcat() concat.addArgument(irString("⇢
${function.name}(")) for ((index, valueParameter) in function.parameters.withIndex()) { if (index > 0) concat.addArgument(irString(", ")) concat.addArgument(irString("${valueParameter.name}=")) concat.addArgument(irGet(valueParameter)) } concat.addArgument(irString(")")) return irCall(funPrintln).also { call -> call.arguments[0] = concat } } Modify code (IR side) -
Generating new code • Declare the new Class and functions
in FIR extension • Inject the function body in IR extension -
Warning Generated code only exist after compilation, not in the
IDE Use KSP instead if you can -
// RUN_PIPELINE_TILL: FRONTEND import com.droidcon.kcp.AllowUnsafeCalls fun testCheck() { val s
= (3 * 7 * 9) <!CHECK_CALL!>check(s % 11 == 0)<!> } @AllowUnsafeCalls fun testAllowed() { val s = (208 + 195) check(s % 13 == 0) } Test Diagnostics: unsafeCall.kt -
// RUN_PIPELINE_TILL: FRONTEND import com.droidcon.kcp.AllowUnsafeCalls fun testCheck() { val s
= (3 * 7 * 9) <!CHECK_CALL!>check(s % 11 == 0)<!> } @AllowUnsafeCalls fun testAllowed() { val s = (208 + 195) check(s % 13 == 0) } Test Diagnostics: unsafeCall.kt -
// RUN_PIPELINE_TILL: FRONTEND import com.droidcon.kcp.AllowUnsafeCalls fun testCheck() { val s
= (3 * 7 * 9) <!CHECK_CALL!>check(s % 11 == 0)<!> } @AllowUnsafeCalls fun testAllowed() { val s = (208 + 195) check(s % 13 == 0) } Test Diagnostics: unsafeCall.kt -
// RUN_PIPELINE_TILL: FRONTEND import com.droidcon.kcp.AllowUnsafeCalls fun testCheck() { val s
= (3 * 7 * 9) <!CHECK_CALL!>check(s % 11 == 0)<!> } @AllowUnsafeCalls fun testAllowed() { val s = (208 + 195) check(s % 13 == 0) } Test Diagnostics: unsafeCall.kt -
// RUN_PIPELINE_TILL: FRONTEND import com.droidcon.kcp.AllowUnsafeCalls fun testCheck() { val s
= (3 * 7 * 9) <!CHECK_CALL!>check(s % 11 == 0)<!> } @AllowUnsafeCalls fun testAllowed() { val s = (208 + 195) check(s % 13 == 0) } Test Diagnostics: unsafeCall.kt -
Testing code generation Each file.kt in the test data has
a matching file.fir.txt and file.fir.ir.txt -
// WITH_STDLIB import com.droidcon.kcp.LogMethod fun box(): String { val result
= logMePlease(42, "Lorem ipsum dolor sit amet…") return "OK" } @LogMethod fun logMePlease(i: Int, s: String): String? { return if (i >= 0 && i < s.length) "[" + s.get(i) + "]" else null } Test Code Gen: logMethod.kt -
FILE: logMethod.kt public final fun box(): R|kotlin/String| { lval result:
R|kotlin/String?| = R|/logMePlease|(Int(42), String(Lorem ipsum dolor sit amet…)) ^box String(OK) } @R|com/droidcon/kcp/LogMethod|() public final fun logMePlease(i: R|kotlin/Int|, s: R|kotlin/String|): R|kotlin/String?| { ^logMePlease when () { CMP(>=, R|<local>/i|.R|kotlin/Int.compareTo|(Int(0))) && CMP(<, R|<local>/i|.R|kotlin/Int.compareTo|(R|<local>/s|.R|kotlin/String.length|)) -> { String([).R|kotlin/String.plus|(R|<local>/s|.R|kotlin/String.get|(R|<local>/i| )).R|kotlin/String.plus|(String(])) Test Code Gen: logMethod.fir.txt -
FILE fqName:<root> fileName:/logMethod.kt FUN name:box visibility:public modality:FINAL returnType:kotlin.String BLOCK_BODY VAR
name:result type:kotlin.String? [val] CALL 'public final fun logMePlease (i: kotlin.Int, s: kotlin.String): kotlin.String? declared in <root>' type=kotlin.String? origin=null ARG i: CONST Int type=kotlin.Int value=42 ARG s: CONST String type=kotlin.String value="Lorem ipsum dolor sit amet…" RETURN type=kotlin.Nothing from='public final fun box (): kotlin.String declared in <root>' CONST String type=kotlin.String value="OK" FUN name:logMePlease visibility:public modality:FINAL returnType:kotlin.String? VALUE_PARAMETER kind:Regular name:i index:0 type:kotlin.Int VALUE_PARAMETER kind:Regular name:s index:1 type:kotlin.String annotations: LogMethod BLOCK_BODY CALL 'public final fun println (message: kotlin.Any?): kotlin.Unit declared in kotlin.io' type=kotlin.Unit origin=null Test Code Gen: logMethod.fir.ir.txt -
Expose them in Gradle plugin Compiler Options Adjust the plugin’s
behavior Additional inputs - Files - Primitives (Strings, Numbers, …) - … -
Injekt Plugins fun-cache Allows caching function results Dependency injection at
compile time Poko Generates equals() and toString() on annotated class github.com/linhnvtit/fun-cache github.com/drewhamilton/Poko github.com/IVIanuu/injekt Mokkery Generates Mocks at compile time github.com/lupuuss/Mokkery