Kotlin's pretty concise, expressive. You know, a real joy to work with sometimes. But even with that, languages end up with all these rituals piling on. Boilerplate all over the place. Those repeated patterns that wear thin quick. And yeah, those times you're thinking, man, I wish the compiler took care of this stuff for me. That's basically where a Kotlin Compiler Plugin, or KCP, steps in. It hooks straight into the compilation process. So it reads your code, generates new bits, transforms things here and there, all before bytecode even happens. This affects the IDE too, with things like autocomplete and navigation working better. And it touches the final binaries as well, you know.

Look at Jetpack Compose, for instance. It turns those @Composable functions into real UI-rendering code, right in the compilation phase. No extra steps or anything. Still, the IDE gets it, understands the whole deal. Oh and, a good KCP makes some features feel like they're just part of the language from the start.

Why build one?

Some problems aren’t cleanly solvable with libraries or annotation processors. You need to compile time smarts and full IDE integration. Familiar examples:

These operate under the syntax tree. They change how your code looks, runs, and feels.

The Idea: Suspendify

The problem

So with server-side Kotlin stuff, you know, people often go for this thing where you inject a dispatcher like Dispatchers.IO. Then you wrap those public methods in withContext(dispatcher) { … }. It does the job basically. However, it is noisy and easy to forget too, leading to subtle bugs and inconsistent threading.

The solution

Annotate a class with @Suspendifyable, and let the compiler generate a wrapper where every public method:

fun main() {
    runBlocking {
        val repository = Repository()
        val repositorySuspendified: Repository.Suspendified = repository.suspendify(Dispatchers.IO)
        val entity = repositorySuspendified.findById("id") // suspend method
        println(entity)
    }
}
@Suspendifyable
class Repository {
    fun findById(id: String) = Entity(id)
}
data class Entity(val id: String)

Benefits

Benefits here are pretty straightforward. Cleaner source code, you know, no repeated withContext stuff messing things up. Fewer mistakes too, since dispatcher usage stays consistent all the way. IDE synergy works great, with the generated API popping up in autocomplete, thanks to FIR doing its thing. Compile-time guarantees mean everything gets validated before runtime even hits.

Typical KCP architecture. KCPs usually come in two logical modules. Plugin-gradle is the one users apply in their build.gradle.kts file. It registers the compiler extension and passes along options. Then there's plugin-kotlin, which is the compiler plugin JAR. That parses CLI args and hooks into FIR or IR to generate or transform code. That's the basic setup.

  1. Users add:

plugins {
    id("io.github.rastiehaiev.suspendify") version "x.y.z"
}

How they connect

K2 phases you care about

The Gradle Plugin (DSL + wiring)

A minimal, user-friendly DSL:

plugins {
    id("io.github.rastiehaiev.suspendify") version "0.0.9"
}
suspendify {
    enabled = true
}
Implementation sketch:
@Suppress("unused")
class SuspendifyGradlePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.extensions.create("suspendify", SuspendifyGradlePluginExtension::class.java)
        target.plugins.apply(SuspendifyGradleSupportPlugin::class.java)
    }
}
open class SuspendifyGradlePluginExtension(var enabled: Boolean = false)

KotlinCompilerPluginSupportPlugin translates DSL → CLI:
class SuspendifyGradleSupportPlugin : KotlinCompilerPluginSupportPlugin {
    override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>) =
        kotlinCompilation.target.project.provider {
            val ext = kotlinCompilation.target.project
                .extensions.findByType(SuspendifyGradlePluginExtension::class.java)
                ?: SuspendifyGradlePluginExtension()
            if (ext.enabled) listOf(SubpluginOption("enabled", "true")) else emptyList()
        }
    override fun isApplicable(kotlinCompilation: KotlinCompilation<*>) =
        with(kotlinCompilation.target.project) {
            plugins.hasPlugin(SuspendifyGradleSupportPlugin::class.java) &&
            (extensions.findByType(SuspendifyGradlePluginExtension::class.java)?.enabled == true)
        }
    override fun getCompilerPluginId(): String =
        with(PluginConfiguration) { "$GROUP_ID.$ARTIFACT_ID_GRADLE" }
    override fun getPluginArtifact() = with(PluginConfiguration) {
        SubpluginArtifact(groupId = GROUP_ID, artifactId = ARTIFACT_ID_KOTLIN, version = VERSION)
    }
}

Note: A template repo can pre-wire Gradle + compiler pieces, service registration, CLI parsing, and publishing defaults. Fork, rename, focus on your logic.

FIR (Frontend IR): Make the IDE See Your API

Registering your plugin

Your plugin-kotlin module must register two services under src/main/resources/META-INF/services:

@OptIn(ExperimentalCompilerApi::class)
class SuspendifyCompilerPlugin : CompilerPluginRegistrar() {
    override val supportsK2: Boolean = true
    override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
        val keys = configuration.toKeys()
        if (keys.enabled) {
            FirExtensionRegistrarAdapter.registerExtension(SuspendifyFirExtensionRegistrar(configuration))
            IrGenerationExtension.registerExtension(SuspendifyCompilerIrExtension(configuration))
        }
    }
}

Service files:

org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor

io.github.rastiehaiev.SuspendifyCommandLineProcessor

org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar

io.github.rastiehaiev.SuspendifyCompilerPlugin

If these are wrong or missing, nothing loads.

What FIR does

FIR is where you add declarations so synthetic classes and methods appear in autocomplete, navigation, and inspections. For each @Suspendifyable class A:

Because you inject at FIR, your users see the generated API as if it were written by hand.

FIR implementation outline

Create a FirDeclarationGenerationExtension. Override:

  1. getNestedClassifiersNames: list nested classes you’ll synthesize.

  2. getCallableNamesForClass: list function/ctor names you’ll synthesize.

  3. generateNestedClassLikeDeclaration / generateFunctions / generateConstructors: emit stubs.

    Naming exmaple

override fun getNestedClassifiersNames(
    classSymbol: FirClassSymbol<*>,
    context: NestedClassGenerationContext,
): Set<Name> = setOf("Suspendified")
override fun getCallableNamesForClass(
    classSymbol: FirClassSymbol<*>,
    context: MemberGenerationContext
) = when (val key = classSymbol.getDeclarationKey<DeclarationKey>()) {
    is DeclarationKey.OriginalClass -> setOf("suspendify")
    is DeclarationKey.SuspendifiedClass -> {
        val names = key.originalClass.getFunctions().map { it.name }.toSet()
        names + SpecialNames.INIT // constructor
    }
    else -> emptySet()
}

Generating the suspend methods

@OptIn(SymbolInternals::class)
private fun DeclarationKey.SuspendifiedClass.createSuspendFunctions(
    callableId: CallableId,
    suspendifiedClass: FirClassSymbol<*>,
): List<FirNamedFunctionSymbol> {
    val originalClassId = originalClass.classId
    val key = DeclarationKey.SuspendifiedClassFunction(
        originalClassId = originalClassId,
        suspendifiedClassId = suspendifiedClass.classId,
    )
    return originalClass.getFunctions()
        .filter { it.name == callableId.callableName }
        .filter { !it.isSuspend }
        .mapNotNull { it.toFunctionSpec(originalClassId) }
        .map { spec ->
            createMemberFunction(
                owner = suspendifiedClass,
                key = key,
                name = spec.name,
                returnType = spec.returnType,
            ) {
                status { isSuspend = true }
                spec.parameters.forEach { p -> valueParameter(p.name, p.type) }
            }
        }
        .map { it.symbol }
        .toList()
}

Why this matters: FIR stubs are what make the IDE see your API. IR will fill in bodies later; without FIR the editor would be blind.