Fundamentals of Kotlin compiler plugin development. This guide covers concepts applicable to any compiler plugin, not just Koin.
- KSP vs Compiler Plugins
- Compilation Pipeline
- IR Fundamentals
- Visitor Pattern
- Creating IR Elements
- Common Patterns
- Debugging Techniques
- What: Code generation based on annotations/symbols
- Use when: Generate boilerplate, create new files
- Limitations: Cannot modify existing code, no access to method bodies
- What: Full access to compilation, can transform code
- Use when: Modify behavior, inject code, transform IR
- Power: Can do anything the compiler can do
Rule: Use KSP for code generation, Compiler Plugins for code transformation.
Source Code (.kt)
↓
[Frontend] → PSI (Program Structure Interface)
↓
[FIR] → Frontend Intermediate Representation (K2 compiler)
↓
[Backend] → IR (Intermediate Representation)
↓
[Codegen] → JVM Bytecode / JS / Native
| Phase | Extension | Purpose |
|---|---|---|
| FIR | FirExtensionRegistrar |
Generate declarations, modify types |
| IR | IrGenerationExtension |
Transform code, inject bodies |
| CLI | CompilerPluginRegistrar |
Configure plugin options |
// Kotlin source
fun hello(): String = "Hello"
// Becomes IR tree
IrSimpleFunction(
name = "hello",
returnType = String,
body = IrBlockBody(
statements = [
IrReturn(
value = IrConst(type = String, value = "Hello")
)
]
)
)| Class | Purpose |
|---|---|
IrElement |
Base class for all IR nodes |
IrDeclaration |
Classes, functions, properties |
IrFunction / IrSimpleFunction |
Function declarations |
IrClass |
Class declarations |
IrProperty |
Properties |
IrExpression |
Expressions (calls, constants, etc.) |
IrStatement |
Statements (returns, loops, etc.) |
IrCall |
Function/method calls |
Your gateway to compiler internals:
class MyExtension : IrGenerationExtension {
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
val irBuiltIns = pluginContext.irBuiltIns // Built-in types
val irFactory = pluginContext.irFactory // Create IR elements
// Find classes by FQN
val myClass = pluginContext.referenceClass(
ClassId(FqName("com.example"), Name.identifier("MyClass"))
)
// Find functions
val printlnFunction = pluginContext.referenceFunctions(
CallableId(FqName("kotlin.io"), Name.identifier("println"))
).first()
}
}class MyTransformer(private val context: IrPluginContext) : IrElementTransformerVoid() {
override fun visitCall(expression: IrCall): IrExpression {
val call = super.visitCall(expression) as IrCall
// Check if this is the call we want to transform
if (call.symbol.owner.name == Name.identifier("myFunction")) {
// Return transformed expression
return createReplacementCall(call)
}
return call
}
override fun visitFunction(declaration: IrFunction): IrStatement {
// Modify function
return super.visitFunction(declaration)
}
}class MyVisitor : IrElementVisitorVoid() {
override fun visitFunction(declaration: IrFunction) {
println("Found function: ${declaration.name}")
super.visitFunction(declaration)
}
override fun visitClass(declaration: IrClass) {
println("Found class: ${declaration.name}")
super.visitClass(declaration)
}
}override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
// Transform
moduleFragment.transform(MyTransformer(pluginContext), null)
// Visit (read-only)
moduleFragment.acceptChildrenVoid(MyVisitor())
}// String constant
IrConstImpl.string(UNDEFINED_OFFSET, UNDEFINED_OFFSET, irBuiltIns.stringType, "Hello")
// Int constant
IrConstImpl.int(UNDEFINED_OFFSET, UNDEFINED_OFFSET, irBuiltIns.intType, 42)
// Boolean constant
IrConstImpl.boolean(UNDEFINED_OFFSET, UNDEFINED_OFFSET, irBuiltIns.booleanType, true)
// Null constant
IrConstImpl.constNull(UNDEFINED_OFFSET, UNDEFINED_OFFSET, nullableType)val builder = DeclarationIrBuilder(context, currentFunctionSymbol)
// Simple call
builder.irCall(functionSymbol).apply {
putTypeArgument(0, typeArg)
putValueArgument(0, valueArg)
}
// Constructor call
builder.irCallConstructor(constructorSymbol, emptyList()).apply {
putValueArgument(0, arg1)
putValueArgument(1, arg2)
}builder.irReturn(expression)val body = context.irFactory.createBlockBody(
UNDEFINED_OFFSET,
UNDEFINED_OFFSET,
listOf(statement1, statement2, returnStatement)
)
function.body = body// 1. Create lambda symbol
val lambdaSymbol = IrSimpleFunctionSymbolImpl()
// 2. Create lambda function
val lambdaFunction = context.irFactory.createSimpleFunction(
startOffset = UNDEFINED_OFFSET,
endOffset = UNDEFINED_OFFSET,
origin = IrDeclarationOrigin.LOCAL_FUNCTION_FOR_LAMBDA,
name = Name.special("<anonymous>"),
visibility = DescriptorVisibilities.LOCAL,
isInline = false,
isExpect = false,
returnType = irBuiltIns.stringType,
modality = Modality.FINAL,
symbol = lambdaSymbol,
isTailrec = false,
isSuspend = false,
isOperator = false,
isInfix = false,
isExternal = false,
isFakeOverride = false
).apply {
parent = builder.scope.getLocalDeclarationParent()
// Create body with scoped builder
val lambdaBuilder = DeclarationIrBuilder(context, lambdaSymbol)
body = context.irFactory.createBlockBody(UNDEFINED_OFFSET, UNDEFINED_OFFSET).apply {
statements.add(lambdaBuilder.irReturn(IrConstImpl.string(..., "hello")))
}
}
// 3. Wrap in expression
val lambda = IrFunctionExpressionImpl(
startOffset = UNDEFINED_OFFSET,
endOffset = UNDEFINED_OFFSET,
type = irBuiltIns.functionN(0).typeWith(irBuiltIns.stringType),
function = lambdaFunction,
origin = IrStatementOrigin.LAMBDA
)Critical: Use lambdaBuilder (scoped to lambda) for irReturn, not the outer builder!
// Find a class
val classSymbol = context.referenceClass(
ClassId(FqName("com.example"), Name.identifier("MyClass"))
)
// Find functions (returns multiple overloads)
val functions = context.referenceFunctions(
CallableId(FqName("kotlin.io"), Name.identifier("println"))
)
// Find constructors
val constructors = context.referenceConstructors(classId)
// Find properties
val properties = context.referenceProperties(
CallableId(FqName("com.example"), Name.identifier("myProperty"))
)// Built-in types
context.irBuiltIns.stringType
context.irBuiltIns.intType
context.irBuiltIns.unitType
context.irBuiltIns.nothingType
context.irBuiltIns.anyType
// Make nullable
val nullableString = context.irBuiltIns.stringType.makeNullable()
// Check types
param.type.isNullable()
param.type.isString()
param.type.isMarkedNullable()
// Generic types
val lazyClass = context.referenceClass(ClassId(FqName("kotlin"), Name.identifier("Lazy")))
val lazyOfString = lazyClass!!.typeWith(irBuiltIns.stringType)fun IrClass.hasAnnotation(fqName: FqName): Boolean {
return annotations.any { annotation ->
annotation.type.classFqName == fqName
}
}
// Get annotation argument
val annotation = declaration.annotations.first { it.type.classFqName == myAnnotationFqn }
val nameArg = annotation.getValueArgument(0) // First argument// CORRECT
val call = builder.irCall(someSymbol)
val body = context.irFactory.createBlockBody(start, end, statements)
// WRONG - Don't use constructors directly
val call = IrCallImpl(...) // Fragile, deprecatedUse UNDEFINED_OFFSET (-1) for generated code:
const val UNDEFINED_OFFSET = -1
val element = IrConstImpl(
startOffset = UNDEFINED_OFFSET,
endOffset = UNDEFINED_OFFSET,
// ...
)// Full IR tree
println(declaration.dump())
// Kotlin-like representation
println(declaration.dumpKotlinLike())
// Type as string
println(type.render())println("Symbol: ${symbol.owner.fqNameWhenAvailable}")
println("Signature: ${declaration.symbol.signature}")override fun visitCall(expression: IrCall): IrExpression {
println("DEBUG: Processing call ${expression.symbol.owner.fqNameWhenAvailable}")
println("DEBUG: Args count = ${expression.valueArgumentsCount}")
println("DEBUG: Type args = ${expression.typeArgumentsCount}")
return super.visitCall(expression)
}fun printParentChain(node: IrElement) {
var current: IrDeclarationParent? = (node as? IrDeclaration)?.parent
while (current != null) {
println("Parent: ${current.javaClass.simpleName}")
current = (current as? IrDeclaration)?.parent
}
}import java.io.File
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
File("/tmp/ir-before.txt").writeText(moduleFragment.dump())
// ... transformations ...
File("/tmp/ir-after.txt").writeText(moduleFragment.dump())
}Then diff: diff /tmp/ir-before.txt /tmp/ir-after.txt
java.lang.NoClassDefFoundError: $$$$$NON_LOCAL_RETURN$$$$$
Cause: Using wrong builder for lambda return
Fix: Use builder scoped to lambda symbol:
val lambdaBuilder = DeclarationIrBuilder(context, lambdaSymbol)
lambdaBuilder.irReturn(value) // NOT outerBuilder.irReturn!Cause: IR node without parent
Fix: Always set parent:
lambdaFunction.parent = builder.scope.getLocalDeclarationParent()This compiler API is deprecated and will be removed soon.
Fix: In build.gradle.kts:
kotlin {
compilerOptions {
allWarningsAsErrors.set(false)
}
}Unresolved reference 'IrCallImpl'
Fix: Use builder instead:
builder.irCall(symbol) // Not IrCallImpl(...)- Jetpack Compose Compiler - Most advanced
- Kotlin Power-Assert - Enhances asserts
- Metro DI - DI with multi-version compat
- Arrow Meta - Functional programming
- Kotlin Compiler DevKit - IntelliJ plugin for testing