Writing Your Second Kotlin Compiler Plugin, Part 1 — Project Setup
At the time of writing this article, Kotlin compatibility for IR backend is in Alpha status and the compiler plugin API is Experimental. As such, information contained in this article about IR and compiler plugins could be out-of-date or incorrect. If official documentation exists, please refer to it first.
I’ve been wanting to write this series of blog posts for a while. Inspired By Kevin Most’s 2018 KotlinConf talk — Writing Your First Kotlin Compiler Plugin — I wanted to show how we could write our second compiler plugin, something IR based that works on all Kotlin targets.
In this first part we will go over the project setup required to build a compiler plugin. In later parts we will explore navigating and transforming Kotlin IR.
- Part 1 — Project Setup
- Part 2 — Inspecting Kotlin IR
- Part 3 — Navigating Kotlin IR
- Part 4 — Building Kotlin IR
- Part 5 — Transforming Kotlin IR
- Part 6 — Support Libraries, Publishing, and Integration Testing
Setup
There are a number pieces to a Kotlin compiler plugin. At least 3 modules are required if you want to support all Kotlin targets:
- Gradle plugin which adds your plugin to Kotlin compiler classpath,
- Kotlin compiler plugin for non-native Kotlin platforms,
- Kotlin compiler plugin for Kotlin/Native platform,
Kotlin/Native requires a separate compiler plugin because certain classes used by the Kotlin compiler have a different package on Kotlin/Native. We’ll explore this oddity more in-depth later.
Gradle Plugin
The Gradle plugin part of a Kotlin compiler plugin project is responsible for defining a few things:
- Artifact coordinates of the Kotlin compiler plugin,
- ID string of the Kotlin compiler plugin,
- Translation of Gradle configuration to command line options.
The artifact coordinates are used to download the correct compiler plugin artifact from MavenCentral (or other location) to include on the compiler plugin classpath. The compiler plugin ID string is used to separate command line options by plugin to avoid conflicting options between multiple plugins.
The artifact coordinates and ID we’ll define with Gradle using the buildconfig plugin. Since the artifact coordinates — group, name, and version — are already defined by Gradle and the Kotlin compiler plugin ID will be the Gradle plugin ID, this will make things consistent when everything is published.
These buildconfig values are used by a KotlinCompilerPluginSupportPlugin
implementation which is responsible for bridging between Gradle and the Kotlin compiler.
Any custom Gradle properties defined in a Gradle extension are translated to Kotlin compiler arguments using the applyToCompilation
function. These will be read by our Kotlin compiler plugin which is next!
Kotlin Plugin
A Kotlin compiler plugin starts with a ComponentRegistrar
and CommandLineProcessor
implementation. Both of these classes are loaded by the Kotlin compiler using a ServiceLoader so we will use the auto-service
annotation processer to automatically generate the required files.
The CommandLineProcessor
is pretty easy to understand: It defines the Kotlin compiler plugin ID string (same as in Gradle plugin) and expected command line options. The processor is also responsible for parsing the command line options of the plugin and placing them in a CompilerConfiguration
. It should read and process the same values defined by the Gradle plugin.
The ComponentRegistrar
is where the actual work of a compiler plugin actually begins. The registrar is responsible for registering extension components with the project being compiled. There are a number of extension points currently available in the Kotlin compiler, but the one we will be focusing on is IrGenerationExtension
, which allows for navigating and transforming Kotlin IR.
An instance of IrGenerationExtension
can be created and registered within a ComponentRegistrar
. This instance will be called during compilation when IR code needs to be generated (or transformed). The entry point for this extension provides an IrModuleFragment
and IrPluginContext
which provide everything we need to navigate and transform the module IR code.
We’ll be going more into how to use the provided IrModuleFragment
and IrPluginContext
values in the next parts of this series. But next we need to talk about how Kotlin/Native is different.
Kotlin Plugin — Native
You may have noticed that the Gradle plugin defines a separate coordinate for the Kotlin/Native compiler plugin artifact. This is because Kotlin/Native compiler plugins are compiled with a different dependency than other compiler plugins. Kotlin/Native compiler plugins use the org.jetbrains.kotlin:kotlin-compiler
artifact while plugins for every other platform use the org.jetbrains.kotlin:kotlin-compiler-embedded
artifact.
The only difference I have found between these artifacts is the embedded artifact shadows some classes so their package is different. Other then that, the code between these plugins can be exactly the same. To share code, I use Gradle to copy the code of the non-Kotlin/Native compiler plugin over to the Kotlin/Native compiler plugin module and change the import statements as required.
This isn’t ideal, but I’m sure this oddity will be resolved as Kotlin/Native and compiler plugins mature.
Kotlin Plugin — Testing
Testing Kotlin/JVM compiler plugins is really easy thanks to the kotlin-compile-testing
library! This library allows you to compile Kotlin source strings with your ComponentRegistrar
in tests which makes debugging easy. The resulting compiled files can even be loaded via a ClassLoader if you want to execute them which we will discuss later.
If you execute the tests you should see the following output present in the log.
i: Argument 'string' = Hello, World!
i: Argument 'file' = file.txt
And with that, congrats! You now know how to set up a Kotlin compiler plugin project and are ready to start navigating and transforming IR code! If you want to avoid the manual work of setting all of this up, you can use the GitHub template I created when writing this article.