Basic KSP validation

The set up and usage of the KSP (Kotlin symbol processing) api can be quite intimidating to begin with but it can be quite rewarding once you’ve found your feet. I started with KSP in my day job for an interesting project to generate code to target JVM/JS that is then compiled by KMP (Kotlin Multiplatform) and eventually run across SpringBoot, Android and iOS. The side benefit of doing a fairly deep dive is then seeing other interesting opportunities to utilise the technology.

This post recreates some validation that is not useful outside the scope of my project but really showcases how with a few lines of code you can get powerful validation for even niche use cases.


Context

One of the modules in my project needed to enforce that we only used properties with no backing field e.g. fields defined as computed properties val label get() = "Some Label". This module has lots of classes with many properties making it difficult to manually audit and keep on top of. After doing the first manual audit to verify everything was computed I had an idea to use KSP to do this for me in future.


Processor

In my use case the whole module needs the same validation applied so I don’t need to be precise about finding specific elements of the code. My high level strategy is to do the following:

  1. Enumerate every file in the project
  2. For each file enumerate all the classes it contains
  3. For each class enumerate all properties
  4. Log a helpful error for every property that has backing storage

There are different ways you can write your KSP code - for cases where you are collecting information about your code to act on you might want to use the visitor pattern and helper classes available. To achieve my goal I can forgo using visitors as the steps above map nicely onto the KSP api and I don’t need to collect any information I’m just going to log errors outright to cause a failed compile.

The code (with markers) ended up something like:

class Processor(private val logger: KSPLogger) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
/* 1 */ resolver.getAllFiles().forEach { file ->
/* 2 */     file.declarations .filterIsInstance<KSClassDeclaration>().forEach { klass ->
/* 3 */         klass.getAllProperties().forEach { property ->
                    if (property.hasBackingField) {
                        val message = """
                            All properties have to be computed. e.g.
                            
                            - val ${property.simpleName.asString()} = ...
                            + val ${property.simpleName.asString()} get() = ...
                        """.trimIndent()
/* 4 */                 logger.error(message, property)
                    }
                }
            }
        }
        return emptyList()
    }
}

The handful of lines above pack a big punch. If any property is added that has a backing field the build will fail to compile and output an error message with some useful tips and the exact source location e.g.

[ksp] .../example/src/main/kotlin/com/paulsamuels/Example.kt:4: All properties have to be computed. e.g.

- val example = ...
+ val example get() = ...

Full disclosure

The code above is the simplest possible processor I could write and in reality there is a little more work involved in getting everything wired up but it’s not too difficult and is very well documented in the KSP quickstart. I won’t repeat the quickstart guide as this post will probably just go out of date but as a rough illustration of how little code it takes to wire things up I have the following

processor/
├── build.gradle.kts
└── src
    └── main
        ├── kotlin
        │   └── com
        │       └── paulsamuels
        │           ├── Processor.kt
        │           └── ProcessorProvider.kt
        └── resources
            └── META-INF
                └── services
                    └── com.google.devtools.ksp.processing.SymbolProcessorProvider

The META-INFO is a one line file and the ProcessorProvider.kt is just

class ProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment) = Processor(environment.logger)
}

This is the entirely of the processer and then you just need to hook this up to the target you want to process.


Conclusion

The actual validator above probably has no practical use outside of my project but hopefully it illustrates that KSP isn’t all that scary and you can build out some fairly niche validations to match your needs with relatively few lines of code.