Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Writing a Kotlin Compiler Plugin

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.

Avatar for Xavier F. Gouchet

Xavier F. Gouchet PRO

September 25, 2025
Tweet

More Decks by Xavier F. Gouchet

Other Decks in Technology

Transcript

  1. Metaprogramming? • Code that can generate or modify code •

    Can happen at runtime or compile time • Exists in Ruby, Python, Java, Typescript, Javascript… and Kotlin
  2. 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
  3. Why use a KCP? Logging Performances Security Transactions Serialization Validation

    Cache Notification Multithreading Memoization Testing UI/UX
  4. Why use a KCP? Logging Performances Security Transactions Serialization Validation

    Cache Notification Multithreading Memoization Testing UI/UX
  5. Why use a KCP? Logging Performances Security Transactions Serialization Validation

    Cache Notification Multithreading Memoization Testing Coroutines Kotlinx serialization Power Assert UI/UX Compose
  6. 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
  7. 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
  8. Compiler Backend FIR IR (+Lowering) JS *.js WASM *.wasm JVM

    *.class Native *.so IR = Internal Representation
  9. class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =

    true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point
  10. class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =

    true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point
  11. class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =

    true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point
  12. class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =

    true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point
  13. class SampleCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean =

    true override fun ExtensionStorage.registerExtensions( configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension( SampleFirExtensionRegistrar() ) IrGenerationExtension.registerExtension( SampleIrGenerationExtension() ) } } Plugin entry point
  14. 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
  15. class CustomChecksExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers

    = … override val expressionCheckers: ExpressionCheckers = … override val typeCheckers: TypeCheckers = … } Compile Time Checks (FIR side)
  16. class CustomChecksExtension(session: FirSession) : FirAdditionalCheckersExtension(session) { override val declarationCheckers: DeclarationCheckers

    = … override val expressionCheckers: ExpressionCheckers = … override val typeCheckers: TypeCheckers = … } Compile Time Checks (FIR side)
  17. 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)
  18. 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)
  19. 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)
  20. 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)
  21. 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)
  22. 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)
  23. @LogMethod fun doSomething(i : Int, s: String) { // …

    } becomes fun doSomething(i : Int, s: String) { println("⇢ doSomething(i=$i, s=$s”) // … } Goal
  24. 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)
  25. 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)
  26. 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)
  27. 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)
  28. 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)
  29. 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)
  30. 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)
  31. 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)
  32. 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)
  33. 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)
  34. 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)
  35. 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)
  36. Generating new code • Declare the new Class and functions

    in FIR extension • Inject the function body in IR extension
  37. // 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
  38. // 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
  39. // 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
  40. // 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
  41. // 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
  42. Testing code generation Each file.kt in the test data has

    a matching file.fir.txt and file.fir.ir.txt
  43. // 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
  44. 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
  45. 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
  46. Expose them in Gradle plugin Compiler Options Adjust the plugin’s

    behavior Additional inputs - Files - Primitives (Strings, Numbers, …) - …
  47. 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