How Gradle Works Part 3 - Build Script

Previously on How Gradle Works:

  1. How Gradle Part 1 - Startup
  2. How Gradle Part 2 - Inside The Daemon

This is the third blog of the series How Gradle Works. In this blog we’ll explain what happens during build script execution.

Kotlin & Groovy DSL

If you are a Java developer, when you open any Gradle build script (for example build.gradle.kts or build.gradle), the first thing that might confuse you is the special syntax of curly braces:

// Kotlin DSL:

plugins {
    id("some.plugin") version "0.0.1"
}

// or Groovy DSL:

plugins {
    id "some.plugin" version "0.0.1"
}

What’s this? What happens when Gradle executes these kinds of scripts?

The short answer is: they are DSLs (Domain Specific Language) on top of the Kotlin programming language for .gradle.kts files, or on top of the Groovy programming language for .gradle files. These DSLs have some implicit rules, making them appear very confusing.

Implicit Rule 1: Lambda/Closure

First of all, { ... } is a special object in both Groovy and Kotlin. This object is called a lambda in Kotlin, or a closure in Groovy. They’re similar to function objects in other programming language, like Java’s lambda, or JavaScript’s function object.

You can think of plugins { ... } as a method invocation in which a Kotlin lambda object or Groovy closure object is passed as the argument, because both Groovy and Kotlin allow you to omit parentheses:

plugins(function() {
    ...
})

Also, there is one noteworthy DSL: in Kotlin/Groovy, if the last parameter of a function is a lambda/closure, it’s allowed to be put outside of parentheses. For example, the following code snippet:

tasks.register("myTask") {
    ...
    doLast {
        ...
    }
}

is equivalent to:

tasks.register("myTask", function() {
    ...
    doLast(function() {
        ...
    })
})

The code inside the function may be executed immediately or later, depending on the implementation of the specific method.

Implicit Rule 2: Chained Method Invocation

In the plugins { } example above, the Kotlin version: id("some.plugin") version "0.0.1" and the Groovy version: id "some.plugin" version "0.0.1" are both equivalent to the chained method invocation id("some.plugin").version("0.0.1").

Wait, why?

Because the version in id("some.plugin") version "0.0.1" is actually an infix function in Kotlin, which is defined here, and id "some.plugin" version "0.0.1" is “command chains” in Groovy.

We won’t explain all the implicit rules in Groovy and Kotlin DSLs (as that would require another full blog series :-P), you should simply understand that they are mapped to Gradle API methods in some ways. See Kotlin DSL Primer and Groovy DSL Primer.

But what is this of the method invocation id("some.plugin")?

The code inside the function is executed against a this object, which is called a “receiver” in Kotlin lambda, or a “delegate” in Groovy closure. Gradle determines the correct this object and invokes the method against that this object. In this example, the this object is of type PluginDependenciesSpec.

Build Script Execution

Once we have unveiled all the mechanics of the DSL, a Gradle build script is simply a few Gradle API calls built on top of the DSLs. Like almost all other programming languages, Gradle executes the build script line by line, top to bottom.

Of course, the build script must be compiled into bytecode before being executed in the JVM. Gradle does this transparently, giving the impression that the build script is being interpreted and executed.

External Dependencies in Build Script

Consider the following build script:

// build.gradle.kts
import com.android.build.gradle.tasks.LintGlobalTask

plugins {
    id("com.android.application") version "7.4.0"
}

tasks.withType<LintGlobalTask>().configureEach {
    ...
}

How can this build script be compiled without specifying the dependency of com.android.build.gradle.tasks.LintGlobalTask? You may say, “doesn’t it come from the plugins { } block below?”

But remember, to execute the plugins { } block, the build script must be compiled first. Now it’s a chicken-and-egg issue: to get the dependency of LintGlobalTask, we must compile and run the build script, but to compile the build script, we must get the dependency of LintGlobalTask.

Gradle handles plugins { } specially as follows:

  1. The plugins block is extracted and executed first;
  2. The resolved dependencies are added to the whole build script’s classpath;
  3. The build script is compiled and executed.

A similar thing happens to buildscript { } block, too: you can explicitly specify the dependencies for build script compilation and execution. In this way, you can leverage the many available libraries in the JVM ecosystem to empower your build script.

What’s Next

The build script invokes Gradle APIs to configure the build, where amazing things happen. It’s possible to pack the build script into a Gradle plugin for better reusability and performance.

In the next blog of the series, we’ll explain what happens under the hood of Gradle plugins.