Unlocking the Art: A Guide to Generating Code with Kotlin Symbol Processor
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
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
Step 2 → Create two annotations.
- GenerateEnum → for
Class
declaration - 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
- SymbolProcessorProvider → Entry and create instances point of
SymbolProcessor
provide by KSP - SymbolProcessor → Main 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
- 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.