kotlin-tooling-kotlin-toolchain-plugin-authoring

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Kotlin Toolchain Plugin Authoring

Kotlin Toolchain 插件开发

Local plugins are the official escape hatch from declarative YAML: a
jvm/amper-plugin
module shipping task actions, settings, and generated sources/resources alongside your project. Code patterns to adapt are in references/examples.md.
本地插件是声明式YAML的官方逃逸通道:它是一个
jvm/amper-plugin
模块,随项目一起提供任务动作、配置项以及生成的源码/资源。可复用的代码模式见references/examples.md。

When to write a plugin

何时编写插件

Write one when you need:
  • A build-time step a library's workflow expects (code generation, schema compilation, resource transformation, version stamping).
  • Custom verification wired into the build (pre-release checks, schema validation, contract tests).
  • A build-time value published into the JAR classpath or downstream tasks — the closest analog to Gradle's
    project.version
    .
  • A named CLI command for a repeated workflow (
    ./kotlin do release
    ).
Don't write one when
module.yaml
already covers it (dependencies, JDK provisioning, source layouts, basic packaging), and never to reuse a Gradle plugin — the Kotlin Toolchain cannot consume them.
当你需要以下能力时可以编写插件:
  • 库工作流所需的构建时步骤(代码生成、schema编译、资源转换、版本打标)。
  • 嵌入构建流程的自定义校验(发布前检查、schema验证、契约测试)。
  • 发布到JAR类路径或下游任务的构建时值——最接近Gradle的
    project.version
    的能力。
  • 用于重复工作流的命名CLI命令(
    ./kotlin do release
    )。
如果
module.yaml
已经覆盖了相关需求(依赖管理、JDK预置、源码布局、基础打包),则不需要编写插件;另外绝对不要为了复用Gradle插件而编写本插件——Kotlin Toolchain无法使用Gradle插件。

Layout

目录结构

repo-root/
├── kotlin, kotlin.bat            # wrappers (from `kotlin init`)
├── project.yaml                  # registers the plugin
├── plugins/<name>/
│   ├── module.yaml               # product: jvm/amper-plugin
│   ├── plugin.yaml               # tasks: + commands: + generated:
│   └── src/
│       ├── Settings.kt           # @Configurable interface
│       ├── tasks/                # one @TaskAction per file
│       │   ├── Foo.kt
│       │   └── FooSteps.kt       # internal shared helpers (not @TaskAction)
│       └── <domain logic>/
└── <consumer-module>/
    └── module.yaml               # plugins: { <name>: enabled: true, ... }
Keep at least one consumer module in the repo — it is the only way to exercise the plugin end-to-end, and plugins cannot be published to any public registry yet.
repo-root/
├── kotlin, kotlin.bat            # wrappers (from `kotlin init`)
├── project.yaml                  # registers the plugin
├── plugins/<name>/
│   ├── module.yaml               # product: jvm/amper-plugin
│   ├── plugin.yaml               # tasks: + commands: + generated:
│   └── src/
│       ├── Settings.kt           # @Configurable interface
│       ├── tasks/                # one @TaskAction per file
│       │   ├── Foo.kt
│       │   └── FooSteps.kt       # internal shared helpers (not @TaskAction)
│       └── <domain logic>/
└── <consumer-module>/
    └── module.yaml               # plugins: { <name>: enabled: true, ... }
仓库中至少要保留一个消费模块——这是端到端验证插件的唯一方式,而且目前插件还不能发布到任何公共仓库。

project.yaml

project.yaml

yaml
modules:
  - consumer-app
  - plugins/<name>

plugins:
  - ./plugins/<name>
Without the top-level
plugins:
block the plugin id is unresolvable from any consumer.
yaml
modules:
  - consumer-app
  - plugins/<name>

plugins:
  - ./plugins/<name>
如果没有顶层的
plugins:
块,任何消费模块都无法解析该插件的ID。

module.yaml
— the plugin module

module.yaml
—— 插件模块

yaml
product: jvm/amper-plugin          # marks the module as a plugin

dependencies:
  - <coordinate>:<version>
  - <coordinate>:<version>: runtime-only   # required only at runtime
  - <coordinate>:<version>: compile-only

pluginInfo:
  id: <plugin-id>                                  # what consumers write under `plugins:`
  settingsClass: <fully.qualified.Settings>        # the @Configurable interface

settings:
  jvm:
    jdk:
      version: 21
  kotlin:
    languageVersion: 2.1
yaml
product: jvm/amper-plugin          # marks the module as a plugin

dependencies:
  - <coordinate>:<version>
  - <coordinate>:<version>: runtime-only   # required only at runtime
  - <coordinate>:<version>: compile-only

pluginInfo:
  id: <plugin-id>                                  # what consumers write under `plugins:`
  settingsClass: <fully.qualified.Settings>        # the @Configurable interface

settings:
  jvm:
    jdk:
      version: 21
  kotlin:
    languageVersion: 2.1

@Configurable interface Settings

@Configurable
接口 Settings

Defaults go in interface property getters; nested blocks become nested
@Configurable
interfaces.
kotlin
@Configurable
interface Settings {
    val someValue: String get() = "default"
    val checks: ChecksSettings
}

@Configurable
interface ChecksSettings {
    val strict: Boolean get() = true
}
Consumers override what they need in
module.yaml
; omitted values fall back to the getter default:
yaml
plugins:
  <plugin-id>:
    enabled: true
    someValue: "override"
    checks:
      strict: false
默认值定义在接口属性的getter中;嵌套的配置块对应嵌套的
@Configurable
接口。
kotlin
@Configurable
interface Settings {
    val someValue: String get() = "default"
    val checks: ChecksSettings
}

@Configurable
interface ChecksSettings {
    val strict: Boolean get() = true
}
消费模块在
module.yaml
中覆盖需要的配置;未配置的项会回退到getter中定义的默认值:
yaml
plugins:
  <plugin-id>:
    enabled: true
    someValue: "override"
    checks:
      strict: false

@TaskAction

@TaskAction
任务动作

Task actions are top-level
fun
s, called when the matching
plugin.yaml
entry executes.
kotlin
@TaskAction
fun foo(
    @Input moduleRootDir: Path,
    @Output outputDir: Path,
    settings: Settings,
) {
    // body
}
  • @Input path: Path
    — declared input; Kotlin Toolchain snapshots its contents for execution avoidance.
  • @Output path: Path
    — declared output directory; Kotlin Toolchain creates it and passes the path in. Write to the exact
    Path
    you received, or downstream references won't find the result.
  • settings: Settings
    (or any
    @Configurable
    ) — typed configuration, wired in
    plugin.yaml
    .
  • Plain
    Path
    / primitives — passed literally from
    plugin.yaml
    .
  • println(...)
    is the output channel; Kotlin Toolchain captures stdout.
任务动作是顶层
fun
函数,当
plugin.yaml
中对应的条目执行时会调用这些函数。
kotlin
@TaskAction
fun foo(
    @Input moduleRootDir: Path,
    @Output outputDir: Path,
    settings: Settings,
) {
    // body
}
  • @Input path: Path
    —— 声明输入;Kotlin Toolchain会对其内容生成快照,用于执行规避。
  • @Output path: Path
    —— 声明的输出目录;Kotlin Toolchain会创建该目录并传入路径。请严格写入你接收到的
    Path
    路径,否则下游引用将找不到结果。
  • settings: Settings
    (或任何
    @Configurable
    类型)—— 类型化配置,在
    plugin.yaml
    中进行绑定。
  • 普通
    Path
    /基本类型 —— 直接从
    plugin.yaml
    中按字面量传入。
  • println(...)
    是输出通道;Kotlin Toolchain会捕获标准输出。

Execution avoidance

执行规避

A
@TaskAction
is skipped when its declared inputs are unchanged. Tasks whose real inputs are Git history, the network, or environment variables cannot be fingerprinted, so opt out:
kotlin
@TaskAction(executionAvoidance = ExecutionAvoidance.Disabled)
fun foo(@Output outputDir: Path, settings: Settings) { /* ... */ }
Tasks with no
@Output
are never cached and always re-run — correct for purely side-effecting tasks (releases, deployments, pushes).
当
@TaskAction
声明的输入未发生变化时,任务会被跳过。如果任务的实际输入是Git历史、网络请求或环境变量,就无法生成指纹,因此需要禁用执行规避:
kotlin
@TaskAction(executionAvoidance = ExecutionAvoidance.Disabled)
fun foo(@Output outputDir: Path, settings: Settings) { /* ... */ }
没有
@Output
的任务永远不会被缓存,且总是重新执行——这适用于纯副作用类任务(发布、部署、推送)。

plugin.yaml

plugin.yaml

yaml
tasks:
  foo:
    action: !<fully.qualified.foo>
      moduleRootDir: ${module.rootDir}
      outputDir: ${taskOutputDir}
      settings: ${pluginSettings}

  bar:
    action: !<fully.qualified.bar>
      input: ${tasks.foo.action.outputDir}/result.txt
      settings: ${pluginSettings}

generated:
  resources:
    - directory: ${tasks.foo.action.outputDir}

commands:
  - foo
ReferenceResolves to
${module.rootDir}
Directory containing the consumer's
module.yaml
. Pass as
@Input
to inspect the consumer's tree.
${taskOutputDir}
Toolchain-managed per-task output directory. Pass as
@Output
.
${pluginSettings}
The
@Configurable
object built from the consumer's
module.yaml
.
${tasks.<task>.action.<param>}
Another task's parameter — used in
generated.*
and to wire one task's
@Input
to another's
@Output
.
yaml
tasks:
  foo:
    action: !<fully.qualified.foo>
      moduleRootDir: ${module.rootDir}
      outputDir: ${taskOutputDir}
      settings: ${pluginSettings}

  bar:
    action: !<fully.qualified.bar>
      input: ${tasks.foo.action.outputDir}/result.txt
      settings: ${pluginSettings}

generated:
  resources:
    - directory: ${tasks.foo.action.outputDir}

commands:
  - foo
引用解析为
${module.rootDir}
消费模块
module.yaml
所在的目录。作为
@Input
传入可用于查看消费模块的目录结构。
${taskOutputDir}
由Toolchain管理的每个任务的输出目录。作为
@Output
传入。
${pluginSettings}
从消费模块
module.yaml
构建的
@Configurable
对象。
${tasks.<task>.action.<param>}
另一个任务的参数——用于
generated.*
配置,以及将一个任务的
@Input
绑定到另一个任务的
@Output
。

generated.resources
/
generated.sources

generated.resources
/
generated.sources

Both register a directory (usually a task's
@Output
) as a contribution to the consumer's build, and both auto-wire the producing task to run first:
  • generated.resources
    — added to the JAR classpath, reachable via
    getResourceAsStream("/path/in/jar")
    .
  • generated.sources
    — added as a Kotlin source root and compiled with the consumer's
    src/
    .
这两个配置项都会将一个目录(通常是任务的
@Output
)注册为消费模块构建的贡献项,并且都会自动绑定生成任务优先执行:
  • generated.resources
    —— 添加到JAR类路径中,可通过
    getResourceAsStream("/path/in/jar")
    访问。
  • generated.sources
    —— 添加为Kotlin源码根目录,随消费模块的
    src/
    一起编译。

Tasks vs commands

任务与命令

Tasks are the implementation, addressed as
./kotlin task :<module>:<task>@<plugin-id>
— the docs advise against relying on that mangled name. Commands are the public API:
./kotlin do <command-name>
, listed via
./kotlin show commands
(
-m <module>
to scope).
  • A task whose
    @Output
    feeds
    generated.resources
    /
    generated.sources
    is a build-graph contributor. Keep it out of
    commands:
    ; it runs automatically and exposing it invites users to run it by hand.
  • A task users invoke directly must be in
    commands:
    .
任务是实现层,通过
./kotlin task :<module>:<task>@<plugin-id>
调用——官方文档建议不要依赖这种拼接出来的名称。命令是公开API:
./kotlin do <command-name>
,可通过
./kotlin show commands
列出(加
-m <module>
可限定范围)。
  • 如果任务的
    @Output
    用于提供
    generated.resources
    /
    generated.sources
    ,那它是构建图的贡献项。不要把它加入
    commands:
    ;它会自动运行,暴露出来反而可能导致用户手动执行它。
  • 用户需要直接调用的任务必须加入
    commands:
    。

File-based task communication

基于文件的任务通信

There is no shared mutable build state — no
project.version
, no extension property maps. Tasks talk through matched paths:
  1. The producer takes
    @Output outputDir: Path
    and writes files into it.
  2. plugin.yaml
    points a consumer task's
    @Input
    at
    ${tasks.<producer>.action.outputDir}/<file>
    .
  3. The Toolchain infers the dependency from the path match — no
    dependsOn
    API needed.
The same
@Output
directory can serve build-time consumers (via
@Input
) and runtime consumers (registered under
generated.resources
, read via
getResourceAsStream
) simultaneously.
不存在共享的可变构建状态——没有
project.version
,也没有扩展属性映射。任务通过匹配的路径进行通信:
  1. 生产任务接收
    @Output outputDir: Path
    并将文件写入该目录。
  2. plugin.yaml
    将消费任务的
    @Input
    指向
    ${tasks.<producer>.action.outputDir}/<file>
    。
  3. Toolchain会根据路径匹配推断依赖关系——不需要
    dependsOn
    API。
同一个
@Output
目录可以同时服务于构建时消费者(通过
@Input
)和运行时消费者(通过
generated.resources
注册,通过
getResourceAsStream
读取)。

Runtime overrides via environment variables

通过环境变量进行运行时覆盖

There is no
-Pkey=value
. Read env vars inside the action for ephemeral overrides:
kotlin
val forced = System.getenv("MYPLUGIN_FORCE_VALUE")?.takeIf { it.isNotBlank() }
val skipChecks = System.getenv("MYPLUGIN_SKIP_CHECKS")?.equals("true", ignoreCase = true) == true
Pass the env map in as a constructor parameter rather than calling
System.getenv()
deep in the call stack, so logic stays unit-testable. Document every recognised variable in the plugin's README. Env vars are ephemeral overrides, not a trust boundary — validate a value before using it in a file path or process argument.
没有
-Pkey=value
这类参数。在任务动作内部读取环境变量来实现临时覆盖:
kotlin
val forced = System.getenv("MYPLUGIN_FORCE_VALUE")?.takeIf { it.isNotBlank() }
val skipChecks = System.getenv("MYPLUGIN_SKIP_CHECKS")?.equals("true", ignoreCase = true) == true
请将环境变量映射作为构造函数参数传入,而不是在调用栈深处直接调用
System.getenv()
,这样逻辑才能保持单元可测试性。请在插件的README中记录所有支持的环境变量。环境变量是临时覆盖手段,而非信任边界——在文件路径或进程参数中使用某个值之前,请先对其进行校验。

Sharing logic across task actions

在任务动作间共享逻辑

Tasks often share steps (verify → create → push). Don't compose an atomic user-facing task from a chain of build-graph tasks: separate invocations re-open shared resources and open a window where another process observes intermediate state.
任务之间通常会共享步骤(验证→创建→推送)。不要用一系列构建图任务来组合成一个面向用户的原子任务:多次独立调用会重新打开共享资源,并且会出现时间窗口,导致其他进程可能观察到中间状态。

Limitations to design around

需要规避的设计限制

  • Plugins are local-only; no public registry publishing yet.
  • Plugins are module-level; there is no project-wide plugin. Every consumer module lists it under
    plugins:
    , and cross-module effects flow through files.
  • No
    ${...}
    interpolation in
    module.yaml
    (as of 0.11) — consumer settings are literal values.
  • No
    Project.afterEvaluate
    , no lazy
    Provider
    /
    Property
    graph. Compute derived values in the action body.
  • -h
    /
    --help
    does not list plugin commands; use
    ./kotlin show commands
    .
  • 插件仅支持本地使用;目前还不能发布到公共仓库。
  • 插件是模块级别的;不存在项目级插件。每个消费模块都需要在
    plugins:
    下声明该插件,跨模块影响通过文件传递。
  • module.yaml
    中不支持
    ${...}
    插值(0.11版本为止)——消费端配置都是字面量值。
  • 没有
    Project.afterEvaluate
    ,没有惰性
    Provider
    /
    Property
    图。派生值需要在任务动作体内计算。
  • -h
    /
    --help
    不会列出插件命令;请使用
    ./kotlin show commands
    。

Validate against a consumer

用消费模块验证

  1. Add a small consumer module (
    demo-app/
    ,
    sample/
    ) enabling the plugin with realistic settings.
  2. Have its
    main.kt
    or a test read whatever the plugin publishes.
  3. Put the exact commands and expected output in the plugin's README, so a fresh clone can paste and compare.
  1. 添加一个小型消费模块(
    demo-app/
    、
    sample/
    ),用贴近实际的配置启用插件。
  2. 让它的
    main.kt
    或测试代码读取插件发布的所有内容。
  3. 将确切的命令和预期输出写入插件的README,这样新克隆的仓库可以直接粘贴命令并对比结果。