diff options
| author | Leitwolf11 | 2026-11-12 18:52:50 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-11-12 18:52:50 +0300 |
| commit | 6c7e9ad4e59c46da58a121d70caa17c01006273b (patch) | |
| tree | e0c7ab793285ff4881f8bf0dbf14a229fcc1329e | |
| parent | 0a37512d10792996402c21051c6ad58e4f5f506e (diff) | |
| download | tazel-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.
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" + version = "0.1.0" + """ + + @Test + fun writesTheDescriptorAndTheServiceEntry() { + build( + """ + $identity + pluginClass = "com.example.NativePlugin" + imports = listOf("com.example.dsl.*") + description = "Builds native code; with = and : in it." + authors = listOf("First Author", "Second Author") + license = "MIT" + homepage = "https://example.com/native" + """, + "processResources", + ) + .build() + + // The descriptor says who the plugin is, from the block and not from the project. + val resources = project.resolve("build/resources/main/META-INF") + assertEquals( + listOf( + "group=com.example.plugins", + "name=native", + "version=0.1.0", + "apiVersion=${TazelPluginPlugin.API_VERSION}", + "imports=com.example.dsl.*", + "description=Builds native code; with \\= and \\: in it.", + "author.0=First Author", + "author.1=Second Author", + "license=MIT", + "homepage=https\\://example.com/native", + ), + r |