Metaprogramming in Kotlin: Annotation Processors and Code Generation

Metaprogramming, the act of writing code that manipulates other code, offers powerful capabilities for extending and customizing programming languages. In Kotlin, metaprogramming is supported through various mechanisms, including reflection and annotation processing. This post will focus on Metaprogramming in Kotlin with a detailed look at annotation processors and code generation.

What is Metaprogramming?

Metaprogramming involves writing code that treats other code as its data. It allows you to write programs that can modify, generate, or analyze other programs, leading to more flexible and maintainable code.

Why Use Metaprogramming in Kotlin?

  • Code Generation: Automates the creation of boilerplate code.
  • Framework Development: Simplifies framework design and extension.
  • Custom DSLs: Enables the creation of domain-specific languages.
  • Compile-time Safety: Catches errors at compile-time rather than runtime.

Metaprogramming Techniques in Kotlin

Kotlin offers several approaches to metaprogramming:

  • Reflection: Allows inspecting and manipulating classes and objects at runtime.
  • Annotation Processing: Enables the generation of code during compilation based on custom annotations.
  • Compiler Plugins: Extends the Kotlin compiler itself with custom transformations.

In this post, we will dive into Annotation Processing.

Annotation Processing in Kotlin

Annotation processing is a powerful form of metaprogramming in Kotlin. It enables you to analyze Kotlin code and generate new code during compilation. This process is primarily used to reduce boilerplate code and create frameworks that simplify common tasks.

Components of Annotation Processing

  • Annotations: Metadata added to code elements, providing additional information.
  • Annotation Processor: A class that handles the processing of annotations.
  • Compiler: Executes the annotation processor and integrates generated code into the project.

Steps to Implement Annotation Processing

  1. Define a Custom Annotation
  2. Create an Annotation Processor
  3. Register the Annotation Processor
  4. Use the Annotation in Your Code

Step 1: Define a Custom Annotation

First, define a custom annotation in Kotlin. This annotation will be used to mark elements in your code that should be processed by the annotation processor.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class GenerateDataClass(val className: String)

Explanation:

  • @Target(AnnotationTarget.CLASS) specifies that this annotation can only be applied to classes.
  • @Retention(AnnotationRetention.SOURCE) indicates that the annotation is only needed during compilation and will not be included in the runtime.
  • val className: String is a parameter that allows you to specify the name of the data class to be generated.

Step 2: Create an Annotation Processor

Next, create an annotation processor that extends SymbolProcessor from the com.google.devtools.ksp library.

First, add KSP (Kotlin Symbol Processing) dependencies in your build.gradle.kts:

plugins {
    id("com.google.devtools.ksp").version("1.9.21-1.0.16") // Adjust to the latest version
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.9.21-1.0.16")
    ksp("com.google.devtools.ksp:symbol-processing:1.9.21-1.0.16")
}

Now, let’s implement the annotation processor:


import com.google.devtools.ksp.processing.*
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.google.devtools.ksp.validate

class DataClassProcessor(
    val codeGenerator: CodeGenerator,
    val logger: KSPLogger
) : SymbolProcessor {

    override fun process(resolver: Resolver): List {
        val symbols = resolver.getSymbolsWithAnnotation("GenerateDataClass")
        if (!symbols.iterator().hasNext()) {
            return emptyList()
        }
        val ret = symbols.filter { it is KSClassDeclaration && it.validate() }
            .map { it.accept(DataClassVisitor(codeGenerator, logger), Unit) }
        return symbols.filterNot { it.validate() }.toList()
    }
}

class DataClassVisitor(
    val codeGenerator: CodeGenerator,
    val logger: KSPLogger
) : KSVisitorVoid() {

    override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
        val annotation = classDeclaration.annotations.firstOrNull {
            it.shortName.asString() == "GenerateDataClass"
        }

        val className = annotation?.arguments?.firstOrNull()?.value as? String
            ?: classDeclaration.simpleName.asString() + "Data"

        val packageName = classDeclaration.packageName.asString()

        FileSpec.builder(packageName, className)
            .addType(
                TypeSpec.classBuilder(className)
                    .addModifiers(KModifier.DATA)
                    .primaryConstructor(
                        FunSpec.constructorBuilder()
                            .addParameter("id", String::class)
                            .build()
                    )
                    .addProperty(
                        PropertySpec.builder("id", String::class)
                            .initializer("id")
                            .build()
                    )
                    .build()
            )
            .build()
            .writeTo(codeGenerator, Dependencies(false))
    }
}

class DataClassProcessorProvider : SymbolProcessorProvider {
    override fun create(
        environment: SymbolProcessorEnvironment
    ): SymbolProcessor {
        return DataClassProcessor(environment.codeGenerator, environment.logger)
    }
}

Key components:

  • SymbolProcessor: Interface for symbol processors.
  • CodeGenerator: Used to generate files.
  • KSClassDeclaration: Represents a class in KSP.
  • DataClassVisitor: A visitor to extract information from the annotated class.
  • FileSpec, TypeSpec, FunSpec, PropertySpec: Classes from the KotlinPoet library to build the generated code.

And create the ksp resource directory by placing this in `src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider`

DataClassProcessorProvider

For reference the kotlin poet code goes in the visitClassDeclaration. Ensure KotlinPoet dependency.

dependencies {
    implementation("com.squareup:kotlinpoet:1.15.3")
}

Step 3: Register the Annotation Processor

To register the annotation processor, you need to configure the build.gradle.kts file.

ksp {
    arg("key", "value")
}

In your `build.gradle.kts`, specify which modules use the processor. Example if creating the symbolprocessor in separate module to application:


dependencies {
    ksp(project(":your-annotation-module"))
    ...
}

Step 4: Use the Annotation in Your Code

Now you can use the @GenerateDataClass annotation in your Kotlin code.

@GenerateDataClass("MyDataClass")
class MyClass

When you compile your project, the annotation processor will generate a data class named MyDataClass with an id property in the same package as MyClass:

package your.package

data class MyDataClass(val id: String)

Complete Example

Project Structure

├── app
│   ├── build.gradle.kts
│   └── src
│       └── main
│           └── kotlin
│               └── your
│                   └── package
│                       └── MyClass.kt
├── annotation-module
│   ├── build.gradle.kts
│   └── src
│       └── main
│           └── kotlin
│               └── DataClassProcessor.kt
└── settings.gradle.kts

app/build.gradle.kts

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.9.21"
    id("com.google.devtools.ksp") version "1.9.21-1.0.16"
}

dependencies {
    implementation(kotlin("stdlib"))
    ksp(project(":annotation-module"))
    // Other dependencies
}

ksp {
    arg("key", "value")
}

annotation-module/build.gradle.kts

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.9.21"
    id("com.google.devtools.ksp") version "1.9.21-1.0.16"
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.9.21-1.0.16")
    implementation("com.squareup:kotlinpoet:1.15.3")
    ksp("com.google.devtools.ksp:symbol-processing:1.9.21-1.0.16")
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
    jvmTarget = "17"
}

Benefits of Annotation Processing

  • Reduces Boilerplate: Automatically generates repetitive code.
  • Increases Productivity: Developers can focus on business logic.
  • Improves Maintainability: Changes in templates propagate automatically.
  • Enables Compile-time Checks: Validates code and configurations during compilation.

Conclusion

Metaprogramming in Kotlin using annotation processors offers powerful capabilities for code generation and customization. By defining custom annotations and implementing annotation processors, you can automate repetitive tasks, reduce boilerplate code, and improve overall code quality. Kotlin’s annotation processing mechanism allows for greater flexibility and maintainability, enabling developers to create more robust and efficient applications.