Automating Kotlin Data Class Testing with KSP

Ever found yourself needing to create a multitude of instances of a data class with slightly different parameters? Perhaps for testing, generating sample data, or populating a UI with various states. Manually creating these can be tedious and error-prone. What if we could automate this?
This was the spark that led me to explore Kotlin Symbol Processing (KSP) and KotlinPoet to create Kombinator, an annotation processor that automatically generates all possible combinations of a data class’s constructor parameters.
In this article, I’ll walk you through the process, the design decisions, and some of the interesting challenges encountered while building Kombinator.
The Goal: What is Kombinator?
At its core, Kombinator aims to simplify the creation of multiple instances of a Kotlin data class. You annotate a data class (or its parameters) with Kombine and provide the possible values for each parameter. Kombinator then generates an object containing properties for every unique combination of these parameters, plus a handy getAllCombinations() function to retrieve them all as a list.
For example, imagine a UserSettings data class:
With Kombinator, you could annotate it like this:
Kombinator would then generate an object, say UserSettingsCombinations, looking something like this (simplified):
- Kotlin Symbol Processing (KSP): KSP is an API developed by Google that allows you to write lightweight compiler plugins for Kotlin. It processes Kotlin code at compile time, providing access to the structure of your code (like classes, functions, parameters, and annotations) without needing to delve into the complexities of the Kotlin compiler internals. This makes it more efficient and stable than the older KAPT (Kotlin Annotation Processing Tool).
- KotlinPoet: From Square, KotlinPoet is a fantastic library for generating .kt source files. It provides a fluid API to construct Kotlin code programmatically, handling imports, formatting, and other details beautifully.
The Journey: Building Kombinator
Let’s break down how Kombinator works and the key components involved.
- The Kombine Annotation
This is the entry point. It’s a SOURCE retention annotation, meaning it’s only present during compilation and doesn’t make it into the final bytecode. It targets classes and value parameters.
A crucial design choice here was to have separate array parameters for each supported data type (allPossibleStringParams, allPossibleIntParams, etc.). This makes it type-safe at the annotation usage site and simplifies parsing within the processor. Initially, I considered a more generic approach, but this explicitness proved more robust.
2. The ProcessorProvider and Processor
KSP works by discovering SymbolProcessorProvider implementations. Our ProcessorProvider is straightforward:
It simply creates an instance of our main Processor. The Processor is where the magic happens:
The process method is the entry point for KSP. It:
- Gets the qualified name of our Kombine annotation.
- Uses the Resolver to find all symbols (classes, parameters, etc.) annotated with Kombine.
- Filters for valid KSClassDeclaration instances.
- Invokes a DataClassVisitor for each annotated class.
3. The DataClassVisitor: Inspecting and Gathering Information
The DataClassVisitor (implemented as an inner class for access to logger and codeGenerator) uses the KSP Visitor pattern to traverse the AST of the annotated class.
A helper data class, ConstructorParameterInfo, was essential for organizing the extracted details about each constructor parameter:
4. Reading Annotation Values and Building Combinable Groups
This is where the logic gets interesting. We need to determine which values to combine for each parameter.
- Booleans: For Boolean parameters without default values, we automatically assume [false, true] as the combinable values.
- Enums: For Enum parameters without default values, we extract all enum entries.
- Other Types (from Kombine): This is handled by the readParameter function. A key design principle here is that if a Kombine annotation is present directly on a constructor parameter, its specified values will always take precedence over any values defined in a class-level Kombine annotation for that same parameter type. This allows for fine-grained control.
- Handling of Default Values: It’s important to note that Kombinator is designed to generate combinations based on explicitly provided values or inherent options (like booleans and enums). If a parameter has a default value in the data class constructor and is not explicitly targeted by a Kombine annotation (either at the parameter or class level with values for its type), Kombinator will not automatically include it in the combination generation. The expectation is that you explicitly define the range of values you want to combine for parameters you’re interested in varying. If a parameter with a default value is targeted by Kombine, then the values from the annotation will be used, overriding the default for the generated combinations.
The readParameter function (from ReadParameter.kt) is crucial. It iterates through constructor parameters that are not booleans, enums, and crucially, only considers those that do not have default values unless they are explicitly annotated with Kombine to provide values. This is because the goal is to combine based on provided sets of possibilities, not just use a single default.
And the readAnnotationArrayArgument utility helps extract values from the Kombine annotation’s array arguments:
Challenge — Unsigned Types: A notable challenge arose with unsigned types (UByte, UShort, etc.). When KSP reads values from an annotation like val allPossibleUByteParams: UByteArray = [1u, 2u], the argument.value for allPossibleUByteParams surprisingly yields a List
5. Generating the Code with KotlinPoet
Once we have the combinableParameterGroups (a list of pairs, where each pair is ConstructorParameterInfo and its List
writeProperties: This function iterates through all possible combinations and creates a KotlinPoet PropertySpec for each.
generateInstanceProperty: This creates the actual PropertySpec for one instance.
Challenge — KotlinPoet Literals: KotlinPoet is powerful, but you need to use the correct format specifiers for literals. %S for strings (adds quotes), %L for general literals, but floats need f suffix (so %Lf), chars need single quotes (‘%L’), and unsigned types need a u suffix (achieved with %Lu after casting the KSP-provided value to its underlying signed type and then to Long for KotlinPoet’s %Lu to work as expected for all unsigned sizes). This required careful construction of CodeBlocks.
generateCode: Finally, this function assembles the generated object, adds the getAllCombinations() function, and writes the file.
Dependency Management with KSP: The Dependencies object is important. Dependencies(aggregating = false, file) tells KSP that the generated file depends on the source file (file) that contains the annotated class. If the source file changes, KSP knows it needs to reprocess and potentially regenerate this output. aggregating = false means this processor doesn’t aggregate information from multiple source files to produce a single output.
6. Logging and Error Handling
Throughout the process, using KSPLogger (logger.error(), logger.warn(), logger.info()) is crucial for providing feedback to the user about what the processor is doing, any misconfigurations, or errors encountered. Clear error messages are key to a good developer experience.
Challenges and Learnings
- Understanding KSP Types vs. KotlinPoet Types: KSP provides its own representations of types (KSType, KSDeclaration). These often need to be converted to KotlinPoet’s TypeName using extensions like toTypeName() from com.squareup.kotlinpoet.ksp.toTypeName.
- Reading Annotation Arguments: Accessing annotation arguments (annotation.arguments) and their values (argument.value) is straightforward, but the type of argument.value can sometimes be a bit surprising (e.g., List
for arrays, the unsigned types issue mentioned earlier). Robust casting and type checking are necessary. - Iterative Combination Logic: The algorithm in writeProperties to iterate through all combinations (incrementing indices much like counting in different bases) is a classic combinatorial problem.
- KotlinPoet Formatting: Mastering CodeBlocks and format specifiers (%N for names, %T for types, %L for literals, %S for strings) is key to generating clean, correct Kotlin code. The indent (⇥) and unindent (⇤) characters are very helpful for readability.
Conclusion
Building Kombinator was a rewarding dive into the world of KSP and KotlinPoet. It showcased how these tools can be used to significantly reduce boilerplate and automate repetitive coding tasks. While there were certainly hurdles, particularly around type handling and the nuances of KSP’s API, the end result is a utility that can genuinely save time and effort.
If you’re looking to automate code generation in your Kotlin projects, I highly recommend exploring KSP. The learning curve is manageable, and the power it offers is substantial.
Happy Koding (and Kombining)!
Github: https://github.com/sarimmehdi/Kombinator