Quick Start¶
How to write a plugin¶
Here we are going to learn how to write a toy build plugin in the Kotlin Toolchain that exposes some external build‑time data to the application by generating sources.
Our plugin should be able to parse a .properties file and generate Kotlin properties out of it.
Later we may implement additional features.
We will name our plugin build-config.
The sources of the plugin are available in the amper-plugins-tutorial repository on GitHub. You can clone it, checkout the initial revision, and follow the Git log to see the changes made step by step.
Basic example¶
Let's start with the following project structure:
<root>/
├─ app/
│ ╰─ module.yaml
├─ build-config/
│ ├─ src/
│ ├─ module.yaml
│ ╰─ plugin.yaml
├─ utils/
│ ╰─ module.yaml
├─ ... #(1)!
╰─ project.yaml
- Other project modules
The project.yaml looks like this:
modules:
- app #(1)!
- build-config #(2)!
- utils #(3)!
- ... #(4)!
plugins: #(5)!
- ./build-config
- Regular module, e.g.,
jvm/app - Our plugin is also a normal module and needs to be listed here
- Regular module, e.g.,
jvm/libthat contains generic utilities useful for most modules in the project - There may be other project modules
- This is a block where we list our plugin dependencies to register in the project.
And the build-config/module.yaml looks like this:
product: jvm/amper-plugin
This is already a valid (although incomplete) Kotlin Toolchain plugin.
It has a plugin ID which defaults to the plugin module name (build-config).
The plugin ID is the string used to refer to the plugin throughout the project, e.g., to enable/configure it.
Declaring the plugin in the plugins section of project.yaml is called registering the plugin, and makes it
available to the project, but it is not yet enabled in (applied to) any of its modules.
Learn more about it here.
But it doesn't contain anything useful yet.
Let's start implementing our plugin by writing a task action that would do the source generation based on the contents of the properties file.
package com.example
import org.jetbrains.amper.plugins.*
import java.nio.file.Path
import java.util.*
import kotlin.io.path.*
@TaskAction
@OptIn(ExperimentalPathApi::class)
fun generateSources(
@Input propertiesFile: Path,
@Output generatedSourceDir: Path,
) {
// clean the old state if any is present from the previous invocation
generatedSourceDir.deleteRecursively()
val outputFile = generatedSourceDir / "properties.kt"
if (!propertiesFile.isRegularFile()) {
error("The file $propertiesFile does not exist")//(1)!
}
println("Generating sources")//(2)!
val properties = propertiesFile.bufferedReader().use { reader ->
Properties().apply { load(reader) }
}.toMap()
// need to ensure the output directory structure exists:
// the Kotlin Toolchain doesn't pre-create it for us
outputFile.createParentDirectories()
val code = buildString {
appendLine("package com.example.generated")
appendLine("public object Config {")
for ((key, value) in properties) {
appendLine(" const val `$key`: String = \"$value\"")
}
appendLine("}")
}
outputFile.writeText(code)
}
- Throwing an exception causes the task to fail
- Simple logging (structured logging support comes later)
The code can be written in any Kotlin file in any package – there's no convention here.
@TaskAction is a marker for a top-level Kotlin function that can be registered as a task.
@Input/@Output are marker annotations required for Path‑referencing action parameters to tell the Kotlin Toolchain how to treat these paths.
Info
The Kotlin Toolchain automatically uses task execution avoidance based on the contents of @Input/@Output-annotated paths.
Declaring a task action does nothing by itself yet.
The task with the action must be registered explicitly to become available in modules the plugin is enabled in.
To do that, we need a special file to register tasks and define how they use the plugin's configuration – plugin.yaml:
tasks:
generate: # (1)!
action: !com.example.generateSources
propertiesFile: ${module.rootDir}/config.properties # (2)!
generatedSourceDir: ${taskOutputDir}
- Registers the
generatetask - Specifies the conventional location for the source .properties file
Note that the task action's type is specified using the type tag — !com.example.generateSources — using the fully qualified function name.
As we see here, the plugin.yaml file allows Kotlin Toolchain references with the syntax ${foo.bar.baz}.
Here we use the built‑in reference‑only property taskOutputDir to direct our output to the unique task‑associated output directory that the Kotlin Toolchain provides for us.
And module.rootDir is the directory of the module the plugin is enabled in.
Learn more about Kotlin Toolchain-provided reference-only properties.
But we need to make the Kotlin Toolchain aware that our output is, in fact, generated Kotlin sources,
so the build tool can include them in the compilation, IDE can resolve symbols from them, etc.
To do that, we'll add a generated.sources block to our plugin.yaml:
tasks:
generate:
action: !com.example.generateSources
propertiesFile: ${module.rootDir}/config.properties
generatedSourceDir: ${taskOutputDir}
generated:
sources:
- language: kotlin # (1)!
directory: ${tasks.generate.action.generatedSourceDir}
javais also possible here; for resources, usegenerated.resourcesinstead
We've added an entry in generated.sources block, where we reference our generatedSourceDir path and state
that Kotlin sources will be located there after the task is run.
That's it with the plugin for now! Let's enable it in one of our modules (app):
plugins:
build-config: enabled # (1)!
# ... Other things, like settings, dependencies, etc.
<plugin-id>: enabledis a shorthand;<plugin-id>: { enabled: true }is the full form
If we now run the build, it will fail with an error from our generateSources task:
ERROR: Task ':app:generate@build-config' failed: java.lang.IllegalStateException: The file /path/to/project/app/config.properties does not exist
at com.example.GenerateSourcesKt.generateSources(generateSources.kt:19)
That's because we haven't created the config.properties file yet, and the code of the task checks for that.
Let's fix it and create the file. As declared in the plugin.yaml above, the file is expected in
${module.rootDir}/config.properties:
tasks:
generate:
action: !com.example.generateSources
propertiesFile: ${module.rootDir}/config.properties
generatedSourceDir: ${taskOutputDir}
# ...the rest is omitted for brevity
In our case, it is <root>/app/config.properties. Let's create it with the following content:
APP_NAME=My Cool App
If we run the build again, we'll see that our generated com.example.Config object is present and is visible in the IDE,
and "Generating sources" is being logged to the console.
Now let's explore what else we can enhance about our plugin:
- Let's use a third-party library to generate Kotlin code instead of doing it manually.
- Our plugin should also accept values directly from the user configuration in their
module.yaml, in addition to taking them from the properties file. - Let's introduce a toy task that just prints all the generated sources to the stdout.
Adding library dependencies¶
We often don't implement a plugin from scratch but rather use the existing tool or a library and wrap around it.
The Kotlin Toolchain plugins, being normal Kotlin modules, can depend on other modules and/or external libraries.
Let's use the kotlin-poet library to make our Kotlin code generation more robust and convenient.
In addition to that, let's assume we have a utils module in the project.
This module is a collection of some utilities that are used across the project – we'd like to use them in our plugin
implementation as well.
product: jvm/amper-plugin
dependencies:
- com.squareup:kotlinpoet:2.2.0 # (1)!
- ../utils # (2)!
- Plugins support external Maven dependencies.
- Plugins support depending on another local module, unless this introduces a dependency cycle, see below.
(For the sake of brevity, we are not going to list the code written with kotlin‑poet APIs here,
as the exact code is largely irrelevant in our example.)
Info: no meta‑build in the Kotlin Toolchain — plugins can depend on regular modules
The Kotlin Toolchain doesn't have a notion of a meta‑build (e.g., "included builds"/buildSrc, etc.).
Plugin modules are built inside the same build as the other "production" modules.
This way, plugins can easily depend on any other project modules (like utils in our example),
as long as there are no physical cyclic dependencies between internal actions.
Example: Self‑documenting
A documentation plugin can technically be safely applied to itself (enabled in its own module), because when the documentation generation runs, the plugin's code itself can already be built and can be executed in a task to generate the docs for itself.
Example: Can't generate resources for itself
If a plugin contributes anything to the compilation, it can't be applied to itself, because the cyclic dependency is detected:
1. task `generateSources` in module `my-plugin` from plugin `my-plugin` (*)
╰───> depends on the compilation of its source code
2. compilation of module `my-plugin` <───────────────╯
╰───> needs sources from ──────────────────╮
3. source generation for module `my-plugin` <─╯
╰───> includes the directory `<project-build-dir>/tasks/_my-plugin_generateSources@my-plugin` generated by
4. task `generateSources` in module `my-plugin` from plugin `my-plugin` (*) <───────────────────────────────╯
Warning
Currently, Kotlin Toolchain plugins can't depend on other plugins meaningfully, other than to share some implementation pieces. This is not recommended anyway – use common utility modules instead.
Adding plugin settings¶
Until now, our plugin just used the fixed values/paths, hardcoded within the plugin, with no ability to change them on the module level. Here we'll describe a way to "parameterize" the plugin, so users can configure its behavior.
Suppose we want the user to be able to:
- customize the properties file name
- provide additional properties values
Let's whip up our public plugin settings definition:
package com.example
import org.jetbrains.amper.plugins.Configurable
@Configurable
interface Settings {
/**
* Properties file name (without extension)
* that is located in the module root.
*/
val propertiesFileName: String get() = "config"
/**
* Extra properties to generate in addition to the ones read from the
* properties file.
*/
val additionalConfig: Map<String, String> get() = emptyMap()
}
Let's be nice and use KDocs!
The provided KDocs are going to be visible in the IDE in the tooltips for plugin settings in module.yaml.
Such an interface acts like a YAML schema to describe the configuration our plugin may receive from the user.
For that we need the @Configurable-annotated public interface with the properties of configurable types and
optional default values, expressed as default getter implementations.
Now we need to tell the Kotlin Toolchain which of our @Configurable declarations
is the root of the plugin settings that users can configure in their module files.
In our case, it's com.example.Settings:
product: jvm/amper-plugin
dependencies: # ...
pluginInfo:
settingsClass: com.example.Settings
Tip
It is good practice to provide reasonable defaults for all the plugin settings if possible,
so the user still can use the plugin right away by simply having written build-config: enabled.
This way, we can now configure our plugin in the app's module.yaml:
plugins:
build-config:
enabled: true # (1)!
propertiesFileName: "konfig" # (2)!
additionalConfig:
VERSION: "1.0"
- We still need to enable our plugin explicitly.
- Overrides the default "config" value from Kotlin.
Warning
It is not yet possible to use references (${...}) in module.yaml files or access the module configuration tree from plugin.yaml.
We are planning on supporting this in some quality in the following releases.
But wait! We've added the plugin settings and even used them to customize the plugin behavior. But we haven't wired them to our task! Let's fix that.
First on the Kotlin side:
// ...
@TaskAction
fun generateSources(
@Input propertiesFile: Path,
@Output generatedSourceDir: Path,
additionalConfig: Map<String, String>, //(1)!
) {
// ...
// don't forget to process properties passed via the additionalConfig parameter
// ...
}
additionalConfig: Map<String, String>parameter does not require an@Inputannotation, because all "plain data" (no references toPathwithin the type) parameters are already considered as task inputs.
And on the "declarative" side:
tasks:
generate:
action: !com.example.generateSources
propertiesFile:
${module.rootDir}/${pluginSettings.propertiesFileName}.properties
additionalConfig: ${pluginSettings.additionalConfig}
generatedSourceDir: ${taskOutputDir}
generated:
sources:
- language: kotlin
directory: ${tasks.generate.action.generatedSourceDir}
pluginSettings is a global reference-only property that contains the configured plugin settings for each module the plugin is enabled in.
In our case the type of pluginSettings would be com.example.Settings which we specified in pluginInfo.settingsClass.
So, e.g., when the plugin is enabled in the app module in our example when we refer to the ${pluginSettings.propertiesFileName} in plugin.yaml,
we would get the "konfig" value the user specified in their plugins.build-config.propertiesFileName in app/module.yaml
Tip
Remember to rename the file config.properties to konfig.properties.
Adding another task¶
As planned, let's now add another task to the plugin that simply reads the already generated sources and prints them to stdout.
package com.example
// import ...
@TaskAction
fun printSources(
@Input sourceDir: Path,
) {
sourceDir.walk().forEach { file ->
println(file.pathString)
println(file.readText())
}
}
tasks:
generate:
action: !com.example.generateSources
# ...
generatedSourceDir: ${taskOutputDir}
print:
action: !com.example.printSources
sourceDir: ${generate.action.generatedSourceDir}
In the line sourceDir: ${generate.action.generatedSourceDir} we reference an @Output path of another task.
In addition to automatic execution avoidance for individual tasks, the Kotlin Toolchain automatically infers task dependencies based on matching @Input paths with @Output paths.
In our example it means that if the task generate has a path /generated/sources in the @Output position, and the task print has the matching path in the @Input position, then print will depend on the generate.
More on task dependencies here.
Info
If a task has no declared @Outputs (like print in our example), then no execution avoidance is done for it — it will always run the action.
This is done because tasks without outputs are almost always introduced for side effects, e.g., diagnostics or deployment.
Now we've added the print task, we'd like to use it.
Unlike the generate task, it doesn't declare any outputs that are contributed back to the build, so it won't be executed automatically when build/test/run is invoked.
So, to run a task manually, one must use the following CLI command:
./amper task :app:print@build-config
That's it for this tutorial! You can study some specific topics about Kotlin Toolchain plugins and/or go try to write one yourself.
Learn more¶
Consuming things from the Kotlin Toolchain build
See the dedicated documentation section with examples.
Tip
There are plugins that we ourselves have implemented and are already using in the Kotlin Toolchain. Feel free to take a look!
- Protobuf
- Binary Compatibility Validator
- a couple of purely internal ones, like
amper-distribution
If you haven't already, check the more detailed reference on the specific topics: