Skip to Content
Course content

162: Kotlin Compiler Plugins Overview

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By the time you've reached this part of the course, you're probably comfortable with how Kotlin works as a user. But today, we're going to peek under the hood. Compiler plugins are where the real "magic" happens—think of things like @Serializable in Kotlinx.Serialization or the way Compose transforms your functions into UI nodes. It's powerful, but I'll be honest: it's one of the steepest learning curves in the ecosystem because you're no longer writing Kotlin; you're writing code that writes Kotlin.

To keep this grounded, let's try to build a conceptual plugin. I want a feature where I can mark a property with a @TraceChange annotation, and the compiler should automatically inject a print statement whenever that property is updated. I'm tired of manually adding println("Value changed to $value") to every setter in my state machines.

Picking our entry point in the IR

Kotlin uses something called IR (Intermediate Representation). Think of it as a tree-like structure that represents your code after it's been parsed but before it becomes JVM bytecode. To change the actual logic of a program, we need to implement an IrGenerationExtension.

My plan is to traverse the IR tree, look for any IrProperty that has my @TraceChange annotation, and then modify its setter. Here is the basic logic I'm aiming for in the extension:

class TraceChangeIrGenerator : IrGenerationExtension {
    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        moduleFragment.accept(object : IrElementVisitorVoid() {
            override fun visitProperty(declaration: IrProperty) {
                if (declaration.hasAnnotation("TraceChange")) {
                    injectLoggingCall(declaration, pluginContext)
                }
                super.visitProperty(declaration)
            }
        }, null)
    }
}

The "Infinite Loop" mistake

Now, here is where I messed up during my first attempt. I decided that since I wanted to log the new value, I would simply call the property's getter inside the setter to see what the value became. It sounded logical at the time.

I wrote a bit of IR code that effectively did this: set(value) { field = value; println(this.myProperty) }. The problem? this.myProperty calls the getter. In some of my more complex custom getters, this triggered a chain reaction that eventually called the setter again, or simply created an unnecessary overhead that slowed the app to a crawl. I realized I shouldn't be referencing the property itself; I should be referencing the field (the backing field) or the value parameter passed into the setter.

I had to go back and modify the IR builder to specifically use the IrValueParameter of the setter. It's a subtle difference in the code, but a huge difference in execution.

Manually stitching the IR nodes

Since we can't just write println() inside a compiler plugin, we have to manually find the function reference for println in the Kotlin Standard Library and "stitch" it into the IR tree. It looks something like this (simplified for clarity):

private fun injectLoggingCall(property: IrProperty, context: IrPluginContext) {
    val setter = property.setter ?: return
    val body = setter.body as IrBlockBody
    
    // Find the println function from the stdlib
    val printlnFunc = context.referenceFunctions(FqName("kotlin.io.println")).first()
    
    // Create the call: println("Property ${property.name} changed!")
    val call = IrCallImpl(
        startOffset = setter.startOffset,
        endOffset = setter.endOffset,
        type = IrBuiltIn.Void,
        symbol = printlnFunc.symbol,
        typeArgumentsCount = 0,
        valueArguments = listOf(createStringIr(context, "Property ${property.name} changed!"))
    )
    
    body.statements.add(call)
}

Wiring it all together via Gradle

The hardest part isn't actually the IR logic—it's getting the compiler to recognize your plugin. You can't just add a dependency; you have to register a ComponentRegistrar. This is a special class that the Kotlin compiler looks for using Java's ServiceLoader.

I usually set up a separate Gradle module for the plugin itself, then use the kotlin-compiler-plugin-gradle-plugin to apply it to my main app. It's a bit of a "bootstrap" process: you write a plugin, you write a Gradle plugin to apply that compiler plugin, and then you apply the Gradle plugin to your project. It feels redundant, but it's necessary to ensure the compiler has the plugin loaded before it starts analyzing your source code.




📋 Practical Task

Implement a "StrictlyReadOnly" IR Validation Logic

Instead of modifying code (which is destructive), you are tasked with designing the logic for a Validation Plugin. Your goal is to create a plugin that marks a compile-time error if a property annotated with @StrictlyReadOnly is assigned a value anywhere other than the class initializer (the init block or constructor).

Write a pseudo-code implementation of the IrElementVisitorVoid that would achieve this. Your solution should:

  • Identify IrSetExpression (where a value is being assigned to a property).
  • Check if the target property has the @StrictlyReadOnly annotation.
  • Verify if the current containing function is the constructor/initializer.
  • Call a hypothetical context.reportError("Property cannot be changed after initialization") if the conditions are met.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.