Compile-time value preprocessed into Kotlin code from Gradle

Viewed 423

I am trying to get some values from a Gradle build into some Kotlin code.

For example, if my code was:

const val compileDate = THE_DATE_THAT_THE_CODE_WAS_COMPILED_ON;
const val compileBranch = THE_GIT_BRANCH_THAT_THE_CODE_WAS_COMPILED_FROM;
fun main() {
    println("$compileDate - $compileBranch")
}

Is there any way to get those constants embedded into the code from the build itself?

If its not possible in that manner, I would think it might be possible using annotations, but again - I don't know how.

Any help would be appreciated.

Thank you.

2 Answers

Using annotations

So I found a solution by writing an annotation processor. Here's a fairly minimal working example:

./annotations/src/main/kotlin/net/example/CompileTimeValue.kt:

package net.example

@Retention(AnnotationRetention.SOURCE)
@MustBeDocumented
annotation class CompileTimeValue(val of : String)

./annotations/build.gradle.kts:

plugins {
    kotlin("jvm")
}

./processor/src/main/kotlin/net/example/CompileTimeEvaluator.kt:

package net.example

import com.google.auto.service.AutoService
import java.io.File
import java.io.IOException
import javax.annotation.processing.*
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.TypeElement

@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedOptions(CompileTimeEvaluator.KAPT_OPTION)
class CompileTimeEvaluator : AbstractProcessor() {
    private val assignments = mutableMapOf<Element, String?>()

    override fun getSupportedAnnotationTypes(): MutableSet<String> =
        mutableSetOf(CompileTimeValue::class.java.name)

    override fun getSupportedSourceVersion(): SourceVersion =
        SourceVersion.latest()

    override fun process(set: MutableSet<out TypeElement>?, env: RoundEnvironment?): Boolean {
        env?.getElementsAnnotatedWith(CompileTimeValue::class.java)?.forEach {
            val note = it.getAnnotation(CompileTimeValue::class.java) as CompileTimeValue

            assignments[it] = outputOfCommand(note)
        }

        val kaptGeneratedDir = processingEnv.options[KAPT_OPTION]
        val file = File(kaptGeneratedDir, "ComputedConstants.kt")

        file.writeText(generatedClass())

        return true
    }

    private fun generatedClass() : String = """
        object ComputedConstants {
            fun load() {
                ${loadAssignments()}
            }
        }
    """.trimIndent()

    private fun loadAssignments() : String =
        assignments.entries.joinToString("\n") {
            val element = it.key
            val value = it.value

            val elementName = element.simpleName.toString()
            // TODO: I think a recursive solution to find the true parent class
            // is necessary here to deal with nested classes
            val elementNest = element.enclosingElement.simpleName.toString()
            val elementPack = processingEnv.elementUtils.getPackageOf(element)

            val fullName = "$elementPack.$elementNest.$elementName"

            if (value != null)
                "$fullName = \"\"\"$value\"\"\""
            else
                "$fullName = null"
        }

    private fun outputOfCommand(note : CompileTimeValue) : String? = try {
        val process = ProcessBuilder(*note.of.split(' ').toTypedArray())
            .redirectOutput(ProcessBuilder.Redirect.PIPE)
            .redirectError(ProcessBuilder.Redirect.PIPE)
            .start()

        process.waitFor()
        process.inputStream.bufferedReader().readText().trimEnd('\n')
    } catch (e : IOException) {
        null
    }

    companion object {
        const val KAPT_OPTION = "kapt.kotlin.generated"
    }
}

./processor/build.gradle.kts:

plugins {
    kotlin("jvm")
    kotlin("kapt")
}

kapt {
    correctErrorTypes = true
}

dependencies {
    "kapt"(project(":annotations"))
    compileOnly(project(":annotations"))
    implementation(fileTree("libs").include("*.jar"))
    implementation("com.google.auto.service:auto-service:1.0-rc6")
    annotationProcessor("com.google.auto.service:auto-service:1.0-rc6")
}

./example/src/main/kotlin/net/example/Example.kt:

package net.example

object Constants {
    @field:CompileTimeValue("date")
    var compileDate : String? = "Unknown"
}

fun main() {
    ComputedConstants.load()
    println("Compiled on: ${Constants.compileDate}")
}

./example/build.gradle.kts:

plugins {
    kotlin("jvm")
    kotlin("kapt")
    application
}

application {
    mainClass.set("net.example.ExampleKt")
}

dependencies {
    implementation(project(":annotations"))
    "kapt"(project(":annotations"))
    "kapt"(project(":processor"))
}

./example/build/generated/source/kaptKotlin/main/ComputedConstants.kt:

object ComputedConstants {
    fun load() {
        net.example.Constants.compileDate = """Sat 27 Mar 2021 01:30:46 PM EDT"""
    }
}

./build.gradle.kts:

plugins {
    // apply(false) so that it can be re-used in children
    kotlin("jvm") version "1.4.31" apply(false)
    kotlin("kapt") version "1.4.31" apply(false)
}

allprojects {
    repositories {
        jcenter()
        mavenCentral()
        maven { url = uri("https://dl.bintray.com/kotlin/kotlin-js-wrappers") }
        maven { url = uri("https://dl.bintray.com/kotlin/kotlinx") }
        maven { url = uri("https://dl.bintray.com/kotlin/ktor") }
    }
}

./settings.gradle.kts:

include(":example")
include(":annotations")
include(":processor")

Another quick way is to use the gradle copy task to inflate a kotlin source template.

  • Create a kotlin template file App.kt some where in the source tree (src/main/templates/App.kt)

    object App {
       const val VERSION = "$version"
       const val BASE_VERSION = "$base_version"
    }
    
  • Create a copy task in your build file

    val copyTemplates by registering(Copy::class) {
        filteringCharset = "UTF-8"
        from(project.projectDir.resolve("src/main/templates"))
        into(project.buildDir.resolve("generated-sources/templates/kotlin/main"))
    }
    
  • Add the generated template to the main sourceset

    sourceSets {
      main {
        java.srcDirs(copyTemplates)
       }
    }
    
  • Now you can access App as regular kotlin object from your classes

    fun main() {
      println("Version is : ${App.VERSION}")
    }
    
Related