In the first part, we explored how APIs are generated, the role of schema definitions, and how these connect to the broader developer workflow. This foundation helped us understand not just what is produced but why it matters for consistency and scalability.

In this part, we’ll dive deeper, first into how the IDE becomes aware of these generated APIs, and then into IR (Intermediate Representation) to see how definitions translate into real implementations.

IR (Intermediate Representation): Building Bodies That Work

Why IR?

IR is the universal, platform-neutral tree after FIR. This is where you implement bodies for your FIR stubs constructing calls, lambdas, receivers, and control flow. It’s powerful and low-level: symbol/receiver mismatches can break compilation.

The target we want

Starting source:

class Repository {
    fun findById(id: String) = Entity(id)
}

What your plugin should synthesize:
fun Repository.suspendify(dispatcher: CoroutineDispatcher): Suspendified =
    Suspendified(this, dispatcher)

inner class Suspendified(
    private val delegate: Repository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun findById(id: String) = withContext(dispatcher) {
        delegate.findById(id)
    }
}

While developing, an IR dump helper is invaluable, it dumps IR trees for annotated types so you can mirror the structure precisely.

IR extension scaffolding

class SuspendifyCompilerIrExtension(configuration: CompilerConfiguration) : IrGenerationExtension {
    private val logger = configuration.getLogger()

    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        moduleFragment.transformChildrenVoid(SuspendifyIrTransformer(pluginContext, logger))
    }
}

Implement IrElementTransformerVoid and override:

Implementation sketch

override fun visitConstructor(declaration: IrConstructor): IrStatement {
    val pluginKey = declaration.origin.getPluginKey<DeclarationKey.SuspendifiedClassConstructor>()
        ?: return super.visitConstructor(declaration)

    declaration.body = irBuilder(declaration.symbol).irBlockBody {
        +irDelegatingConstructorCall(context.irBuiltIns.anyClass.owner.constructors.single())
        +IrInstanceInitializerCallImpl(
            startOffset = startOffset,
            endOffset = endOffset,
            classSymbol = pluginKey.suspendifiedClassId.toClassSymbol(),
            type = context.irBuiltIns.unitType,
        )
    }

    val nested = declaration.parentAsClass
    val props = listOf(
        Meta.SuspendifiedClass.dispatcherParameter.name to Meta.SuspendifiedClass.dispatcherParameter.type.toIrType(),
        Meta.SuspendifiedClass.delegateParameterName to pluginKey.originalClassId.toIrType()
    )

    props.forEach { (name, type) ->
        val vp = declaration.valueParameters.first { it.name == name }
        val prop = nested.addProperty {
            this.name = name
            visibility = DescriptorVisibilities.PRIVATE
        }
        prop.addBackingField { this.type = type }.apply {
            initializer = factory.createExpressionBody(
                IrGetValueImpl(
                    UNDEFINED_OFFSET, UNDEFINED_OFFSET,
                    vp.type, vp.symbol,
                    origin = IrStatementOrigin.INITIALIZE_PROPERTY_FROM_PARAMETER
                )
            )
        }
        prop.addDefaultGetter(nested, pluginContext.irBuiltIns) {
            visibility = DescriptorVisibilities.PRIVATE
        }
    }
    return super.visitConstructor(declaration)
}

Wrapper method logic

Each wrapper should look like:

return withContext(dispatcher) {
    delegate.original(args)
}

Steps you’ll encode:

  1. Load dispatcher via generated getter on the Suspendified receiver.

  2. Build a FUN_EXPR (lambda) capturing parameters.

  3. Inside lambda, call original on delegate.

  4. Wire type arguments, receivers, and value parameters correctly.

    The complete implementation is longer (symbol lookups, factories, scopes). Keep your IR builder helpers tidy; small mistakes surface as cryptic errors.

    Publishing, Putting It All Together & Conclusions

    Publishing plan

    You’ll publish three artifacts:

    1. plugin-gradle: via java-gradle-plugin so users can apply id("...")
    2. plugin-kotlin: the compiler plugin JAR, via net.thebugmc.gradle.sonatype-central-portal-publisher
    3. Lib: a tiny module exposing @Suspendifyable (also via the Sonatype publisher)

    Also configure maven-publish for publishToMavenLocal to iterate quickly.

    One-command release

    Create a root task (e.g., in buildSrc) called publishPluginsAndLibs that:

    • scans subprojects for available publish tasks (publishPlugins, publish, publishToMavenLocal);

    runs the appropriate ones.
    Add a./publish.sh wrapper for convenience.

    Project Layout

    A clean, testable structure:

    lib/@Suspendifyableannotation (no compiler deps).
    plugin-kotlin/— FIR + IR logic (compiler JAR).
    plugin-gradle/ — user DSL (suspendify { enabled = true}) and wiring to the compiler plugin.
    example/ — sample consumer to validate IDE visibility + runtime behavior.

    What happens at build table

    Consumer build script:

    plugins {
        id("io.github.rastiehaiev.suspendify") version "x.y.z"
    }
    
    dependencies {
        implementation("io.github.rastiehaiev:suspendify-core:x.y.z")
    }
    
    suspendify {
        enabled = true // true by default
    }
    

    Behind the scenes:

  5. Gradle plugin registers the compiler artifact and passes a CLI flag like
    io.github.rastiehaiev.suspendify.enabled=true.

  6. The Kotlin compiler discovers your registrar via META-INF/services and activates FIR + IR extensions.

  7. FIR phase finds @Suspendifyable classes and generates declarations:

    • nested A.Suspendified,
    • factory suspendify(...),
    • suspend mirrors of public methods.
  8. IR phase implements those stubs:

    • constructor + backing fields/getters,
    • suspendify(...) factory body,
    • each suspend wrapper using withContext(dispatcher) { original(...) }

Because FIR injects declarations early, the synthetic API appears in the IDE before IR runs.

Practical starting checklist

Conclusion

Over the course of these four parts, we’ve walked through the why, what, and how of Kotlin compiler plugins, using “Suspendify” as a practical case study. Let’s bring the threads together.

The Bigger Picture: Why Compiler Plugins Matter

Kotlin is already an expressive and developer-friendly language, but as with any language, real-world projects accumulate boilerplate and recurring patterns. Compiler plugins let you extend the language itself to meet the needs of your team, project, or framework. Instead of forcing developers to follow conventions manually, a compiler plugin enforces, generates, and guarantees them automatically all while integrating seamlessly with the IDE.

This is a level of power beyond libraries or annotation processors: it’s about teaching the compiler new tricks, effectively evolving Kotlin into a version that fits your workflow.

The concepts and the representation

  1. **Concepts & Theory (Part 1): \ We already covered the basics on compiler plugins, you know, what they are and why they matter. They help get rid of stuff like runtime libraries or processors in different ways. That Suspendify idea showed how you can wipe out common coroutine boilerplate right at compile time. It makes the code cleaner and safer, even more maintainable in the long run. We also dug into the plugin architecture, the way it splits between Gradle and Kotlin modules. And then there are those compilation phases, FIR and IR, which give natural spots to hook in our logic.

  2. **Frontend IR (Part 1): \ FIR really drives home how important developer experience is. Without those FIR stubs, the IDE would be totally blind to any generated APIs. I mean, by injecting declarations during the frontend phase, we make sure autocompletion, navigation, and static analysis just work without any hassle. This is the thing that makes compiler plugins feel natural. Developers don't even think of them as external tools. They see them as part of the language itself.

  3. Intermediate Representation (Part 2):
    This is where the real heavy lifting goes down. We move from declarations over to actual implementations here. We're wiring in the logic that makes Suspendify actually useful, like wrapping methods  withContext(dispatcher). The phase gets intricate fast. Handling symbols, parameters, scopes, and receivers correctly, that takes precision. But it's also universal, working across all Kotlin targets, JVM, JS, Native, you name it.

  4. Publishing & Integration (Part 2):
    Publishing and Integration, part 2. Finally, we got into the real-world stuff. Like packaging the plugin properly, exposing a clean Gradle DSL. And publishing artifacts so others can grab your plugin with just a single line in their build script. This step is crucial. A plugin only makes an impact if it's usable and easy for the community to discover.

The Benefits of This Approach

Lessons for Aspiring Plugin Authors

If you’re new to compiler plugins, the process may feel daunting at first especially when working with IR. But the journey pays off quickly. Some practical advice:

Looking Ahead

Kotlin's moving pretty fast these days. The K2 compiler, it opens up all these new possibilities for plugins, you know. As the ecosystem keeps maturing, we'll probably see more plugins coming from the community. Those could really push what the language can handle. Maybe even turn patterns like Suspendify into something everyone just does as standard practice.

Compiler plugins, they're basically the next big step for getting more done with Kotlin. They allow us to tweak the language so it fits right into our specific domain. It becomes this tool that matches our architecture and how we code. All while keeping things safe, consistent, and performing well.

Final Thoughts

The Suspendify example really shows off how you can tackle that annoying repetitive stuff with coroutines, all sorted out nicely at compile time. But hey, it's not just about that one thing. The big point here is compiler plugins let you shape Kotlin exactly how you want it, instead of twisting your own code around to match what the language expects.

You know, whether you're putting together some tools for your team or frameworks that the whole community can use, or even just messing around with experiments to grow your skills personally, getting good at these plugins is a huge step up. Basically, it's not only about coding. It's about creating the language that handles the coding for you.