Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Idiomatic Code Generation

Idiomatic Code Generation

Writing an annotation processor written in Kotlin that generates Kotlin code.

Avatar for Anthony Restaino

Anthony Restaino

February 01, 2018
Tweet

More Decks by Anthony Restaino

Other Decks in Programming

Transcript

  1. Scenario val TAG = "Pizza" Log.d(TAG, "Hello Pizzeria") Creating log

    tags with the name of the enclosing class is common during development
  2. Hardcode val TAG = "Pizza" Log.d(TAG, "Hello Pizzeria") - Write

    the tags by hand - Won’t survive refactors, tag will remain unchanged - Can be time consuming - Mistakes like spelling errors can occur
  3. Reflection val TAG = Pizza!::class.simpleName Log.d(TAG, "Hello Pizzeria") - Use

    reflection API to get class name at runtime - Survives refactoring - Can’t make spelling mistakes - If you use minimization tools like Proguard, you will lose the constant
  4. Our Goal @Tag class Pizza { fun doLogging() { Log.d(TAG,

    "Hello Pizzeria") } }A The code we will generate
  5. Our Goal val Pizza.TAG: String get() = "Pizza" We will

    generate an extension property. Extension properties cannot be const. We have to create a custom getter.
  6. Setup Annotation (runtime) module Annotation processing module Annotation processing module

    will contain our code generator, our app can’t access this code
  7. Setup implementation project(':project-name') kapt project(':project-name-compiler') Annotation (runtime) module Annotation processing

    module App module App module will rely on the annotation module and will tell kapt to run our code generator
  8. Annotation annotation class Tag - Annotation is a reserved keyword

    - Annotation modifies class keyword - Annotations cannot be instantiated
  9. Annotation @Target(AnnotationTarget.CLASS) annotation class Tag - Annotate annotation with @Target

    - Use CLASS option - Tells compiler to only allow annotation on classes
  10. Annotation @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.CLASS) annotation class Tag @Retention annotation with SOURCE

    option tells compiler to exclude our annotation from compiled source. It is only used during compile time.
  11. Processor class TaggerProcessor : AbstractProcessor() { }A - AbstractProcessor implements

    Processor - Come from javax package - Kotlin annotation processing leverages javax framework - All annotation processors must implement Processor interface
  12. Processor @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { }A Java annotation

    processing framework requires you to put a file in the location “META-INF/services/javax.annotation.processing.Processor” that contains the qualified name (package + class name) of your processor. If you forget to do this, your processor will not run (even though we’re using kapt). Use google’s auto-service library to do this for you (refer to link in references) by annotating your processor with @AutoService and adding the dependency to Gradle (see example project in references).
  13. Processor @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun getSupportedOptions()

    = setOf<String>() }A Can implement this function to support options. We aren’t supporting options yet, so return empty.
  14. Processor @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun getSupportedOptions()

    = setOf<String>() override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) }A We must override this method and return a set containing the name of our annotation. If we don’t return anything, our processor won’t be run.
  15. Processor @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun getSupportedOptions()

    = setOf<String>() override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean }A - Process function is where we generate code - Return true when you are done generating code - Environment parameter represents all the code you are analyzing
  16. @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun getSupportedOptions() =

    setOf<String>() override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean }A
  17. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] }Z - processingEnv is a property we use to get setup params - processingEnv.options provides us with key/value pairs given to our processor - kapt.kotlin.generated option provided automatically by kapt, it’s the output directory
  18. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) }Z We get a list of all elements annotated with @Tag from the project
  19. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) }Z Element can represent a class (TypeElement), a function (ExecutableElement), a field (VariableElement), etc. We have a list of Element, so we need to convert to the proper subtype.
  20. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()AB }A }Z FunSpec is from KotlinPoet. It’s a declarative API for defining a function. We create a getter using the proper builder. We add a single return statement. That return statement returns the name of the annotated class.
  21. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> FunSpec .getterBuilder() .addStatement("return %S","Pizza") .build()AB }A }Z Pizza class gets passed through and we create a function that has the body return “Pizza”
  22. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A }Z We need to create the properties still. PropertySpec is KotlinPoet’s way of creating properties. We are creating an extension property with the type of String. Kotlin poet handles imports for us and keywords so we don’t have to write them ourself. We then add the getter to the property
  23. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("com.food.Pizza.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A }Z When we process the Pizza class, we create the property with the right name
  24. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A .let { properties !-> FileSpec .builder("com.anthonycr.tagger", "Tags") .addProperties(properties) .build()C }B }Z We have to put our properties somewhere. We’ll put them in a file called “Tags.” We are using KotlinPoet again to create a FileSpec with the provided package name and file name.
  25. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A .let { properties !-> FileSpec .builder("com.anthonycr.tagger", "Tags") .addProperties(properties) .build()C }B .writeTo(File(sourceDir)) }Z We need to write our FileSpec to disk. It has a writeTo function that takes a file. We create the file from the option given to us by kapt, which is the output directory.
  26. override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean {

    val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A .let { properties !-> FileSpec .builder("com.anthonycr.tagger", "Tags") .addProperties(properties) .build()C }B .writeTo(File(sourceDir)) }Z The minimal amount of code needed to create these extension properties.
  27. Using Generated Code import com.anthonycr.tagger.TAG @Tag class Pizza { fun

    doLogging() { Log.d(TAG, "Hello Pizzeria") } }A
  28. Annotation Arguments enum class CaseStyle { ALL_CAPS, NORMAL }A @Retention(AnnotationRetention.SOURCE)

    @Target(AnnotationTarget.CLASS) annotation class Tag( val caseStyle: CaseStyle = CaseStyle.NORMAL )A Annotation arguments provide a way to have extra options. Here, we create an option that can convert the tag to UPPERCASE. Annotation arguments MUST have a default value, they cannot be null.
  29. Annotation Arguments val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map

    { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A .let { properties !-> FileSpec .builder("com.anthonycr.tagger", "Tags") .addProperties(properties) .build()C }B .writeTo(File(sourceDir))
  30. Annotation Arguments .map { typeElement !-> val getterSpec = FunSpec

    .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A We need to modify the map function to accommodate the new arguments
  31. Annotation Arguments .map { typeElement !-> val caseStyle = typeElement.getAnnotation(Tag!::class.java).caseStyle

    val returnValue = when (caseStyle) { CaseStyle.NORMAL !-> typeElement.simpleName.toString() CaseStyle.ALL_CAPS !-> typeElement.simpleName.toString().toUpperCase() } val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", returnValue) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A We get the annotation from the TypeElement. Then we get the option. We switch on the option and change the return value based on it.
  32. Annotation Arguments .map { typeElement !-> val caseStyle = typeElement.getAnnotation(Tag!::class.java).caseStyle

    val returnValue = when (caseStyle) { CaseStyle.NORMAL !-> typeElement.simpleName.toString() CaseStyle.ALL_CAPS !-> typeElement.simpleName.toString().toUpperCase() } val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", "Pizza") .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A For @Tag (default option assumed) or @Tag(CaseStyle.NORMAL)
  33. Annotation Arguments .map { typeElement !-> val caseStyle = typeElement.getAnnotation(Tag!::class.java).caseStyle

    val returnValue = when (caseStyle) { CaseStyle.NORMAL !-> typeElement.simpleName.toString() CaseStyle.ALL_CAPS !-> typeElement.simpleName.toString().toUpperCase() } val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", "PIZZA") .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A For @Tag(CaseStyle.ALL_CAPS)
  34. Annotation Arguments .map { typeElement !-> val caseStyle = typeElement.getAnnotation(Tag!::class.java).caseStyle

    val returnValue = when (caseStyle) { CaseStyle.NORMAL !-> typeElement.simpleName.toString() CaseStyle.ALL_CAPS !-> typeElement.simpleName.toString().toUpperCase() } val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", returnValue) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A
  35. Processor Arguments build.gradle kapt { arguments { arg("tagger.package_name", "com.food.sample") }

    } Processor arguments are a way to add global options to an annotation processor. They are added as key/value string pairs in your gradle file as shown here. We’ll create an option that allows us to change the package name of the “Tags” file.
  36. Processor Arguments @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun

    getSupportedOptions() = setOf<String>() override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean }A
  37. Processor Arguments @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun

    getSupportedOptions() = setOf<String>() override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean }A Now the getSupportedOptions function we didn’t use earlier becomes useful.
  38. Processor Arguments @AutoService(Processor!::class) class TaggerProcessor : AbstractProcessor() { override fun

    getSupportedOptions() = setOf("tagger.package_name") override fun getSupportedAnnotationTypes() = setOf(Tag!::class.qualifiedName) override fun process( set: Set<TypeElement>, environment: RoundEnvironment ): Boolean }A We need to return the name of the option. If we don’t, then the framework won’t recognize us as a receiver of the option.
  39. Processor Arguments val sourceDir = processingEnv.options["kapt.kotlin.generated"] environment .getElementsAnnotatedWith(Tag!::class.java) .filterIsInstance(TypeElement!::class.java) .map

    { typeElement !-> val getterSpec = FunSpec .getterBuilder() .addStatement("return %S", typeElement.simpleName) .build()A PropertySpec .builder("${typeElement.qualifiedName}.TAG", String!::class) .mutable(false) .getter(getterSpec) .build()B }A .let { properties !-> FileSpec .builder("com.anthonycr.tagger", "Tags") .addProperties(properties) .build()C }B .writeTo(File(sourceDir))
  40. Processor Arguments val packageName = processingEnv.options .getOrDefault("tagger.package_name", "com.anthonycr.tagger") .let {

    properties !-> FileSpec .builder(packageName, "Tags") .addProperties(properties) .build() }A Similar to how we got the output directory, we get the option from processingEnv. We provide a default value because this option is not required.
  41. Processor Arguments package com.anthonycr.tagger import kotlin.String import com.pizzeria.Pizza val Pizza.TAG:

    String get() = "Pizza" Our package name with no argument provided (default used).
  42. Summary Try it Generate Java with Kotlin Generate Kotlin with

    Kotlin The annotation processor in this talk can be found on Github. If you are new to annotation processing, I suggest you clone the code and compile and run the sample app on your machine. You can use this presentation to follow along. https://github.com/anthonycr/Tagger
  43. Resources My sample annotation processor: https://github.com/anthonycr/ Tagger Kotlin’s sample annotation

    processor: https://github.com/ JetBrains/kotlin-examples/tree/master/gradle/kotlin-code- generation Kapt documentation: https://kotlinlang.org/docs/reference/ kapt.html KotlinPoet: https://github.com/square/kotlinpoet AutoService: https://github.com/google/auto/tree/master/service