Unlocking the Art: A Guide to Generating Code with Kotlin Symbol Processor

Thaw Zin Toe
7 min readJul 9, 2023

--

In the modern Android world, there are numerous architecture and design patterns available to enhance performance. However, as businesses grow, code bases are also getting bigger and bigger. Developers end up writing a significant amount of code, which becomes difficult to maintain due to the expanding code base. This problem increases the challenges of creating boilerplate code and slows down the coding process.

My goal is to reduce manual coding and increase the development speed in my project. Therefore, I have started using KSP to address these challenges.

KSP (Kotlin Symbol Processor) is so powerful tool and API that you can use to develop lightweight compiler plugins.

Unlike traditional code generation approaches that involve manual code modifications or using external libraries, Kotlin Symbol Processor leverages the compiler’s internal APIs to analyze and process the symbols (classes, functions, properties, etc.) in your codebase. This enables you to dynamically generate code during compilation, eliminating the need for manual intervention.

Sorry for TLDR, Let’s start from the beginning

Expectation

In this codelab, we want to generate code with KSP by utilizing simple annotations, as illustrated below:

@GenerateEnum
data class User(
@Enum(enumConstants = ["Male","Female","Other"])
val genderType: Int = 2
)

to

// auto generated code with KSP
public enum class GenderType(
private val type: Int,
) {
MALE(0),
FEMALE(1),
OTHER(2),
;

public companion object {
public fun fromInt(type: Int): GenderType = values().first { it.type == type }
}
}

Overall Dependency Structure

I structured it using KSP in the following.

  • Annotation: This holds your annotation class.
  • Processor: This contains KSP code generation logic.
  • App: This is the Android app that consumes the generated files
overall dependency structure from Raywenderlich

Code Implementation

Please clone from GitHub for code lab and checkout -> start branch

Steps 1 → Create new modules “annotation” and “processor in enumKSP module as shown in the picture below

creating new modules “annotation” and “processor”

Step 2 → Create two annotations.

  1. GenerateEnum → for Class declaration
  2. Enum → for Property declaration
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class GenerateEnum

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.PROPERTY)
annotation class Enum(val enumConstants: Array<String>)

Step 3 → Add dependencies in libs.versions.toml. But I already added these dependencies in this codelab.

kotlinpoet = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet"}
squareup-ksp = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref ="ksp" }

Step 4 → Declare or register in app.build.gradle and processor module

kspAndroid = { id = “com.google.devtools.ksp”, version.ref = “ksp” }

Steps 5, 6, 7 → Implement annotations in processor module

implementation(project(":enumKSP:annotation"))
implementation(libs.squareup.ksp)
implementation(libs.kotlinpoet)

change Java version to JavaVersion.VERSION_17 in all gradle modules.

Before Step 8 we need to know about basic interfaces in KSP

  1. SymbolProcessorProvider → Entry and create instances point of SymbolProcessor provide by KSP
  2. SymbolProcessorMain component for symbol processing in KSP ( such as class, field, property, and annotation symbol). It can analyze and manipulate these symbols during the compilation process
  3. KSVisitorVoid → Use for symbol traversal and processing in KSP and can perform actions for different symbols ( eg. class symbol, field symbol, and property symbol)

Step 8 → Create SymbolProcessor to generate an Enum class

class EnumGenerateProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {}
}

Step 9 → create an extension for SymbolProcessor

class EnumGenerateProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {}
private fun Resolver.getSymbols(cls: KClass<*>) =
this.getSymbolsWithAnnotation(cls.qualifiedName.orEmpty())
.filterIsInstance<KSClassDeclaration>()
.filter(KSNode::validate)
}

Step 10 -> validating Symbols

val symbols = resolver.getSymbols(GenerateEnum::class)
val validatedSymbols = symbols.filter { it.validate() }.toList()
validatedSymbols.forEach { symbol ->
// TODO : step 11 -> create KSVisitorVoid to check Hierarchy
symbol.accept(visitor,Unit)
}

Step 11 → Create KSVisitorVoid to check the Hierarchy

class EnumGenerateVisitor (
private val codeGenerator: CodeGenerator
) : KSVisitorVoid() {
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {}
}

Step 12 → Create packageName and getProperties from classDeclaration

override fun visitClassDeclaration(
classDeclaration: KSClassDeclaration,
data: Unit
) {
val packageName = classDeclaration.packageName.asString()
val properties = classDeclaration.getDeclaredProperties()
}

Step 13 → Finding annotation Enum with KSPropertyDeclaration

this function is finding Enum annotation in the property field variable in GeneratedEnum class annotation. If we have no Enum declaration, we don’t generateEnuClassForProperty

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val packageName = classDeclaration.packageName.asString()
val properties = classDeclaration.getDeclaredProperties()
properties.forEach { ksPropertyDeclaration ->
val enumAnnotation =
ksPropertyDeclaration.annotations.find { it.shortName.asString() == enumClass
}
if (enumAnnotation != null) {
generateEnumClassForProperty(ksPropertyDeclaration,packageName)
}
}

Step 14 → Create enumClass property variable

create enumClass variable only for more readability

class EnumGenerateVisitor(
private val codeGenerator: CodeGenerator
) : KSVisitorVoid() {
private val enumClass = Enum::class.simpleName
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {}
...
}

Step 15 → Create constants ( type and enumConstants )

/**
* type -> private val type: Int for Enum class
* enumConstants -> parameter from "Enum" annotation to get how many enum constants for this
*/
const val enumValueType = "type"
const val enumConstants = "enumConstants"

Steps 16 and 17 → Using Kotlin poet for code generation enum class

/**
* Read more for code generation -> https://square.github.io/kotlinpoet/
*/
private fun generateEnumClassForProperty(
property:KSPropertyDeclaration,
packageName: String
){
val enumAnnotation =property.annotations.find {
it.shortName.asString() == enumClass
}
}

Step 18 → Get arguments from annotated class parameter

get arguments from enumconstants array (we can’t use List because annotation can’t accept it) → get these names [“Male”, “Female”, “Other”] and want to convert to enumconstants.

val listArguments = enumAnnotation?.arguments?.find {  arg ->
arg.name?.asString() == enumConstants
}?.value as List<*>

Step 19 → Create a class from the Property field name ( Enum )

val propertyName = property.simpleName.asString().replaceFirstChar { 
if (it.isLowerCase())
it.titlecase(Locale.getDefault())
else
it.toString()
}
val enumClassName = ClassName(packageName, propertyName)

Step 20 → type is param for GeneratedEnum class using FuncSpec with KotlinPoet

/**
* public enum class GenderType(
* private val type: Int
* )
*/
val type = FunSpec.constructorBuilder()
.addParameter(enumValueType, Int::class)
.build()

Step 21 → Create enumClass using TypeSpec.enumBuilder with KotlinPoet

val enumClass = TypeSpec.enumBuilder(propertyName)
.primaryConstructor(type)
.addProperty(
PropertySpec.builder(enumValueType, Int::class)
.initializer(enumValueType)
.addModifiers(KModifier.PRIVATE)
.build()
)
.apply {}

Step 22 → Create enum constants (as we mentioned earlier: [“MALE”, “FEMALE”, “OTHER”]) with all letters in uppercase (following the enum naming rule).

 listArguments.forEachIndexed { index, value ->
addEnumConstant(
value.toString().uppercase(),TypeSpec.anonymousClassBuilder()
.addSuperclassConstructorParameter("%L", index)
.build()
).build()}

Step 23 → Create a mapper function from Integer to Enum

/**
* we want to create mapper function as shown in below
* public companion object {
* public fun fromInt(type: Int): AgentType = values().first { it.type == type }
* }
*/
addType(
TypeSpec.companionObjectBuilder()
.addFunction(
FunSpec.builder("fromInt")
.addParameter(enumValueType, Int::class)
.returns(enumClassName)
.addCode(
"""
return values().first { it.$enumValueType == $enumValueType }
""".trimIndent()
).build()
).build()
)

Step 24 → Generate an Enum class file with FileSpec with KotlinPoet

val fileSpec = FileSpec.builder(packageName, propertyName).apply {
addType(enumClass)
}.build()

Step 25 → Register visitor class in EnumGenerateProcessor

class EnumGenerateProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val visitor = EnumGenerateVisitor(codeGenerator)
}
}

Step 26 → Register SymbolProcessorProvider class

Create directory in processor/resources/META-INF/services in processor module.

And then create a file with naming “com.google.devtools.ksp.processing.SymbolProcessorProvider

write SymbolProcessorProvider class. In this codelab, we need to register EnumGenerateSymbolProcessorProvider class.

Steps 27 and 28 → Add sourceSets for each configuration in app's build.gradle.kts

Basically, the generated files aren’t a part of your Android code yet. In order to fix that, update the android section in the app's build.gradle.kts

sourceSets.configureEach {
kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/")
}

implement annotation and processor in the app’s build.gradle.kts

implementation(project(":enumKSP:annotation"))
ksp(project(":enumKSP:processor"))

before we write KSP, we need to sync and declare a plugin for KSP.

Step 29 → Create a new model (User) and declare “GenerateEnum” And “Enum” annotation

We have to create this model because KSP will generate when this annotation is calling.

@GenerateEnum
data class User(
@Enum(enumConstants = ["Male", "Female", "Other"])
val genderType: Int = 1
)

Step 30 → Finally, build and run. You should now be able to use GenderType enum class in build/generated/ksp/debug directory.

and then use in Android Compose just like this

val user = User()
val genderType = GenderType.fromInt(user.genderType)
Text(
text = "Hello $name is $genderType",
modifier = modifier
)

If you have any problem, you can reference from “final” branch from KSPEnum codelab.

Congratulations on completing the tutorial! You’ve successfully built an Integer variable to Enum class code generation with SymbolProcessor (KSP).

I hope you’ve enjoyed delving into the world of KSP. Feel free to share your thoughts and engage in the forum comment below.

See you next time … bye bye

References

https://square.github.io/kotlinpoet

--

--