How Gradle compiles your build scripts

How Gradle compiles your build scripts

This post is inspired by a discussion on the Gradle community slack. Many thanks to @Vampire and @ephemient for their help understanding all of this

Have you ever tried to do something like this?

val kotlinVersion = "1.7.21"

plugins {
    id("org.jetbrains.kotlin.jvm").version(kotlinVersion)
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
}

Looks pretty neat, right? Your Kotlin version is defined in a single place and it's easy to change it when a new version is release.

If you have tried this, you probably know it doesn't work. You'll get hit pretty quickly by this:

e: build.gradle.kts:4:44: Unresolved reference: kotlinVersion

That's surprising. There are very good reasons for this but they're not obvious at first sight.

Let's dive in.

Plugins and classpaths

Gradle support plugins. Plugins are very handy because they allow you augment your builds. If you're an Android developer, you're certainly familiar with snippets like this:

android {
    compileSdk = 32

    defaultConfig {
        applicationId = "com.example"
        minSdk = 23
        targetSdk = 32
        versionCode = 1
        versionName = "1.0"
    }
}

Looking a bit closer, this is all compiled and typesafe. Gradle knows compileSdk is a var Int, same for minSdk, targetSdk and others.

This means that Gradle knows about com.android.build.api.dsl.CommonExtension the class that defines compileSdk. Since Gradle cannot put the whole world on the build script classpath, this has to be conveyed somehow. This is what plugins {} do.

Multiple passes of compilation

Gradle parses your build.gradle.kts file and extracts the plugins {} block. It does so using the same KotlinLexer that the Kotlin compiler uses. Once the plugins {} block extracted, Gradle compiles it and runs it. This is also the moment when generated accessors are ... well... generated!

So after the plugins {} block is evaluated, Gradle has:

  • the plugins used by the script and their matching jar
  • generated accessors

In a second pass, it can compile and evaluate the script, with all the plugin jars on the classpath. It all makes sense!

This first pass is why the syntax of the plugins {} block is so constrained.

It's not only plugins {}

plugins {} is the most used block but this also applies to other blocks:

  • buildscript {}
  • pluginManagement {}
  • iniscript {}

If you bump into errors, double check what block you're in. Chances are that your code is evaluated in a separate context.

What's working

Once we know that, how do we make the above work? In general, I'm not 100% sure what syntax is allowed or not in these blocks. The good news is that there are multiple solutions to define your Kotlin version in a single place (or do other things).

The most straightforward is to use version catalogs:

// libs.versions.toml
[versions]
kotlin = "1.7.21"

[libraries]
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }

[plugins]
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
// build.gradle.kts
plugins {
    alias(libs.plugins.kotlin)
}

dependencies {
    implementation(libs.kotlin.stdlib)
}

There are other solutions using pluginManagement and Gradle properties. Or you can build your own!

In all cases, I hope this post helped you understand how Gradle processes your build script. It's nothing magical!