Blog Infos
Author
Published
Topics
,
Author
Published
Posted by: David Rawson
What is KSP?

KSP (Kotlin Symbol Processor) is a new API from Google for writing Kotlin compiler plugins. Using KSP we can write annotation processors to reduce boilerplate, solve cross-cutting concerns, and move checks from runtime to compile-time.

While we already have annotation processors in Kotlin through kapt, the new KSP API provides promises speed and ergonomics.

Firstly, KSP is faster since kapt depends on compiling Kotlin to Java stubs for consumption by thejavax.lang.model API used by Java annotation processors. Compiling these stubs takes a significant amount of time, and since KSP can skip this step we can write faster processors.

Secondly, KSP provides an idiomatic Kotlin API. Much of the old javax.lang.model API is poorly suited for dealing with the idiosyncrasies of Kotlin. By contrast, KSP has a Kotlin-first approach, while still allowing processing of Java files.

Isn’t that very niche?

While this may seem like an extreme departure from standard annotation processor practice, things are heating up for KSP! At the time of writing, the popular annotation processors for Room and Moshi have experimental support, while DaggerHilt, and Glide have open issues tracking support. The goal is the complete elimination of kapt, which will lead to the biggest

performance gain:

Let’s get started

We’re going to start by building a toy annotation processor. Following this article by Lubos Mudrak, our annotation processor will emit an extension function for summing the Int properties of a Kotlin data class. In other words, given the following data class:

@IntSummable
data class Foo(
val bar: Int = 234,
val baz: Int = 123
)
view raw Foo.kt hosted with ❤ by GitHub

We would like to emit the following code:

public fun FooSummable.sumInts(): Int {
val sum = bar + baz
return sum
}
Dependencies

We will start with a Kotlin JVM module with the following dependencies:

plugins {
kotlin("jvm") version "1.4.32"
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation(kotlin("stdlib"))
implementation("com.google.devtools.ksp:symbol-processing-api:1.5.10-1.0.0-beta01")
}

Note that we need the google() repository for KSP.

Provider

Now we can start writing our classes — the first is a provider used to instantiate our processor:

import com.google.devtools.ksp.symbol.*
class IntSummableProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return IntSummableProcessor(
options = environment.options,
codeGenerator = environment.codeGenerator,
logger = environment.logger
)
}
}
view raw Provider.kt hosted with ❤ by GitHub

In the create function, you get a handle on the SymbolProcessorEnvironment which will allow you to pass options, logging, and code generation as dependencies to your symbol processor.

class IntSummableProcessor(
private val options: Map<String, String>,
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
private lateinit var intType: KSType
override fun process(resolver: Resolver): List<KSAnnotated> {
intType = resolver.builtIns.intType
val symbols = resolver.getSymbolsWithAnnotation(IntSummable::class.qualifiedName!!)
val unableToProcess = symbols.filterNot { it.validate() }
symbols.filter { it is KSClassDeclaration && it.validate() }
.forEach { it.accept(Visitor(), Unit) }
return unableToProcess.toList()
}
}

For our processor itself, we override a process function where we get a Resolver as a parameter. We use the resolver to get the KSTypefor Intsince we will be using this later. Then we look for annotations of the type IntSummable and check them using one of KSP’s inbuilt functions KSNode#validate. We will return a list of any invalid symbols at the end in order to fulfill the function contract. Finally, we will use the visitor pattern to visit each class declaration marked with our annotation.

Visitor

Visitors in KSP have a signature with two type parameters:

interface KSVisitor<D, R> {
fun visitNode(node: KSNode, data: D): R
fun visitAnnotated(annotated: KSAnnotated, data: D): R
// etc.
}
view raw KSVisitor.kt hosted with ❤ by GitHub

The D is an opportunity for you to pass some data into the visitor, while the R is the result you want to return from visiting. The type parameters allow you to create pipelines where you chain the output of one visitor to the input of another visitor.

For our visitor we will keep things simple and perform side-effects on members rather than maintain purity, and so we will use KSVisitor<Unit, Unit>. KSP provides a convenience class for this called KSVisitorVoid:

inner class Visitor : KSVisitorVoid() {
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val qualifiedName = classDeclaration.qualifiedName?.asString()
if (!classDeclaration.isDataClass()) {
logger.error(
"@IntSummable cannot target non-data class $qualifiedName",
classDeclaration
)
return
}
if (qualifiedName == null) {
logger.error(
"@IntSummable must target classes with qualified names",
classDeclaration
)
return
}
}
private fun KSClassDeclaration.isDataClass() = modifiers.contains(Modifier.DATA)
}
view raw Visitor1.kt hosted with ❤ by GitHub

The first part of our visitor performs some easy validation on the class declaration we are visiting, checking whether it is a data class and whether it has a qualified name.

Job Offers

Job Offers


    Senior Android Engineer – Big Release Team

    Zalando SE
    Berlin
    • Full Time
    apply now

    Developer (m/w/d) Backend/ Mobile

    Payback GmbH
    Cologne, Germany
    • Full Time
    apply now

    Lead Android Engineer

    ASOS
    London
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

,

Keep Rules in the Age of Kotlin

ProGuard has been the industry standard for obfuscating, shrinking and optimizing Java & Android apps for close to 20 years. ProGuard, and the compatible R8 shrinker, usually need some configuration since it’s not always technically possible to determine which entities should be processed. The configuration for these tools is based on `-keep` rules that use a…
READ MORE

Jobs

Engine

Now we get to the engine of our visitor. Since we will need the class name, package name, and a list of summable properties in order to generate our code, we are going to store these in members:

inner class Visitor : KSVisitorVoid() {
private lateinit var className: String
private lateinit var packageName: String
private val summables: MutableList<String> = mutableListOf()
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val qualifiedName = classDeclaration.qualifiedName?.asString()
className = qualifiedName
packageName = classDeclaration.packageName.asString()
classDeclaration.getAllProperties()
.forEach {
it.accept(this, Unit)
}
if (summables.isEmpty()) {
return
}
}
override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) {
if (property.type.resolve().isAssignableFrom(intType)) {
val name = property.simpleName.asString()
summables.add(name)
}
}
}
view raw Visitor2.kt hosted with ❤ by GitHub

We obtain the properties we want by having a property declaration visitor visit every property and checking if the type is assignable from Int.

Code generation

We’ve now got enough information to emit our generated code. The KSP examples use string templates to construct the code, but we can bring in KotlinPoet to help us here:

dependencies {
implementation("com.squareup:kotlinpoet:1.8.0")
}
view raw KotlinPoet.kt hosted with ❤ by GitHub

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
// continued from above
if (summables.isEmpty()) {
return
}
val fileSpec = FileSpec.builder(
packageName = packageName,
fileName = classDeclaration.simpleName.asString()
).apply {
addFunction(
FunSpec.builder("sumInts")
.receiver(ClassName.bestGuess(className))
.returns(Int::class)
.addStatement("val sum = ${summables.joinToString(" + ")}")
.addStatement("return sum")
.build()
)
}.build()
codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = false),
packageName = packageName,
fileName = classDeclaration.simpleName.asString()
).use { outputStream ->
outputStream.writer()
.use {
fileSpec.writeTo(it)
}
}
}

We will use FileSpec and FunSpec from KotlinPoet to build the function we need to emit. Finally, the CodeGenerator provided by KSP can provide us an OutputStream which plugs into KotlinPoet’s FileSpec#writeTo.

Testing

The good news for testing is we can use our pre-existing tools:

dependencies {
testImplementation("com.github.tschuchortdev:kotlin-compile-testing:1.4.2")
testImplementation("com.github.tschuchortdev:kotlin-compile-testing-ksp:1.4.2")
}

Thilo Schuchort’s excellent compile testing tool comes with extensions for testing KSP. This is shown in a private function from our test class below where we pass in a list of SymbolProcessorProvider:

private fun compile(vararg source: SourceFile) = KotlinCompilation().apply {
sources = source.toList()
symbolProcessorProviders = listOf(IntSummableProcessor.IntSummableProcessorProvider())
workingDir = temporaryFolder.root
inheritClassPath = true
verbose = false
}.compile()
view raw Compile.kt hosted with ❤ by GitHub

Obtaining the output source files is a bit tricky at the moment. I used a technique described by Gabriel Freitas Vasconcelos in the Github issues:

private fun KotlinCompilation.Result.kspGeneratedSources(): List<File> {
val kspWorkingDir = workingDir.resolve("ksp")
val kspGeneratedDir = kspWorkingDir.resolve("sources")
val kotlinGeneratedDir = kspGeneratedDir.resolve("kotlin")
val javaGeneratedDir = kspGeneratedDir.resolve("java")
return kotlinGeneratedDir.walk().toList() +
javaGeneratedDir.walk().toList()
}
private val KotlinCompilation.Result.workingDir: File
get() = checkNotNull(outputDirectory.parentFile)

Now we can write our tests:

@Test
fun `target is a data class`() {
val kotlinSource = SourceFile.kotlin(
"file1.kt", """
package com.tests.summable
import com.tsongkha.kspexample.IntSummable
@IntSummable
data class FooSummable(
val bar: Int = 234,
val baz: Int = 123
)
"""
)
val compilationResult = compile(kotlinSource)
assertEquals(KotlinCompilation.ExitCode.OK, compilationResult.exitCode)
assertSourceEquals(
"""
package com.tests.summable
import kotlin.Int
public fun FooSummable.sumInts(): Int {
val sum = bar + baz
return sum
}""",
compilationResult.sourceFor("FooSummable.kt")
)
}
view raw KSPTest.kt hosted with ❤ by GitHub

Tags: Kotlin, Annotation Processor

 

View original article at:


Originally published: June 13, 2021

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE
blog
Modern mobile applications are already quite serious enterprise projects that are developed by hundreds…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu