Write a Symbol Processor with Kotlin Symbol Processing

Learn how to get rid of the boilerplate code within your app by using Kotlin Symbol Processor (KSP) to generate a class for creating Fragments By Anvith Bhat.

5 (2) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Registering the Provider

The last step to set up your processor is to register its provider. This is done by defining its qualified reference in a special file in the src/main/resources/META-INF/services directory. So navigate to the folder as shown below.

Navigate to Meta Services folder

Create a file named com.google.devtools.ksp.processing.SymbolProcessorProvider with the content below:

com.yourcompany.fragmentfactory.processor.FragmentFactoryProcessorProvider

KSP will use this reference to locate your provider. This is similar to how Android locates your activity path by reading the AndroidManifest.xml file.

Next, move to the app/build.gradle file and add the following line in the dependencies section:

 ksp(project(":processor"))

Include Processor as KSP dependency in app module

This allows the processor to process :app module’s source files.

Kudos, you’ve just configured your first SymbolProcessor. Now, open the Terminal tab in Android Studio and run the following command:

./gradlew clean installDebug --info

You should see a build output similar to the one below:

Processor linked confirmation

You’ll also see a ksp directory inside the build folder of your :app module.

KSP Folder is present in generated folder

Processing Annotations

Before you begin processing and filtering annotations, it’s important to understand how KSP looks at your code. The diagram below shows a bridged version of how KSP models the source code.

How KSP looks at your code

One thing to notice here is how a class declaration statement maps to a KSClassDeclaration node. This will contain more nodes representing the elements that form the body of the class like functions and properties. KSP builds a tree of these nodes from your source code which is then available to your SymbolProcessor. All the classes you define in Android and pretty much every Kotlin entity is available as a list of symbols to the processor.

Filtering Annotations

Since you’re only concerned about Fragments annotated with FragmentFactory you’d want to filter through all the symbols provided. Start by adding the following imports to the FragmentFactoryProcessor class:

import com.google.devtools.ksp.validate
import com.yourcompany.fragmentfactory.annotation.FragmentFactory

Next, replace the process function in the same class with the following code:

 override fun process(resolver: Resolver): List<KSAnnotated> {
    var unresolvedSymbols: List<KSAnnotated> = emptyList()
    val annotationName = FragmentFactory::class.qualifiedName

    if (annotationName != null) {
      val resolved = resolver
        .getSymbolsWithAnnotation(annotationName)
        .toList()     // 1
      val validatedSymbols = resolved.filter { it.validate() }.toList()     // 2
      validatedSymbols
        .filter {
          //TODO: add more validations
          true
        }
        .forEach {
          //TODO: visit and process this symbol
        }     // 3
      unresolvedSymbols = resolved - validatedSymbols     //4
    }
    return unresolvedSymbols
  }

Here’s a summary of the code above:

  1. The getSymbolsWithAnnotation fetches all the symbols annotated with the FragmentFactory annotation. You can also use getClassDeclarationByName, getDeclarationsFromPackage when your processor relies on logic outside annotation targets.
  2. Here you use the default validate function offered by KSP to filter symbols in the scope that can be resolved. This is done internally using a KSValidateVisitor that visits each declaration and resolves all type parameters.
  3. This statement attempts to process each of the valid symbols for the current round. You’ll add the processing code in a bit, but for now, the placeholder comments will do the job.
  4. Finally, you return all the unresolved symbols that would need more rounds. In the current example, this would be an empty list because all the symbols should resolve in the first round.

Your class will now look similar to this:

Processor filtering skeleton code

Validating Symbols

KSP offers a validator out of the box that ensures a symbol is resolvable. However, as a common use case, you’ll need to validate the inputs of your annotation.

Start by creating a file called SymbolValidator.kt in the com.yourcompany.fragmentfactory.processor.validator package.

add validator file

Now add the following code so you’ve got a validator ready to go:

package com.yourcompany.fragmentfactory.processor.validator

import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.validate
import com.yourcompany.fragmentfactory.annotation.FragmentFactory

class SymbolValidator(private val logger: KSPLogger) {

  fun isValid(symbol: KSAnnotated): Boolean {
    return symbol is KSClassDeclaration //1
        && symbol.validate() //2
  }

}

The validator exposes an isValid function that

  1. Check whether the symbol was annotated on a class.
  2. Ensures it’s resolvable using the default validator that KSP provides. You’ll soon update this with more checks.

Adding Hierarchy Checks

One of the first validations would be to check whether the annotation target should be a Fragment. Another would be to ensure that the bundled class is a Parcelable. Both these conditions require you to verify the hierarchy.

So add an extension function for it in the SymbolValidator class:


private fun KSClassDeclaration.isSubclassOf(
    superClassName: String, //1
): Boolean {
    val superClasses = superTypes.toMutableList() //2
    while (superClasses.isNotEmpty()) { //3
      val current = superClasses.first()
      val declaration = current.resolve().declaration //4
      when {
        declaration is KSClassDeclaration 
         && declaration.qualifiedName?.asString() == superClassName -> { //5
          return true
        }
        declaration is KSClassDeclaration -> { 
          superClasses.removeAt(0) //6
          superClasses.addAll(0, declaration.superTypes.toList())
        }
        else -> { 
          superClasses.removeAt(0) //7
        }
      }
    }
    return false //8
  }

That seems like a lot of code, but what it’s doing is straight-forward:

  1. The function accepts a fully qualified class name as superClassName.
  2. This statement retrieves all the superclasses of the current class.
  3. You initiate a loop that exits when there are no superclasses to process.
  4. This resolves a class declaration. In KSP, resolving a symbol retrieves more qualified data about it. This is a costly affair, so it’s always done explicitly.
  5. This checks whether the first superclass’s fully qualified name matches. If so, it exits and returns true.
  6. If it doesn’t match and it’s another class, you remove the current class from the list and add the supertypes of that to the current list of supertypes.
  7. If it’s not a class, then remove the current class from the list.
  8. The code terminates and returns false when it’s traveled to the top of the class hierarchy and there are no matches.

Add a function to retrieve FragmentFactory annotation from the class declaration token immediately below isSubclassOf function:

private fun KSClassDeclaration.getFactoryAnnotation(): KSAnnotation {
    val annotationKClass = FragmentFactory::class
    return annotations.filter {
      it.annotationType
     .resolve()
     .declaration.qualifiedName?.asString() == annotationKClass.qualifiedName
    }.first()
}

The code above loops through all annotations on the class and finds the one whose qualified name matches that of FragmentFactory.

Validating Annotation Data

Now that you’ve got a way to extract the annotation, it’s time to validate the data tagged with it. You’ll start by verifying whether the class to be bundled is a Parcelized class.
Append the code below right after the getFactoryAnnotation function in SymbolValidator:

private fun KSClassDeclaration.isValidParcelableData(): Boolean {
    val factorAnnotation = getFactoryAnnotation()
    val argumentType = (factorAnnotation.arguments.first().value as? KSType) 
    //1
    val argument = argumentType?.declaration as? KSClassDeclaration
    val androidParcelable = "android.os.Parcelable" //2
    if (argument == null || !argument.isSubclassOf(androidParcelable)) { //3
      logger.error(
         "FragmentFactory parameter must implement $androidParcelable"
      ) //4
      return false
    }
    val parcelKey = (factorAnnotation.arguments[1].value as? String) //5
    if (parcelKey.isNullOrBlank()) { //6
      logger.error("FragmentFactory parcel key cannot be empty")//7
      return false
    }
    return true //8
}

Here’s what this does.

  1. The argumentType stores the type of the first argument passed to the annotation.
  2. You’ll use the qualified class name of Parcelable to check hierarchy.
  3. You check whether the argument passed is a class declaration. Also, check whether it’s a subclass of Parcelable.
  4. If the check fails, you log an error.
  5. You retrieve the parcelKey parameter.
  6. You ensure that this key isn’t empty.
  7. If that fails, log an error notifying that the parcelKey needs to be supplied.
  8. Because all checks pass, you return true.
Note: Logging an error using the KSP Logger terminates processing after the current round. Other SymbolProcessors can still continue with the current round, however once the round is over, KSP terminates all processing.

The last check required is to determine that the annotated class is a Fragment. Add the code below to the end of SymbolValidator class:

private fun KSClassDeclaration.isFragment(): Boolean {
  val androidFragment = "androidx.fragment.app.Fragment"
  return isSubclassOf(androidFragment)
}

This uses a fully qualified name check for the Fragment class.

Phew! That’s a lot of validations. It’s time to collate them. Replace the isValid function defined in the SymbolValidator:

fun isValid(symbol: KSAnnotated): Boolean {
    return symbol is KSClassDeclaration
        && symbol.validate()
        && symbol.isFragment()
        && symbol.isValidParcelableData()
}

Your validator is complete.