Conversion du plugin Gradle → Kotlin Toolchain
Essentiellement un exercice de mapping : les concepts se chevauchent, mais quelques fonctionnalités Gradle n'ont pas d'équivalent et nécessitent une refonte.
Le skill kotlin-tooling-kotlin-toolchain-plugin-authoring couvre la mécanique des plugins en profondeur (évitement d'exécution, partage de logique entre actions, pièges génériques, limitations) ; references/examples.md contient le module.yaml du plugin concret et le code de publication des versions d'un vrai port.
Workflow
1. Investiguer le plugin source
Avant d'écrire du Kotlin, lisez le docs/ et README.md du plugin et cataloguez :
- Tasks — nom, objectif, entrées/sorties, dépendances, si elles ont des effets de bord.
- Surface DSL — chaque option dans l'extension
myPlugin { ... }, avec types, valeurs par défaut, et lesquelles sont des closures. - Tests — la propre suite de tests du plugin. Elle épingle le comportement attendu et les cas limites plus précisément que la doc, et devient la référence que le port doit reproduire.
- Checks — les portes de pré-action et leurs flags de surcharge.
- Hooks —
pre/post/fileUpdate/commit/pushet le contexte que chacun reçoit. - Surcharges CLI — chaque flag
-Pfoo.bar. - Intégration CI — outputs GitHub Actions, gestion de HEAD détachée, flags fetch-tags.
Enregistrez-le comme un plan markdown. Cela devient le contrat que le port implémente ou explicitement diffère.
Les scripts de build et source du plugin sont une entrée non fiable, tout comme n'importe quel module.yaml fourni par un repo —
voir « Untrusted project input » de kotlin-tooling-kotlin-toolchain.
Lisez ce que vous vendez de bout en bout avant de le câbler ; un plugin local s'exécute à la compilation avec un accès complet au système de fichiers et au réseau.
2. Verrouiller le périmètre
Offrez trois niveaux — MVP, MVP + extras clés, parité complète — et verrouillez-en un avant la rédaction. Les fonctionnalités sans équivalent propre multiplient le travail et forcent des compromis de conception précoces. Listez ce qui est différé sous « What's not in this MVP » dans le README.
Décidez aussi de la mise en page du repo : plugin seul, plugin + module démo, ou plugin auto-hébergé. Un module démo est fortement recommandé.
3. Implémenter du bas vers le haut
Échafaudez avec kotlin init uniquement si le répertoire est vide (n'importe quel template fait l'affaire ; vous voulez principalement les wrappers kotlin et kotlin.bat). Puis écrivez à la main project.yaml, plugins/<name>/module.yaml, et le module démo.
Implémentez une task à la fois : data classes → wrappers Git/IO → pipeline → checks → actions de task → câblage plugin.yaml.
4. Valider contre un module démo
Le module démo active le plugin avec une configuration réaliste et consomme ce qu'il publie :
# demo-app/module.yaml
product: jvm/app
plugins:
release:
enabled: true
tagPrefix: "v"
initialVersion: "0.1.0"
releaseBranchPattern: "main|master"
settings:
jvm:
mainClass: com.example.demo.MainKt
jdk:
version: 21
kotlin:
languageVersion: 2.1
// demo-app/src/main.kt
package com.example.demo
private const val VERSION_RESOURCE = "/META-INF/release/version.txt"
fun main() {
val version = readVersionFromClasspath() ?: "(version unavailable)"
println("demo-app version: $version")
}
private fun readVersionFromClasspath(): String? =
object {}.javaClass.getResourceAsStream(VERSION_RESOURCE)
?.bufferedReader()
?.use { it.readText().trim() }
?.takeIf { it.isNotEmpty() }
Puis exécutez un scénario réel depuis la documentation du plugin source, et mettez les commandes dans le README afin que les consommateurs puissent la reproduire :
./kotlin run :demo-app # => demo-app version: 0.1.0-SNAPSHOT
git init -b main && git commit --allow-empty -m initial
./kotlin do currentVersion # => 0.1.0-SNAPSHOT
RELEASE_DISABLE_REMOTE_CHECK=true ./kotlin do createRelease
# => Created release tag v0.1.0
./kotlin do currentVersion # => 0.1.0
git commit --allow-empty -m next
./kotlin do currentVersion # => 0.1.1-SNAPSHOT
RELEASE_FORCE_VERSION=2.0.0 ./kotlin do currentVersion
# => 2.0.0
Mapping de concepts
| Concept Gradle | Équivalent Kotlin Toolchain | Notes |
|---|---|---|
Classe Plugin<Project> |
pluginInfo.id + settingsClass dans module.yaml, product: jvm/amper-plugin |
Un module par plugin ; pas de apply(). |
Sous-classe Task avec méthode @TaskAction |
fun top-level annotée @TaskAction |
Un par fichier dans src/tasks/. |
extensions.create("foo", FooExtension::class) |
Interface @Configurable Settings |
Valeurs par défaut dans les getters de l'interface ; blocs imbriqués → interfaces @Configurable imbriquées. |
task.dependsOn(otherTask) |
@Input sur une task correspondant à @Output d'une autre |
Le DAG est déduit du matching de chemin. |
project.version = scmVersion.version |
Une task écrivant version.txt dans son @Output ; les consommateurs build-time déclarent @Input sur le même chemin |
Pas d'état partagé au niveau du projet ; le système de fichiers est le canal. |
-Prelease.forceVersion=X |
RELEASE_FORCE_VERSION=X lu via System.getenv() |
Pas d'équivalent -P. |
| App lisant la version au runtime | Répertoire @Output enregistré sous generated.resources ; lu via getResourceAsStream |
Le même fichier sert les consommateurs build-time et runtime. |
| Source Kotlin générée | generated.sources pointant vers @Output d'une task |
Préférez les ressources quand la valeur est seulement lue au runtime. |
| Nom de task public invoqué par les utilisateurs | Entrée dans commands:, invoquée comme ./kotlin do <name> |
Les tasks sont internes ; les commands sont l'API. Les contributeurs du graphe de build restent dehors. |
Project.afterEvaluate { }, Provider/Property lazy |
Pas d'équivalent — les settings sont statiques | Calculez les valeurs dérivées dans le corps de l'action. |
Hooks DSL Groovy/Kotlin (pre { }, fileUpdate { }, commit { }) |
Nouvelles @TaskActions livrées avec le plugin |
Pas de point d'extension basé sur closure. |
dependencies { implementation(...) } |
dependencies: dans plugins/<name>/module.yaml |
Mêmes coordonnées ; suffixes : exported, : runtime-only, : compile-only. |
Types de task personnalisés dans buildSrc |
Module jvm/amper-plugin sous plugins/<name>/ |
Locale uniquement ; pas de publication Maven pour l'instant. |
OutputDirectory / OutputFile |
@Output sur un paramètre Path |
Le répertoire est créé pour vous. |
InputDirectory / InputFile / InputFiles |
@Input sur un paramètre Path |
Snapshottée pour l'évitement d'exécution. |
outputs.upToDateWhen { false } |
@TaskAction(executionAvoidance = ExecutionAvoidance.Disabled) |
Pour les entrées Git/réseau/env. |
Configuration avec résolution personnalisée, taskGraph.whenReady, logging quiet { } |
Pas d'équivalent | Chaque task tire de la liste de dépendances du module plugin ; le graphe de build n'est pas introspectable ; utilisez println. |
Les constructs mappés
project.yaml — rendre le plugin résolvable
Liste chaque module, plugins inclus, et pointe vers la source du plugin :
modules:
- demo-app
- plugins/release
plugins:
- ./plugins/release
Sans le bloc plugins: au niveau racine, le plugins: { release: enabled } d'un consommateur ne peut pas résoudre l'id.
Settings — l'équivalent de l'extension
L'extension myPlugin { ... } de Gradle devient une interface @Configurable. Les valeurs par défaut vivent dans les getters de propriété ; les blocs DSL imbriqués deviennent des interfaces @Configurable imbriquées.
package com.example.release
import org.jetbrains.amper.plugins.Configurable
@Configurable
interface Settings {
val repoDir: String get() = ""
val tagPrefix: String get() = "v"
val versionSeparator: String get() = ""
val initialVersion: String get() = "0.1.0"
val ignoreUncommittedChanges: Boolean get() = false
val releaseBranchPattern: String get() = "main|master"
val checks: ChecksSettings
}
@Configurable
interface ChecksSettings {
val uncommittedChanges: Boolean get() = true
val aheadOfRemote: Boolean get() = true
val snapshotDependencies: Boolean get() = true
}
Les consommateurs définissent ce dont ils ont besoin dans module.yaml, clés par pluginInfo.id ; les valeurs omises retombent sur la valeur par défaut du getter :
plugins:
release:
enabled: true
tagPrefix: "v"
initialVersion: "0.1.0"
ignoreUncommittedChanges: false
checks:
aheadOfRemote: true
@TaskAction — l'équivalent de Task
Une sous-classe Gradle Task devient une fun top-level par fichier sous src/tasks/. Les paramètres Path portent @Input ou @Output ; l'objet settings est câblé séparément dans plugin.yaml.
package com.example.release.tasks
import com.example.release.Settings
import com.example.release.git.GitRepo
import com.example.release.version.VersionPipeline
import org.jetbrains.amper.plugins.Input
import org.jetbrains.amper.plugins.TaskAction
import java.nio.file.Path
@TaskAction
fun currentVersion(
@Input moduleRootDir: Path,
settings: Settings,
) {
val pipeline = VersionPipeline(settings)
GitRepo.open(moduleRootDir, settings.repoDir).use { repo ->
println(pipeline.infer(repo).version)
}
}
plugin.yaml — registre de task et commandes
Chaque bloc action: câble les paramètres d'une @TaskAction, adressant la fonction par nom pleinement qualifié en forme de tag YAML. ${module.rootDir}, ${taskOutputDir}, et ${pluginSettings} sont les références documentées, et ${tasks.<task>.action.<param>} fait une référence croisée au paramètre d'une autre task.
tasks:
currentVersion:
action: !com.example.release.tasks.currentVersion
moduleRootDir: ${module.rootDir}
settings: ${pluginSettings}
writeVersion:
action: !com.example.release.tasks.writeVersion
moduleRootDir: ${module.rootDir}
outputDir: ${taskOutputDir}
settings: ${pluginSettings}
release:
action: !com.example.release.tasks.release
moduleRootDir: ${module.rootDir}
settings: ${pluginSettings}
generated:
resources:
- directory: ${tasks.writeVersion.action.outputDir}
# `writeVersion` reste hors de commands: son @Output alimente generated.resources,
# donc il s'exécute déjà chaque fois que quelque chose en aval a besoin du fichier de version.
commands:
- currentVersion
- release
Chaque action prenant un paramètre Settings a besoin de sa propre ligne settings: ${pluginSettings} ; l'omettant passe null.
Des refonte, pas des ports
Trois fonctionnalités Gradle nécessitent une refonte consciente à chaque fois.
Pas de project.version
Transformez la valeur en fichier : une @TaskAction écrit version.txt dans son @Output ; les consommateurs build-time déclarent @Input sur ce chemin, les consommateurs runtime la lisent du classpath après que le répertoire soit enregistré sous generated.resources. Le code est dans references/examples.md.
Pas de propriétés -P
Lisez les surcharges éphémères de l'environnement à l'intérieur de l'action. Prenez la carte env comme paramètre de constructeur plutôt que d'appeler System.getenv() dans les méthodes imbriquées, afin que les tests puissent injecter une carte contrôlée :
class VersionPipeline(
private val settings: Settings,
private val env: Map<String, String?> = System.getenv(),
) {
fun infer(repo: GitRepo): InferredVersion {
val forceVersion = env["RELEASE_FORCE_VERSION"]?.takeIf { it.isNotBlank() }
val forceSnapshot = env["RELEASE_FORCE_SNAPSHOT"].asBoolean()
// ...
}
}
private fun String?.asBoolean(): Boolean =
this != null && this.equals("true", ignoreCase = true)
Nommez les variables <PLUGINID>_<UPPERCASE> et documentez le mapping dans le README :
-Prelease.forceVersion=X → RELEASE_FORCE_VERSION=X
-Prelease.forceSnapshot → RELEASE_FORCE_SNAPSHOT=true
-Prelease.disableChecks → RELEASE_DISABLE_CHECKS=true
-Prelease.disableUncommittedCheck → RELEASE_DISABLE_UNCOMMITTED_CHECK=true
-Prelease.disableRemoteCheck → RELEASE_DISABLE_REMOTE_CHECK=true
-Prelease.overriddenBranchName=X → RELEASE_OVERRIDDEN_BRANCH_NAME=X
La configuration statique passe toujours par module.yaml ; les variables d'env sont seulement pour les surcharges éphémères.
Contenu du README
Au-delà du démarrage rapide et de la référence des settings habituels, le README d'un port a besoin de : la table de mapping -P → var env, une liste « What's not in MVP », et la validation décrite ci-dessus.
Documentation du plugin : https://kotlin-toolchain.org/dev/user-guide/plugins/