Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-11-12 18:52:50 +0300
committerLeitwolf112026-11-12 18:52:50 +0300
commit6c7e9ad4e59c46da58a121d70caa17c01006273b (patch)
treee0c7ab793285ff4881f8bf0dbf14a229fcc1329e
parent0a37512d10792996402c21051c6ad58e4f5f506e (diff)
downloadtazel-6c7e9ad4e59c46da58a121d70caa17c01006273b.tar.gz
Bring the plugin API and the Gradle plugin from the legacy Tazel
Tazel Plugins stay, built with Gradle and published to Tazel Portal. The api/ (com.progmasoft.tazel:tazel-api) and gradle-plugin/ (com.progmasoft.tazel.plugin) projects come from Progmasoft/tazel-legacy with their tests, with these changes: - tazelPlugin { } holds group, name and version. The descriptor META-INF/tazel-plugin.properties records them first, so the JAR says who the plugin is; before, ViGet recorded the publisher, name and version at push time. The group must be a reversed domain of at least two parts (Tazel Portal checks the namespace by the Sonatype rules), the name letters, digits and _ . -, and the version a Semantic Version. The version of the Gradle project is not the plugin's. - The API version starts again at 1, so plugins built for the legacy Tazel (API 3) are not loaded. - Documentation no longer speaks of ViGet, Tala targets or tazel.tala.run; the site is tazel.net. - Dependency locking of the legacy build is not carried over, like in the rest of this build. The root build includes both projects, so ./gradlew check runs the Gradle TestKit tests of the plugin as well.
-rw-r--r--CHANGELOG.md3
-rw-r--r--api/build.gradle.kts93
-rw-r--r--api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/TazelPluginDsl.kt14
-rw-r--r--api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/BuildStep.kt77
-rw-r--r--api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/Host.kt40
-rw-r--r--api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/PluginExtensions.kt56
-rw-r--r--api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/ProjectPlugin.kt62
-rw-r--r--build.gradle.kts4
-rw-r--r--gradle-plugin/build.gradle.kts90
-rw-r--r--gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginDescriptorTask.kt139
-rw-r--r--gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginPlugin.kt123
-rw-r--r--gradle-plugin/sources/test/kotlin/com/progmasoft/tazel/gradle/TazelPluginPluginTest.kt172
-rw-r--r--settings.gradle.kts8
13 files changed, 879 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 532172f..569451d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,3 +14,6 @@ Versions follow [Semantic Versioning](https://semver.org). The earlier Tazel, 0.
`c`, `cpp`, `objc`, `objcpp` and `zig` targets of the four kinds, with `isPublic` per file. A setting that a
language or kind does not have is a compile error of the project file. A target block runs for each triple and
build mode; its labels are resolved and visibility is checked.
+- The plugin API, `com.progmasoft.tazel:tazel-api` (API version 1), and the Gradle plugin `com.progmasoft.tazel.plugin`,
+ taken from the legacy Tazel. `tazelPlugin { }` now holds `group`, `name` and `version`, which the plugin descriptor
+ records; the group must be a reversed domain and the version a Semantic Version.
diff --git a/api/build.gradle.kts b/api/build.gradle.kts
new file mode 100644
index 0000000..135708e
--- /dev/null
+++ b/api/build.gradle.kts
@@ -0,0 +1,93 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+// The API that plugins of the Tazel project file compile against: com.progmasoft.api.tazel and its
+// packages, published as com.progmasoft.tazel:tazel-api to https://maven.progmasoft.com/tazel.
+// A plugin does not bundle it; Tazel provides it at run time.
+plugins {
+ kotlin("jvm")
+ `java-library`
+ `maven-publish`
+}
+
+group = "com.progmasoft.tazel"
+version = rootProject.version
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(25)
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_25
+ allWarningsAsErrors = true
+ }
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_25
+ targetCompatibility = JavaVersion.VERSION_25
+ withSourcesJar()
+}
+
+sourceSets {
+ main { kotlin.srcDir("sources/main/kotlin") }
+}
+
+tasks.withType<AbstractArchiveTask>().configureEach {
+ isPreserveFileTimestamps = false
+ isReproducibleFileOrder = true
+}
+
+tasks.jar {
+ manifest {
+ attributes("Implementation-Title" to "Tazel API", "Implementation-Version" to project.version)
+ }
+}
+
+publishing {
+ publications {
+ create<MavenPublication>("api") {
+ artifactId = "tazel-api"
+ from(components["java"])
+ pom {
+ name = "Tazel API"
+ description = "The API that plugins of the Tazel project file compile against."
+ url = "https://tazel.net/"
+ licenses {
+ license {
+ name = "MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0"
+ url = "https://github.com/Progmasoft/tazel/blob/main/LICENSE.txt"
+ }
+ }
+ developers {
+ developer {
+ name = "Progmasoft"
+ email = "support@progmasoft.com"
+ }
+ }
+ scm {
+ url = "https://github.com/Progmasoft/tazel"
+ connection = "scm:git:https://github.com/Progmasoft/tazel.git"
+ }
+ }
+ }
+ }
+ repositories {
+ // A local repository, which is copied to https://maven.progmasoft.com/tazel.
+ maven {
+ name = "staging"
+ url =
+ rootProject.layout.buildDirectory
+ .dir("maven")
+ .get()
+ .asFile
+ .toURI()
+ }
+ }
+}
diff --git a/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/TazelPluginDsl.kt b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/TazelPluginDsl.kt
new file mode 100644
index 0000000..0babe77
--- /dev/null
+++ b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/TazelPluginDsl.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.api.tazel.dsl
+
+/**
+ * Marks the scopes of a plugin DSL, so that a nested block of the project file cannot reach the
+ * members of the block around it by mistake: inside `cc { c { } }`, `c` sees only its own members.
+ */
+@DslMarker
+@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE)
+annotation class TazelPluginDsl
diff --git a/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/BuildStep.kt b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/BuildStep.kt
new file mode 100644
index 0000000..d295f94
--- /dev/null
+++ b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/BuildStep.kt
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.api.tazel.dsl.plugin
+
+import java.nio.file.Path
+
+/** Work a plugin adds to the build of a project; see [ExtensionScope.buildStep]. */
+fun interface BuildStep {
+ fun run(context: BuildContext)
+}
+
+/** What a [BuildStep] runs with. */
+interface BuildContext {
+ val projectRoot: Path
+
+ /** The project the step builds. */
+ val project: ProjectSummary
+
+ /** The absolute directory the build mode of the project writes artifacts to. */
+ val outputDirectory: Path
+
+ val buildMode: BuildMode
+ val host: Host
+
+ /**
+ * A directory of this step for intermediate files, created before the step runs: the same one in
+ * every build, below `.tazel/build` of the project.
+ */
+ val workDirectory: Path
+
+ /** Prints a status line, such as `Configuring cmake (Release)`. */
+ fun status(
+ verb: String,
+ message: String,
+ )
+
+ fun warn(message: String)
+
+ /**
+ * Runs a program with the variables of [environment] added to those of Tazel and returns its exit
+ * code. What it prints goes to the output of Tazel.
+ */
+ fun run(
+ command: List<String>,
+ directory: Path = projectRoot,
+ environment: Map<String, String> = emptyMap(),
+ ): Int
+
+ /**
+ * Runs a program like [run], but gathers what it prints, output and errors in the order written,
+ * instead of showing it: for a probe whose failure is an answer, or a program whose output the
+ * step reads.
+ */
+ fun capture(
+ command: List<String>,
+ directory: Path = projectRoot,
+ environment: Map<String, String> = emptyMap(),
+ ): CapturedRun
+
+ /** The program [name] found on `PATH`, with the executable extension of the host, or `null`. */
+ fun findProgram(name: String): Path?
+
+ /** Stops the build with [message]. */
+ fun fail(message: String): Nothing
+}
+
+/** The exit code of a program [BuildContext.capture] ran, and what it printed. */
+data class CapturedRun(
+ val exitCode: Int,
+ val output: String,
+)
+
+/** Thrown by [BuildContext.fail]; Tazel reports its message and stops the build. */
+class BuildStepException(message: String) : RuntimeException(message)
diff --git a/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/Host.kt b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/Host.kt
new file mode 100644
index 0000000..1e138a8
--- /dev/null
+++ b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/Host.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.api.tazel.dsl.plugin
+
+enum class OperatingSystem {
+ LINUX,
+ MACOS,
+ WINDOWS,
+ FREEBSD,
+ OPENBSD,
+ NETBSD,
+ UNKNOWN,
+}
+
+enum class Architecture {
+ X86_64,
+ AARCH64,
+ ARMV7H,
+ RISCV64,
+ UNKNOWN,
+}
+
+/** The machine Tazel runs on. */
+data class Host(
+ val os: OperatingSystem,
+ val architecture: Architecture,
+) {
+ /** The extension the host gives an executable file: `.exe` on Windows, none elsewhere. */
+ val executableExtension: String
+ get() = if (os == OperatingSystem.WINDOWS) ".exe" else ""
+}
+
+/** The build mode of the project. */
+enum class BuildMode {
+ DEBUG,
+ RELEASE,
+}
diff --git a/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/PluginExtensions.kt b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/PluginExtensions.kt
new file mode 100644
index 0000000..f8ae8d1
--- /dev/null
+++ b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/PluginExtensions.kt
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.api.tazel.dsl.plugin
+
+import java.nio.file.Path
+import java.util.ServiceLoader
+
+/**
+ * The extensions of the project whose file is running.
+ *
+ * The DSL function of a plugin calls [require] with an extension its plugin registered and adds the
+ * work of its block through the [ExtensionScope] it receives:
+ * ```
+ * fun cmake(block: CMakeScope.() -> Unit) {
+ * val settings = CMakeScope().apply(block)
+ * PluginExtensions.require("cmake").buildStep("configure") { context -> ... }
+ * }
+ * ```
+ */
+object PluginExtensions {
+ private val host: ExtensionHost by lazy {
+ ServiceLoader.load(ExtensionHost::class.java, ExtensionHost::class.java.classLoader)
+ .firstOrNull()
+ ?: throw IllegalStateException("plugin extensions are available only inside Tazel")
+ }
+
+ /**
+ * The scope of [extension] in the project whose file is running. It fails when no plugin of the
+ * project registered [extension], or when the project file did not declare that plugin.
+ */
+ fun require(extension: String): ExtensionScope = host.require(extension)
+}
+
+/** What the block of an extension can add to its project. */
+interface ExtensionScope {
+ val projectRoot: Path
+ val host: Host
+
+ /**
+ * Adds [step] to the build of the project. `tazel build` and `tazel run` run the steps of a
+ * project in the order they were added, before its targets compile; a failed step stops the
+ * build. [name] names the step in the output and is one identifier, unique within the plugin.
+ */
+ fun buildStep(
+ name: String,
+ step: BuildStep,
+ )
+}
+
+/** Provided by Tazel, which [PluginExtensions] finds as a service. Plugins do not implement it. */
+interface ExtensionHost {
+ fun require(extension: String): ExtensionScope
+}
diff --git a/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/ProjectPlugin.kt b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/ProjectPlugin.kt
new file mode 100644
index 0000000..9c50fe9
--- /dev/null
+++ b/api/sources/main/kotlin/com/progmasoft/api/tazel/dsl/plugin/ProjectPlugin.kt
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.api.tazel.dsl.plugin
+
+import java.nio.file.Path
+
+/** The version of this API; a plugin built for another one is not loaded. */
+const val PROJECT_PLUGIN_API_VERSION = 1
+
+/**
+ * A plugin of the Tazel project file.
+ *
+ * Who the plugin is, its group, name and version, is in the descriptor of its JAR,
+ * `META-INF/tazel-plugin.properties`, which the Gradle plugin writes from `tazelPlugin { }`
+ * together with the API version, the imports it adds and what Tazel Portal shows about it. The
+ * service entry of the JAR, `META-INF/services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin`,
+ * names the class, which needs a public constructor without parameters. Tazel creates it for every
+ * module that declares the plugin and calls [apply] before the project files run. The project files
+ * then use the DSL the plugin defines; see [PluginExtensions].
+ *
+ * A plugin runs with the same JVM permissions as Tazel. The integrity and the compatibility of its
+ * JAR are checked before it is activated; its code is not sandboxed.
+ */
+fun interface ProjectPlugin {
+ fun apply(context: ProjectPluginContext)
+}
+
+/** What a plugin can do while it is activated for one project. */
+interface ProjectPluginContext {
+ val projectRoot: Path
+
+ /** The machine Tazel runs on. */
+ val host: Host
+
+ /**
+ * Claims [name] as an extension of this plugin: a block of the project file that the DSL of the
+ * plugin defines, such as `cmake`. Two plugins of one project cannot claim the same name.
+ */
+ fun registerExtension(name: String)
+
+ /** Records [value] under [key] in the plan of the project; each key is written once. */
+ fun contribute(
+ key: String,
+ value: String,
+ )
+
+ /** Runs [action] once the project file has run and the plan of the project is finished. */
+ fun onFinalize(action: (ProjectSummary) -> Unit)
+}
+
+/** The project as a plugin sees it once its project file has run. */
+interface ProjectSummary {
+ val name: String?
+ val version: String?
+ val buildMode: BuildMode
+
+ /** The directory the build mode writes artifacts to, relative to the project root. */
+ val outputDirectory: String
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 8d3d62b..c2b69ae 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -63,13 +63,13 @@ sourceSets {
spotless {
kotlin {
- target("sources/**/*.kt")
+ target("sources/**/*.kt", "api/sources/**/*.kt", "gradle-plugin/sources/**/*.kt")
ktfmt().googleStyle()
trimTrailingWhitespace()
endWithNewline()
}
kotlinGradle {
- target("*.gradle.kts")
+ target("*.gradle.kts", "api/*.gradle.kts", "gradle-plugin/*.gradle.kts")
ktlint()
trimTrailingWhitespace()
endWithNewline()
diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts
new file mode 100644
index 0000000..0aec44d
--- /dev/null
+++ b/gradle-plugin/build.gradle.kts
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
+
+// The Gradle plugin com.progmasoft.tazel.plugin, which builds plugins of the Tazel project file.
+// It runs inside Gradle, with the Kotlin standard library Gradle ships: it does not bundle one,
+// compiles for the Kotlin of Gradle 9.0 and runs on its JVM.
+plugins {
+ kotlin("jvm")
+ `java-gradle-plugin`
+ id("com.gradle.plugin-publish") version "2.0.0"
+}
+
+group = "com.progmasoft.tazel"
+version = rootProject.version
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ compileOnly(kotlin("stdlib"))
+ testImplementation(kotlin("test"))
+}
+
+kotlin {
+ jvmToolchain(25)
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ apiVersion = KotlinVersion.KOTLIN_2_2
+ languageVersion = KotlinVersion.KOTLIN_2_2
+ }
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
+
+sourceSets {
+ main { kotlin.srcDir("sources/main/kotlin") }
+ test { kotlin.srcDir("sources/test/kotlin") }
+}
+
+// The version of the plugin is the version of Tazel; tazelApi() asks for the API of that version.
+val versionResource =
+ tasks.register("versionResource") {
+ val directory = layout.buildDirectory.dir("generated/version")
+ val pluginVersion = project.version.toString()
+ inputs.property("version", pluginVersion)
+ outputs.dir(directory)
+ doLast {
+ directory.get().asFile.resolve("tazel-gradle-plugin.properties").apply {
+ parentFile.mkdirs()
+ writeText("version=$pluginVersion\n")
+ }
+ }
+ }
+sourceSets.main { resources.srcDir(versionResource) }
+
+tasks.withType<AbstractArchiveTask>().configureEach {
+ isPreserveFileTimestamps = false
+ isReproducibleFileOrder = true
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+gradlePlugin {
+ website = "https://tazel.net/"
+ vcsUrl = "https://github.com/Progmasoft/tazel"
+ plugins {
+ create("tazelPlugin") {
+ id = "com.progmasoft.tazel.plugin"
+ implementationClass = "com.progmasoft.tazel.gradle.TazelPluginPlugin"
+ displayName = "Tazel plugin"
+ description =
+ "Builds a plugin of the Tazel project file: adds the Tazel API from " +
+ "https://maven.progmasoft.com/tazel/ as tazelApi(), and writes the descriptor and the " +
+ "service entry of the plugin from the tazelPlugin { } block."
+ // The Portal refuses the tags "gradle" and "plugin".
+ tags = listOf("tazel", "build", "kotlin-dsl")
+ }
+ }
+}
diff --git a/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginDescriptorTask.kt b/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginDescriptorTask.kt
new file mode 100644
index 0000000..78ac736
--- /dev/null
+++ b/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginDescriptorTask.kt
@@ -0,0 +1,139 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.tazel.gradle
+
+import java.io.StringWriter
+import java.util.Properties
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Writes `META-INF/tazel-plugin.properties` and the `ProjectPlugin` service entry of a Tazel
+ * plugin, which Tazel reads before it loads the plugin and when it pushes it to Tazel Portal. The
+ * descriptor says who the plugin is: its group, name and version.
+ */
+@CacheableTask
+abstract class TazelPluginDescriptorTask : DefaultTask() {
+ // A required field is optional to Gradle, so the task reports it as a tazelPlugin setting.
+ @get:Input @get:Optional abstract val pluginGroup: Property<String>
+
+ @get:Input @get:Optional abstract val pluginName: Property<String>
+
+ @get:Input @get:Optional abstract val pluginVersion: Property<String>
+
+ @get:Input @get:Optional abstract val pluginClass: Property<String>
+
+ @get:Input abstract val imports: ListProperty<String>
+
+ // `description` is a property of every task already.
+ @get:Input @get:Optional abstract val description_: Property<String>
+
+ @get:Input abstract val authors: ListProperty<String>
+
+ @get:Input @get:Optional abstract val license: Property<String>
+
+ @get:Input @get:Optional abstract val homepage: Property<String>
+
+ @get:Input @get:Optional abstract val repository: Property<String>
+
+ @get:Input abstract val apiVersion: Property<Int>
+
+ @get:OutputDirectory abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun write() {
+ val group = required(pluginGroup, "group")
+ if (!group.matches(GROUP)) {
+ throw GradleException(
+ "tazelPlugin.group is '$group'; a group is a reversed domain such as com.example, " +
+ "of at least two parts of letters, digits, - and _"
+ )
+ }
+ val name = required(pluginName, "name")
+ if (!name.matches(NAME)) {
+ throw GradleException(
+ "tazelPlugin.name is '$name'; a name is letters, digits and _ . -, " +
+ "starting with a letter or digit"
+ )
+ }
+ val version = required(pluginVersion, "version")
+ if (!version.matches(SEMVER)) {
+ throw GradleException(
+ "tazelPlugin.version is '$version', which is not a Semantic Version such as 0.1.0"
+ )
+ }
+ val pluginClass = required(pluginClass, "pluginClass")
+ val imports = imports.get()
+ imports.forEach { import ->
+ if (!import.matches(IMPORT)) {
+ throw GradleException("tazelPlugin.imports has '$import', which is not a dotted name")
+ }
+ }
+
+ val entries =
+ linkedMapOf(
+ "group" to group,
+ "name" to name,
+ "version" to version,
+ "apiVersion" to apiVersion.get().toString(),
+ )
+ if (imports.isNotEmpty()) entries["imports"] = imports.joinToString(",")
+ description_.orNull?.let { entries["description"] = it }
+ authors.get().forEachIndexed { index, author -> entries["author.$index"] = author }
+ license.orNull?.let { entries["license"] = it }
+ homepage.orNull?.let { entries["homepage"] = it }
+ repository.orNull?.let { entries["repository"] = it }
+
+ val metaInf = outputDirectory.get().asFile.resolve("META-INF")
+ metaInf.resolve("services").mkdirs()
+ metaInf.resolve("tazel-plugin.properties").writeText(properties(entries))
+ metaInf
+ .resolve("services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin")
+ .writeText("$pluginClass\n")
+ }
+
+ private fun required(
+ property: Property<String>,
+ name: String,
+ ): String =
+ property.orNull?.trim()?.takeIf(String::isNotEmpty)
+ ?: throw GradleException("tazelPlugin.$name is required")
+
+ /**
+ * The entries in the format of [Properties], escaped by it, without the date line it writes, so
+ * the same settings always give the same bytes.
+ */
+ private fun properties(entries: Map<String, String>): String {
+ val text = StringWriter()
+ Properties()
+ .apply { entries.forEach { (key, value) -> setProperty(key, value) } }
+ .store(text, null)
+ val lines = text.toString().lines().filter { it.isNotEmpty() && !it.startsWith("#") }
+ val order = entries.keys.toList()
+ return lines
+ .sortedBy { line -> order.indexOf(line.substringBefore('=').replace("\\", "")) }
+ .joinToString("\n", postfix = "\n")
+ }
+
+ private companion object {
+ val GROUP = Regex("[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9][A-Za-z0-9_-]*)+")
+ val NAME = Regex("[A-Za-z0-9][A-Za-z0-9_.-]*")
+ val SEMVER =
+ Regex(
+ """(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""" +
+ """(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?"""
+ )
+ val IMPORT = Regex("[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*(?:\\.\\*)?")
+ }
+}
diff --git a/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginPlugin.kt b/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginPlugin.kt
new file mode 100644
index 0000000..e86ceaa
--- /dev/null
+++ b/gradle-plugin/sources/main/kotlin/com/progmasoft/tazel/gradle/TazelPluginPlugin.kt
@@ -0,0 +1,123 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.tazel.gradle
+
+import java.net.URI
+import java.util.Properties
+import javax.inject.Inject
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+
+/**
+ * `id("com.progmasoft.tazel.plugin")`: builds a plugin of the Tazel project file.
+ *
+ * It adds the repository of the Tazel API, https://maven.progmasoft.com/tazel, the `tazelApi()`
+ * dependency and the `tazelPlugin { }` block, from which it writes the descriptor and the service
+ * entry of the plugin into its JAR. The version of this Gradle plugin is the version of Tazel, and
+ * `tazelApi()` takes the API of that version.
+ */
+class TazelPluginPlugin : Plugin<Project> {
+ override fun apply(project: Project) {
+ project.pluginManager.apply("java-library")
+ project.repositories.maven { repository ->
+ repository.name = "ProgmasoftTazel"
+ repository.url = URI.create(REPOSITORY)
+ // Only the API comes from it; everything else keeps its own repositories.
+ repository.content { it.includeGroup(GROUP) }
+ }
+ val settings = project.extensions.create("tazelPlugin", TazelPluginExtension::class.java)
+ project.extensions.create("tazelApi", TazelApiDependency::class.java, project)
+
+ val descriptor =
+ project.tasks.register("tazelPluginDescriptor", TazelPluginDescriptorTask::class.java) { task
+ ->
+ task.group = "build"
+ task.description = "Writes the descriptor and the service entry of the Tazel plugin."
+ task.pluginGroup.set(settings.group)
+ task.pluginName.set(settings.name)
+ task.pluginVersion.set(settings.version)
+ task.pluginClass.set(settings.pluginClass)
+ task.imports.set(settings.imports)
+ task.description_.set(settings.description)
+ task.authors.set(settings.authors)
+ task.license.set(settings.license)
+ task.homepage.set(settings.homepage)
+ task.repository.set(settings.repository)
+ task.apiVersion.set(API_VERSION)
+ task.outputDirectory.set(project.layout.buildDirectory.dir("generated/tazel-plugin"))
+ }
+ project.extensions.getByType(JavaPluginExtension::class.java).sourceSets.named("main") {
+ it.resources.srcDir(descriptor)
+ }
+ }
+
+ companion object {
+ const val REPOSITORY = "https://maven.progmasoft.com/tazel"
+ const val GROUP = "com.progmasoft.tazel"
+
+ /** The plugin API of the Tazel this Gradle plugin belongs to. */
+ const val API_VERSION = 1
+
+ /** The version of this Gradle plugin, which is the version of Tazel and of its API. */
+ val VERSION: String by lazy {
+ val properties = Properties()
+ TazelPluginPlugin::class.java.getResourceAsStream("/tazel-gradle-plugin.properties").use {
+ stream ->
+ requireNotNull(stream) { "the Tazel Gradle plugin has no version resource" }
+ properties.load(stream)
+ }
+ properties.getProperty("version")
+ }
+ }
+}
+
+/**
+ * The `tazelPlugin { }` block: who the plugin is, its class, and what Tazel Portal shows about it.
+ * `tazel portal plugins push plugin.jar` publishes the JAR under its group, name and version.
+ */
+abstract class TazelPluginExtension {
+ /**
+ * The namespace of the plugin, such as `com.example`; Tazel Portal checks it by the Sonatype
+ * rules.
+ */
+ abstract val group: Property<String>
+
+ /** The name of the plugin within its group, such as `cmake`. */
+ abstract val name: Property<String>
+
+ /** A Semantic Version, such as `0.1.0`. */
+ abstract val version: Property<String>
+
+ /** The class that implements `ProjectPlugin`. */
+ abstract val pluginClass: Property<String>
+
+ /** Packages or classes the project file imports by default, such as `com.example.cmake.*`. */
+ abstract val imports: ListProperty<String>
+
+ abstract val description: Property<String>
+ abstract val authors: ListProperty<String>
+
+ /** An SPDX license expression, such as `MIT`. */
+ abstract val license: Property<String>
+
+ abstract val homepage: Property<String>
+ abstract val repository: Property<String>
+}
+
+/**
+ * `tazelApi()` in the `dependencies` block: the API of Tazel, which the plugin compiles against and
+ * its tests use. It is not bundled; Tazel provides it at run time.
+ */
+abstract class TazelApiDependency @Inject constructor(private val project: Project) {
+ operator fun invoke() {
+ val coordinate = "${TazelPluginPlugin.GROUP}:tazel-api:${TazelPluginPlugin.VERSION}"
+ project.dependencies.add("compileOnly", coordinate)
+ project.dependencies.add("testImplementation", coordinate)
+ }
+}
diff --git a/gradle-plugin/sources/test/kotlin/com/progmasoft/tazel/gradle/TazelPluginPluginTest.kt b/gradle-plugin/sources/test/kotlin/com/progmasoft/tazel/gradle/TazelPluginPluginTest.kt
new file mode 100644
index 0000000..205f1c6
--- /dev/null
+++ b/gradle-plugin/sources/test/kotlin/com/progmasoft/tazel/gradle/TazelPluginPluginTest.kt
@@ -0,0 +1,172 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+ * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+ */
+
+package com.progmasoft.tazel.gradle
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.gradle.testkit.runner.GradleRunner
+
+/** Runs the plugin in a project of its own, as a plugin author would. */
+class TazelPluginPluginTest {
+ private lateinit var project: File
+
+ @BeforeTest
+ fun create() {
+ project = Files.createTempDirectory("tazel-gradle-plugin-").toFile()
+ project.resolve("settings.gradle.kts").writeText("rootProject.name = \"native\"\n")
+ }
+
+ @AfterTest
+ fun delete() {
+ project.deleteRecursively()
+ }
+
+ private fun build(
+ tazelPlugin: String,
+ vararg arguments: String,
+ ) =
+ GradleRunner.create()
+ .withProjectDir(project)
+ .withPluginClasspath()
+ .also {
+ project
+ .resolve("build.gradle.kts")
+ .writeText(
+ """
+ plugins { id("com.progmasoft.tazel.plugin") }
+
+ // The project version is not the plugin version: tazelPlugin { version } is.
+ version = "1.2.0.3.4-build5"
+
+ tazelPlugin {
+ $tazelPlugin
+ }
+
+ dependencies { tazelApi() }
+
+ tasks.register("printApi") {
+ val compileOnly = configurations.compileOnly.get().dependencies.map { "${'$'}{it.group}:${'$'}{it.name}:${'$'}{it.version}" }
+ val tests = configurations.testImplementation.get().dependencies.map { "${'$'}{it.group}:${'$'}{it.name}:${'$'}{it.version}" }
+ doLast { println("compileOnly=${'$'}compileOnly testImplementation=${'$'}tests") }
+ }
+ """
+ .trimIndent()
+ )
+ }
+ .withArguments(*arguments, "--stacktrace")
+
+ private val identity =
+ """
+ group = "com.example.plugins"
+ name = "native"
+