
@GenerateInterface("UserRepository") class MongoUserRepository<T> : UserRepository { override suspend fun findUser(userId: String): User? = TODO() override suspend fun findUsers(): List<User> = TODO() override suspend fun updateUser(user: User) { TODO() } @Throws(DuplicatedUserId::class) override suspend fun insertUser(user: User) { TODO() } } class FakeUserRepository : UserRepository { private var users = listOf<User>() override suspend fun findUser(userId: String): User? = users.find { it.id == userId } override suspend fun findUsers(): List<User> = users override suspend fun updateUser(user: User) { val oldUsers = users.filter { it.id == user.id } users = users - oldUsers + user } override suspend fun insertUser(user: User) { if (users.any { it.id == user.id }) { throw DuplicatedUserId } users = users + user } }
interface UserRepository { suspend fun findUser(userId: String): User? suspend fun findUsers(): List<User> suspend fun updateUser(user: User) @Throws(DuplicatedUserId::class) suspend fun insertUser(user: User) }
The complete project can be found on GitHub under the name MarcinMoskala/generateinterface-ksp.
generateinterface-processor. We also need a module where we will define the annotation, which we’ll call generateinterface-annotations.ksp plugin. Assuming we use Gradle in our project, this is how we might define our main module dependency in newly created modules.// build.gradle.kts
plugins {
id("com.google.devtools.ksp")
// …
}
dependencies {
implementation(project(":annotations"))
ksp(project(":processor"))
// ...
}
kspTest configuration.// build.gradle.kts
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation(project(":annotations"))
ksp(project(":processor"))
kspTest(project(":processor"))
// ...
}
generateinterface-annotations module, all we need is a file with our annotation definition:package academy.kt import kotlin.annotation.AnnotationTarget.CLASS @Target(CLASS) annotation class GenerateInterface(val name: String)
generateinterface-processor module below, we need to specify two classes. We need to specify the processor provider, a class that implements SymbolProcessorProvider and overrides the create function, which produces an instance of SymbolProcessor. Inside this method, we have access to the environment, which can be used to inject different tools into our processor. Both SymbolProcessorProvider and SymbolProcessor are represented as interfaces, which makes our processor simpler to unit test.class GenerateInterfaceProcessorProvider : SymbolProcessorProvider { override fun create( environment: SymbolProcessorEnvironment ): SymbolProcessor = GenerateInterfaceProcessor( codeGenerator = environment.codeGenerator, ) }
com.google.devtools.ksp.processing.SymbolProcessorProvider, under the path src/main/resources/META-INF/services. Inside this file, you need to specify the processor provider using its fully qualified name:academy.kt.GenerateInterfaceProcessorProvider
SymbolProcessor interface and override the single process function. The processor does not need to specify annotations or the language versions it supports. The process function needs to return a list of annotated elements, which I will explain later in this chapter in the Multiple round processing section. In this example, we will return an empty list.class GenerateInterfaceProcessor( private val codeGenerator: CodeGenerator, ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { // ... return emptyList() } }
override fun process(resolver: Resolver): List<KSAnnotated> { resolver .getSymbolsWithAnnotation( GenerateInterface::class.qualifiedName!! ) .filterIsInstance<KSClassDeclaration>() .forEach(::generateInterface) return emptyList() } private fun generateInterface(annotatedClass: KSClassDeclaration){ // ... }
generateInterface, we first establish our interface name by finding the interface reference and reading its name.val interfaceName = annotatedClass .getAnnotationsByType(GenerateInterface::class) .single() .name
getAnnotationsByTypeneeds@OptIn(KspExperimental::class)annotation to work.
val interfacePackage = annotatedClass .qualifiedName ?.getQualifier() .orEmpty()
In this chapter, I won’t explain how KSP models Kotlin because it is quite intuitive to those who understand programming nomenclature. It is also quite similar to how Kotlin Reflect models code elements. I could write a long section explaining all the classes, functions, and properties, but this would be useless. Who would want to remember all that? We only need knowledge about an API when we use it, and in this case it is much more convenient to read docs or to look for answers on Google. I believe that books should explain possibilities, potential traps, and ideas that are not easy to deduce.
getDeclaredFunctions method. The getAllFunctions method is not appropriate because we don’t want to include methods from class parents (like hashCode and equals from Any). I also needed to filter out constructors that are considered methods in Kotlinwell.val publicMethods = annotatedClass .getDeclaredFunctions() .filter { it.isPublic() && !it.isConstructor() }
val fileSpec = buildInterfaceFile( interfacePackage, interfaceName, publicMethods ) val dependencies = Dependencies( aggregating = false, annotatedClass.containingFile!! ) fileSpec.writeTo(codeGenerator, dependencies)
buildInterfaceFile method for building a file.private fun buildInterfaceFile( interfacePackage: String, interfaceName: String, publicMethods: Sequence<KSFunctionDeclaration>, ): FileSpec = FileSpec .builder(interfacePackage, interfaceName) .addType(buildInterface(interfaceName, publicMethods)) .build()
private fun buildInterface( interfaceName: String, publicMethods: Sequence<KSFunctionDeclaration>, ): TypeSpec = TypeSpec .interfaceBuilder(interfaceName) .addFunctions( publicMethods .map(::buildInterfaceMethod).toList() ) .build()
getShortName. We’ll separately establish function modifiers, and we also have a separate function to map parameters. We use the same result type and the same annotations, but both need to be mapped to KotlinPoet objects.private fun buildInterfaceMethod( function: KSFunctionDeclaration, ): FunSpec = FunSpec .builder(function.simpleName.getShortName()) .addModifiers(buildFunctionModifiers(function.modifiers)) .addParameters( function.parameters.map(::buildInterfaceMethodParameter) ) .returns(function.returnType!!.toTypeName()) .addAnnotations( function.annotations .map { it.toAnnotationSpec() } .toList() ) .build()
private fun buildInterfaceMethodParameter( variableElement: KSValueParameter, ): ParameterSpec = ParameterSpec .builder( variableElement.name!!.getShortName(), variableElement.type.toTypeName(), ) .addAnnotations( variableElement.annotations .map { it.toAnnotationSpec() }.toList() ) .build()
abstract modifier, and we should ignore the override and open parameters as the former is not allowed in interfaces, while the latter is simply redundant in interfaces.private fun buildFunctionModifiers( modifiers: Set<Modifier> ) = modifiers .filterNot { it in IGNORED_MODIFIERS } .plus(Modifier.ABSTRACT) .mapNotNull { it.toKModifier() } companion object { val IGNORED_MODIFIERS = listOf(Modifier.OPEN, Modifier.OVERRIDE) }

CodeGenerator are represented with interfaces in KSP, we can easily make their fakes and implement unit tests for our processors. However, there are also ways to verify the complete compilation result, together with logged messages and generated files.GenerateInterfaceProcessorProvider provider and test the source code, compile it, and confirm that it is as expected. This is the function I defined for that:private fun assertGeneratedFile( sourceFileName: String, @Language("kotlin") source: String, generatedResultFileName: String, @Language("kotlin") generatedSource: String ) { val compilation = KotlinCompilation().apply { inheritClassPath = true kspWithCompilation = true sources = listOf( SourceFile.kotlin(sourceFileName, source) ) symbolProcessorProviders = listOf( GenerateInterfaceProcessorProvider() ) } val result = compilation.compile() assertEquals(OK, result.exitCode) val generated = File( compilation.kspSourcesDir, "kotlin/$generatedResultFileName" ) assertEquals( generatedSource.trimIndent(), generated.readText().trimIndent() ) }
class GenerateInterfaceProcessorTest { @Test fun `should generate interface for simple class`() { assertGeneratedFile( sourceFileName = "RealTestRepository.kt", source = """ import academy.kt.GenerateInterface @GenerateInterface("TestRepository") class RealTestRepository { fun a(i: Int): String = TODO() private fun b() {} } """, generatedResultFileName = "TestRepository.kt", generatedSource = """ import kotlin.Int import kotlin.String public interface TestRepository { public fun a(i: Int): String } """ ) } // ... }
class GenerateInterfaceProcessorTest { // ... @Test fun `should fail when incorrect name`() { assertFailsWithMessage( sourceFileName = "RealTestRepository.kt", source = """ import academy.kt.GenerateInterface @GenerateInterface("") class RealTestRepository { fun a(i: Int): String = TODO() private fun b() {} } """, message = "Interface name cannot be empty" ) } // ... }
A in file A.kt and class B in file B.kt, both annotated with GenerateInterface.// A.kt @GenerateInterface("IA") class A { fun a() } // B.kt @GenerateInterface("IB") class B { fun b() }
GenerateInterfaceProcessor will generate both IA.kt and IB.kt. When you compile your project again without making any changes in A.kt or B.kt, GenerateInterfaceProcessor will not generate any files. If you make a small change in A.kt, like adding a space, and you compile your project again, only IA.kt will be generated again and will replace the previous IA.kt.getSymbolsWithAnnotation and getAllFiles will only return files and elements from files that are considered dirty.Resolver::getAllFiles- returns only dirty file references.Resolver::getSymbolsWithAnnotation- returns only symbols from dirty files.
getDeclarationsFromPackage or getClassDeclarationByName, will still return all files; however, when we determine which symbols to process, we typically base them on getAllFiles and getSymbolsWithAnnotation.// A.kt @GenerateInterface open class A { // ... } // B.kt class B : A() { // ... }
Dependencies class, which lets us specify the aggregating parameter and then any number of file dependencies. In our example processor, we specified that the generated file depends only on the file containing the annotated class used to generate this file.val dependencies = Dependencies( aggregating = false, annotatedClass.containingFile!! ) val file = codeGenerator.createNewFile( dependencies, packageName, fileName )
Dependencies class allows vararg arguments of type KSFile.fun classWithParents( classDeclaration: KSClassDeclaration ): List<KSClassDeclaration> = classDeclaration .superTypes .map { it.resolve().declaration } .filterIsInstance<KSClassDeclaration>() .flatMap { classWithParents(it) } .toList() .plus(classDeclaration) val dependencies = Dependencies( aggregating = ann.dependsOn.isNotEmpty(), *classWithParents(annotatedClass) .mapNotNull { it.containingFile } .toTypedArray() )
- Determine that a file not associated with any existing file should be removed.
- Determine which files should be considered dirty.
OA.kt depends on A.kt and B.kt, then:- A change in
A.ktmakesA.ktandB.ktdirty. - A change in
B.ktmakesB.ktandA.ktdirty.

If we change
A.kt , then both A.kt and B.kt become dirty.OA.kt depends on A.kt and B.kt, and OB.kt depends on B.kt and C.kt, and OD.kt depends on D.kt and E.kt, then:- A change in
A.ktmakesA.kt,B.kt, andC.ktdirty. - A change in
B.ktmakesA.kt,B.kt, andC.ktdirty. - A change in
C.ktmakesA.kt,B.kt, andC.ktdirty. - A change in
D.ktmakesD.ktandE.ktdirty. - A change in
E.ktmakesD.ktandE.ktdirty.

A change in
A.kt makes A.kt, B.kt, and C.kt dirty.
A change in
D.kt makes D.kt and E.kt dirty.aggregating flag to true in their dependency. An aggregating output can potentially be affected by any input changes. All input files that are associated with aggregating outputs will be reprocessed. Consider the following scenario:OA.kt depends on A.kt, and OB.kt depends on B.kt, and OC.kt is aggregating and depends on C.kt and D.kt, then:- A change in
A.ktmakesA.kt,C.kt, andD.ktdirty. - A change in
B.ktmakesB.kt,C.kt, andD.ktdirty. - A change in
C.ktmakesC.ktandD.ktdirty. - A change in
D.ktmakesC.ktandD.ktdirty.

A change in
A.kt makes A.kt, C.kt and D.kt dirty.aggregating to false (so we set this file as isolating), and we depend on the file used to generate this output file, which is typically the file that includes this annotated element.val dependencies = Dependencies( aggregating = false, annotatedClass.containingFile!! ) val file = codeGenerator.createNewFile( dependencies, packageName, fileName )
process function can be called multiple times if needed. For instance, let's say that you implement a simple Dependency Injection Framework and you use KSP to generate classes that create objects. Consider that you have the following classes:@Single class UserRepository { // ... } @Provide class UserService( val userRepository: UserRepository ) { // ... }
class UserRepositoryProvider : SingleProvider<UserRepository>() { private val instance = UserRepository() override fun single(): UserRepository = instance } class UserServiceProvider : Provider<UserService>() { private val userRepositoryProvider = UserRepositoryProvider() override fun provide(): UserService = UserService(userRepositoryProvider.single()) }
UserServiceProvider depends on UserRepositoryProvider, which is also created using our processor. To generate UserServiceProvider, we need to reference the class generated for UserRepositoryProvider; for that, we need to generate UserRepositoryProvider in the first round, and UserServiceProvider in the second round. How do we achieve that? From process, we need to return a list of annotated elements that could not be generated but that we want to try to generate in the next round. In the next round, getSymbolsWithAnnotation from Resolver will only return elements that were not generated in the previous round. This way, we can have multiple rounds of processing to resolve deferred symbols.class ProviderGenerator( private val codeGenerator: CodeGenerator, ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { val provideSymbols = resolver.getSymbolsWithAnnotation( Provide::class.qualifiedName!! ) val singleSymbols = resolver.getSymbolsWithAnnotation( Single::class.qualifiedName!! ) val symbols = (singleSymbols + provideSymbols) .filterIsInstance<KSClassDeclaration>() val notProcessed = symbols .filterNot(::generateProvider) return notProcessed.toList() } // ... }
UserRepositoryProvider is not available then. Instead, we should first generate UserServiceProvider and then UserRepositoryProvider in the second round. Multiple rounds of processing are useful when processor execution depends on elements that might be generated by this or another processor.A simplified example of this use-case is presented on my GitHub repository MarcinMoskala/DependencyInjection-KSP.
ksp in the dependencies block for a specific compilation target, we define a special dependencies block at the top-level. Inside it, we specify which targets we should use a specific processor for.plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
}
kotlin {
jvm {
withJava()
}
linuxX64() {
binaries {
executable()
}
}
sourceSets {
val commonMain by getting
val linuxX64Main by getting
val linuxX64Test by getting
}
}
dependencies {
add("kspCommonMainMetadata", project(":test-processor"))
add("kspJvm", project(":test-processor"))
add("kspJvmTest", project(":test-processor"))
// Doing nothing, because there's no such test source set
add("kspLinuxX64Test", project(":test-processor"))
// kspLinuxX64 source set will not be processed
}
[^11_1]: According to KSP documentation.
[^11_2]: By "main module", I mean the module that will use KSP processing.
[^11_4]: Not to be confused with Kerbal Space Program.
[^11_5]: For KSP, lazy means the API is designed to initially provide references instead of concrete types. These references are resolved to a code element when actually needed.