So, how do I write a Kotlin Symbol Processor (KSP)?
So, how do I write a Kotlin Symbol Processor (KSP)?
Photo from UnsplashA guide for creating your first Kotlin Symbol Processor (KSP)Kotlin Symbol Processing (KSP) is a new API from Google for creating lightweight Kotlin compiler plugins that do annotation processing.The benefit of KSP is that it provides an idiomatic way of processing Kotlin code by understanding Kotlin-specific features, such as extension functions, local functions, declaration-site variance, and others. According to its creators, annotation processors that use KSP can run up to 2x faster compared to KAPT.In this tutorial, we will go through each step of creating annotation processors with KSP, so that you will have an understanding of how to use it for your needs. No prior experience with annotation processing is required.You can find the complete source code for this tutorial below.What will our processor do?We will create a processor that will generate a function for each Kotlin interface annotated with @Function where all its properties are converted to function arguments.For example, for the code snippet below.Example of an interface, annotated with the custom annotationOur processor will generate the following Kotlin function.Example of a function, generated using KSPIn this tutorial we will be focusing on generating function signatures, so their bodies will be pretty simple — just printing some message.Project structureWe will create a Gradle project that consists of the following modules.The structure of the Gradle projectprocessor — is our Kotlin symbol processor.annotations —includes annotations that our processor will use.main-project — is the test project where we will apply our processor.Project setup overviewIn this section, we will overview the Gradle project setup, so it is ready to work with KSP.First, you must create an empty Gradle JVM project using CLI or IntelliJ IDEA.Alternatively, you can clone the GitHub repository and use start directory as your project root. It contains the basic project setup that will get you ready for work with symbol processing.We will need 3 Gradle modules described in the previous section. Their build.gradle.kts files should have the following configuration.annotationsThis is the simplest module that requires only Kotlin language configuration.Annotations module build fileprocessorThis is the most important module of the project. It should depend on the annotations module as well as ksp-api artifact.Processor module build filemain-projectThis module is the consumer of our processor that will help to see the code generation in action.Main-project module build fileFinally, we need to specify versions of Kotlin and KSP plugins in the pluginManagement section of settings.gradle.kts file (in the root project directory). After that, specify the list of repositories for the project in the dependencyResolutionManagement section.Gradle settings fileNow, everything is ready for the most interesting part.Creating the processorStep 1In order to create the processor, we must implement a SymbolProcessor interface.Under the processor/src/main/kotlin/com/morfly directory create a FunctionProcessor class that implements SymbolProcessor interface.Symbol processor implementation classA SymbolProcessor interface requires us to implement a process function. This will be the place where we will have all the processing logic. Let’s leave it blank for now.Step 2We must also create a class that implements SymbolProcessorProvider interface. This class is responsible for instantiating the processor.Under the processor/src/main/kotlin/com/morfly directory create a FunctionProcessorProvider class that implements SymbolProcessorProvider.SymbolProcessorProvider implementationAs you can see, the FunctionProcessor takes 3 constructor arguments:codeGenerator: CodeGenerator — entity that is responsible for creating files with the code generated by the processor.logger: KSPLogger — logger that performs.. well.. logging for the processor.options: Map<String, String> — a collection of key-value options that are passed to the processor. We will see how it works later in this tutorial.All these arguments are taken from the SymbolProcessingEnvironment instance that is provided for us by KSP in FunctionProcessorProvider.create function.Step 3Finally, we need to register the processor we’ve just created.Create a processor/src/main/resources/META-INF/services directory and put there a file with the following name: com.google.devtools.ksp.processing.SymbolProcessorProvider.Open it and register the processor provider by adding its fully qualified name:com.morfly.FunctionProcessorProviderHere is the structure of the processor Gradle module so far:Processor module structureCreating the annotationWe need to create a custom @Function annotation, so that our processor will resolve all symbols annotated with it.Under the annotations/src/main/kotlin/com/morfly directory create a Function.kt file with the following code:Custom annotation for the processorNow, we are ready to implement the processor.Implementing the processorLet’s go back to the FunctionProcessor class and add some logic to it’s process function.FunctionProcessor.process function (part 1)In the snippet above, we are using resolver to retrieve all the class declarations annotated with our annotation com.morfly.Function.In case there are no symbols that can be processed — exit.Note. Since symbols is a Sequence but not List we don’t know its size in advance. That is why we use its iterator in order to check if its empty or not.Now, let’s continue implementing the proceess function with the code below.FunctionProcessor.process function (part 2)All our functions will be generated into a single file under the com.morfly package. Using codeGenerator we generate a file that could be found at build/generated/ksp/main/kotlin/com/morfly/GeneratedFunctions.ktWhen we create a new file using createNewFile it returns an instance of type OutputStream to which we will append all the file content.Let’s implement a += operator for OutputStream, for convenience. In FunctionProcessor class above the process function, add the following code.+= operator implementation for OutputStreamFinally, complete process implementation by adding the final piece of code:FunctionProcessor.process function (part 3)This is the most important part where we traverse the symbol tree using our custom Visitor that we will create further.Don’t forget to close OutputStream and return symbols that can’t be processed during the current round. KSP will try to run them through another round later.Here is the complete process function implementation:FunctionProcessor.process function (complete)Creating a VisitorWe still have one compilation error, as Visitor(file) is unresolved currently. Let’s implement it.In order to traverse and visit each symbol, we must implement a KSVisitor with 2 generic type arguments, D and R.KSVisitor interfaceThe visitor contains a function for each type of symbol, where D is data that is getting passed or accumulated through visiting each symbol, while R is a result that each visit must return.We will not pass the data D or return results R during the traversal for our processor. Therefore, we will use KSVisitor<Unit, Unit>.Moreover, we don’t need to implement all the available visiting functions. Luckily, there is a KSVisitorVoid that contains empty implementations for each of the visiting functions, so we will override only those that we need.Inside the FunctionProcessor class below the process create a Visitor class that extends KSVisitorVoid.Custom visitor implementationWe are passing file as a constructor argument, so that we will be able to do a code generation while visiting symbols. The inner modifier is used to have an access to properties of the FunctionProcessor such as KSPLogger and others.We need to override only 3 visitor functions that we will work with: visitClassDeclaration, visitPropertyDeclaration and visitTypeArgument.First launchLet’s try to run the processor and see it in action. Of course, it won’t do too much useful work for now, but at least we can verify if everything is configured correctly.Under the main-project/src/main/kotlin/com/morfly create a Main.kt file with the following code.Running code generationRun the program using the main function.Open main-project/build/generated/ksp/main/kotlin/com/morfly directory. If everything is done correctly, it should contain a GeneratedFucnctions.kt file with the specified package statement.package com.morflyHow to work with a Visitor?Before implementing the Visitor class let’s see why and how do we need to use visitors while working with KSP.Let’s step aside from Kotlin for a moment and take a look at a simple code snippet of some abstract programming language where we assign the result of addition to the variable.sum = 5 + 4In the beginning, the language compiler or interpreter sees the code as a string. Therefore, it needs to convert it to something more meaningful.The parsing process starts with a lexer that deconstructs the code into a bunch of components called tokens: sum, =, 5, +, 4.After that, a parser comes into action where its goal is to organize tokens into a meaningful data structure and order, according to the language grammar. As the output, it produces an abstract syntax tree that will be used by further compilation stages.Based on this, the example above can be represented with the following syntax tree, where each node is represented by the specific class.Abstract syntax tree exampleNow, if we go back to Kotlin and the symbol processing API in particular, we can see that it operates with similar concepts.Let’s consider an example of the interface, that our processor is going to process:Example of an interface, annotated with the custom annotationWe can split it into the following list of components: MyAwesomeFunction, arg1, String, arg2, Map, String, List, *.If we turn them into a tree data structure, it will look like the diagram below.The syntax tree of a sample interface declarationThis is not the complete tree, as in practice each node would have more children. However, these are the nodes, that will be sufficient for our processor.Each node of the symbol tree in KSP implements the base KSNode interface.KSNode interfaceThe KSNode itself implements a visitor pattern by having accept function that takes KSVisitor as an argument.Basically, what this function does, it calls back the visitor’s visit function with the right argument type. Let’s see the KSPropertyDeclarationImpl class from KSP source code as an example.KSPropertyDeclaration implementation from KSP sourcesThis is how, inside theFunctionProcessor.process function, we’ve started symbol processing using our custom Visitor.symbols.forEach { it.accept(Visitor(file), Unit) }Therefore, by knowing the structure of the tree, we can traverse all nodes that we need using a visitor pattern.Implementing code generationAll the code generation logic of our processor will be implemented inside Visitor class.Step 1. Processing class declarationsOur processor needs to work with the annotated interfaces. In KSP, interface declaration is represented with a KSClassDeclaration type. Therefore, we start processing with visiting class declarations.Go to visitClassDeclaration method of a Visitor and add the implementation code.Visitor.visitClassDeclaration function (part 1)In general KSClassDeclaration describes all types of classes such as class, data class, interface and others. Therefore, we need to make sure, that the annotated class is actually an interface by checking the classKind property. Otherwise, we will stop processing.Next, we need to get @Function annotation from the class declaration. In Kotlin, interfaces may be annotated with multiple annotations, as we know. Therefore, we need to find the right one by its name.Next, we need to get the information from annotation arguments. In our example, we need to find out the name of the generated function by identifying the value of the name argument of the annotation.After that, we can do the actual generation.Continue implementing visitClassDeclaration function with the code below.Visitor.visitClassDeclaration function (part 2)First, we need to identify all the properties that annotated interface has. These will be the arguments of a generated function.Then, we start generating the signature of the function by delegating argument generation to the visitPropertyDeclaration function.Finally, when the function signature is generated, we add a simple body that prints Hello from <function_name> message.Step 2. Processing property declarationsLet’s see how visitPropertyDeclaration is implemented.Visitor.visitPropertyDeclaration functionFor each property, we need to simply generate function arguments in a format argName: ArgType.While argName part is straightforward, just getting simpleName property from the property declaration, the ArgType part requires considering 2 additional steps.Firstly, we not only need to generate the argument type but also make sure it is imported to the file. This means that we need to have the fully qualified name of the type. This can be done in two ways:Using import statement: import com.package.ArgType.Using the fully qualified name of the type each time we use it:argName: com.package.ArgType.We will use the second option as it is easier to implement. We won’t need to go back and forth between the import declarations and places in the code where the type is used, during the code generation.So, how do we get the fully qualified name of the type?If we use KSPropertyDeclaration.type, we get the object of type KSTypeReference . However, in order to get the information about the type, KSTypeReference must be resolved by calling resolve function that returns the object of the type KSType.So, when you have an object of type KSType, we can get the fully qualified name by using the code below.resolvedType.declaration.qualifiedName?.asStringNote. The resolve operation is expensive in terms of computation as the KSP will need to go and find the place where the type is declared. So, it must be used carefully.Secondly, we must handle cases when the type has generic parameters such as ArgType<String> . This requires us to implement additional functionality.Step 3. Processing generic type argumentsFor each argument type that we resolve in visitPropertyDeclaration we must separately resolve its generic arguments that are declared between angle brackets < >.In the Visitor class between visitPropertyDeclaration and visitTypeArgument functions declare a helper function called visitTypeArguments (plural) that takes the list of type arguments.Visitor.visitTypeArguments functionThe function above will set up formatting for generic type arguments block and iterate through each one by calling visitTypeArgument function.Let’s start implementing the visitTypeArgument function.Visitor.visitTypeArgument function (part 1)When working with generics in Kotlin we must also consider a concept in generics called variance. (see the official Kotlin docs to learn more).There are 4 variance types that we must consider:star — Type<*> (we just generate * symbol instead of argument type)covariant — Type<out OtherType> (we add out before the actual type)contravariant — Type<in OtherType> (we add in before the actual type)invariant — Type<OtherType> (just add the argument type itself)After we handled the type variance, we must identify the actual type of the generic argument.Visitor.visitTypeArgument function (part 2)We apply the same approach as we used to define the property type. First, we resolve KSTypeReference, and then get the fully qualified name from KSType:resolvedType?.declaration?.qualifiedName?.asString()Next, we must handle the nested argument types such as Type<OtherType<OtherType>>.This is a pretty straightforward task as we have all the necessary components for it already implemented. Just call visitTypeArguments (plural) inside visitTypeArgument function for its nested argument types and it will do the work recursively.Finally, don’t forget to make the argument type nullable by adding ?, if needed.Running the processorThis is all we need to implement our processor. Now we can run it and see how it works.Open main-project/src/main/kotlin/com/morfly/Main.kt file and replace its contents with the code below.Running the annotation processorWe are almost there...By default, your IDE knows nothing about the code generated with KSP. In order to help it to recognize generated files, open main-project/build.gradle.kts and add the code below somewhere in the file.Adding generated files to the main source setBy adding this configuration, we explicitly marked a directory with the generated code as a Kotlin source directory.Note. Don’t forget to sync Gradle changes.Now, run the main function in the main-project/.../Main.kt file.To verify, that the code generation was done successfully, open main-project/build/generated/ksp/.../GeneratedFunctions.kt file and verify its contents.Generated functionsCongratulations, we just finished implementing the main functionality of our Kotlin Symbol Processor!Passing arguments to the processorNow, when the primary job for creating our symbol processor is done, let’s take look at one more interesting feature of KSP.It is possible to pass arguments to the symbol processor in order to configure its behavior. Let’s try it with our processor.Let’s implement a feature, where all generic type parameters of each function argument, will be replaced with star-projections by our processor.For example, for the annotated interface that has properties with generic arguments…Example of an interface, annotated with the custom annotationWe will generate function arguments where their generic type arguments are replaced with *.Example of a generated function, where all generic arguments are replaced with star-projectionsTo do this we need to add a small change to our processor.Open processor/.../FunctionProcessor.kt file. Go to the Visitor.visitTypeArgument function and add the following code at the very beginning of the function.Handling KSP options in Visitor.visitTypeArgument functionIf you remember, we have passed an options: Map<String, String> argument to the FunctionProcessor class constructor.We can check if it has an option named ignoreGenericArgs and check if it equals to "true".Note. KSP supports only String values for the options, that is why we check if the option is equal to "true" string instead of boolean true.Now, open main-project/build.gradle.kts file and pass the argument to our processor.Passing an argument to our symbol processorNote. Don’t forget to sync Gradle changes.Now, run the main function in the main-project/.../Main.kt file. Open main-project/build/generated/ksp/.../GeneratedFunctions.kt file and verify its contents.Generated code with ‘ignoreGenericArgs’ option enabledAs you can see, all the generic parameters were replaced with star projections.That’s it. In this tutorial, we went through each step required for creating custom Kotlin symbol processors. As you can see KSP is a powerful API that allows creating annotation processors by handling Kotlin-specific features such as declaration-site variance and others.You can practice with KSP by extending the processor we’ve just created. For example, try to add functionality that allows specifying the return type of the generated function. This can be done by marking one of the properties of the annotated interface with the new custom annotation — @Returns.Example of extending the processorYou can find the complete source code for this tutorial below.