From 7c7093b5781a03270f595da3898fed7aa06681bd Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 14 Jul 2026 18:12:41 +0200 Subject: [PATCH] Provide Gradle plugin. --- .github/workflows/check.yml | 6 +- .gitignore | 1 + README.md | 224 ++++++++++ buildSrc/build.gradle.kts | 12 + .../src/main/kotlin/jvm-module.gradle.kts | 2 + gradle-plugin/build.gradle.kts | 106 +++++ .../embedcode/gradle/EmbedCodeExtension.java | 116 +++++ .../embedcode/gradle/EmbedCodePlatform.java | 120 ++++++ .../embedcode/gradle/EmbedCodePlugin.java | 167 ++++++++ .../spine/embedcode/gradle/EmbedCodeTask.java | 285 +++++++++++++ .../gradle/InstallEmbedCodeTask.java | 223 ++++++++++ .../spine/embedcode/gradle/version.properties | 1 + .../embedcode/gradle/EmbedCodePlatformSpec.kt | 82 ++++ .../embedcode/gradle/EmbedCodePluginIgTest.kt | 399 ++++++++++++++++++ 14 files changed, 1742 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java create mode 100644 gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java create mode 100644 gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 047d4a6..7bced9d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,10 +15,12 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: 21 + java-version: | + 17 + 21 - name: Set Up Gradle uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew build + run: ./gradlew build :gradle-plugin:publishToMavenLocal diff --git a/.gitignore b/.gitignore index fe9cbe9..f29958d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ +.kotlin/ .idea/ *.iml **/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5970d90 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# Embed Code Gradle Plugin + +The `io.spine.embed-code` plugin runs Embed Code without requiring developers +or CI jobs to download an executable manually. It selects the released binary +for the current platform, installs it under the project's `build/` directory, +and exposes separate `checkEmbedding` and `embedCode` tasks. + +## Apply and Configure + +After the plugin is published, apply its released version: + +```kotlin +plugins { + id("io.spine.embed-code") version "" +} +``` + +Until then, test the plugin directly from this checkout by adding its build to +the consuming project's `settings.gradle.kts`: + +```kotlin +pluginManagement { + includeBuild("../embed-code-gradle-plugin") +} +``` + +The consuming `build.gradle.kts` can then apply `id("io.spine.embed-code")` +without a version while using that included build. + +Configure Embed Code directly in `build.gradle.kts`; no `embed-code.yml` file +is required: + +```kotlin +embedCode { + codePath.set(layout.projectDirectory.dir("src/main/java")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("...") + info.set(false) + stacktrace.set(false) +} +``` + +`docsPath` is required. Configure either one unnamed `codePath` or one or more +named sources. By default, the plugin downloads the Embed Code release with the +same version as the plugin. The other properties use the same defaults as the +Embed Code command-line application. + +| Property | Default | Purpose | +| --- | --- | --- | +| `version` | Plugin version | Selects the executable release. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a `$name/` source root. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | + +If a matching CLI release has a problem, override only the executable version +while keeping the applied plugin version unchanged: + +```kotlin +embedCode { + version.set("1.2.3") +} +``` + +### Named Source Roots + +Use `namedSource` when documentation embeds code from multiple modules: + +```kotlin +embedCode { + namedSource( + "company-site", + layout.projectDirectory.dir("company-site"), + ) + namedSource( + "jxbrowser", + layout.projectDirectory.dir("browser"), + ) + docsPath.set(layout.projectDirectory) +} +``` + +Embedding instructions select these roots with `$company-site/` and +`$jxbrowser/`. The plugin writes the corresponding Embed Code configuration +into the Gradle task's temporary directory and passes it to the executable; +the project does not need an `embed-code.yml` file. + +`codePath` and `namedSource(...)` are mutually exclusive. Multiple independent +documentation targets are not exposed by this Gradle DSL. + +## Run + +Check that documentation already contains current source snippets: + +```bash +./gradlew :checkEmbedding +``` + +Update documentation in place: + +```bash +./gradlew :embedCode +``` + +Both tasks belong to the `embed code` group. `installEmbedCode` is an ungrouped +internal preparation task, so it is hidden from the normal `tasks` report but +remains visible with `tasks --all`. Gradle runs it automatically before either +execution task and reuses its output until the requested version, platform, +download URL, or build directory changes. + +The plugin prefers the `checkEmbedding` and `embedCode` task names. If one is +already occupied, it prepends underscores until it finds an available name, for +example `_checkEmbedding` or `__checkEmbedding`. Existing tasks are unchanged; +use the `tasks` report to see the selected names. The leading `:` in the +commands above selects the root task explicitly; without it, a multi-project +build may also run every subproject task with the same name. + +The plugin supports the platforms for which Embed Code currently publishes +release assets: + +- Linux AMD64. +- Windows AMD64. +- macOS AMD64 and ARM64. + +## Compatibility + +The published plugin implementation targets Java 8 bytecode. Compatibility is +tested with Gradle 7.6.3 and the current wrapper version, Gradle 9.6.1. The JVM +used to run Gradle must also satisfy the selected Gradle version's own Java +compatibility requirements. + +The plugin build uses Kotlin DSL and Kotlin tests, while its published classes +are Java. Keeping Kotlin 2.x off the consumer plugin classpath allows older +Gradle Kotlin DSL compilers to load the plugin. + +The plugin declares support for Gradle's configuration cache. Functional tests +run plugin tasks with `--configuration-cache` and verify cache reuse. + +## Develop + +Run compilation, plugin validation, unit tests, and TestKit functional tests: + +```bash +./gradlew check +``` + +The functional tests create local fake release assets. They do not download or +execute a real GitHub release. + +Publish the current plugin version to the local Maven repository when testing +it from another checkout: + +```bash +./gradlew :gradle-plugin:publishToMavenLocal +``` + +Then make the local repository available to plugin resolution in the consuming +project's `settings.gradle.kts`: + +```kotlin +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} +``` + +The `mavenLocal()` declaration must be in `pluginManagement.repositories`. +Adding it only to the consuming project's regular `repositories` block does not +make locally published Gradle plugin markers available to the `plugins` block. +The consuming build can then apply the locally published version normally: + +```kotlin +plugins { + id("io.spine.embed-code") version "" +} +``` + +The plugin publication version and its default Embed Code executable version +are both read from `version.gradle.kts`. + +## Publish + +The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Before +publishing, verify that the matching `v` GitHub release contains all +platform executables. The plugin uses its own version as the default executable +version, so publishing it before the binaries would leave new installations +without a downloadable asset. + +Request validation from the Plugin Portal without publishing a version: + +```bash +./gradlew :gradle-plugin:publishPlugins --validate-only +``` + +The Portal task requires API credentials even in validation-only mode. Provide +them through `GRADLE_PUBLISH_KEY` and `GRADLE_PUBLISH_SECRET`. The regular CI +build uses `publishToMavenLocal` instead, which assembles the plugin marker, +implementation publication, POM metadata, sources, and Javadocs without +contacting the Portal. + +To publish after validation, run: + +```bash +./gradlew :gradle-plugin:publishPlugins +``` + +The first publication of `io.spine.embed-code` requires manual Portal approval. +The publishing account must be able to establish ownership of the `io.spine` +namespace; this external approval cannot be validated by the local build. + +## License + +The plugin is available under the [Apache License 2.0](LICENSE). + +[plugin-portal]: https://plugins.gradle.org/docs/publish-plugin diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index faa9e6b..15ced43 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -36,8 +36,20 @@ plugins { */ val kotlinVersion = "2.4.0" +/** + * Version of the Gradle Plugin Publish plugin. + * + * `buildSrc` needs this version before its dependency objects are compiled. + * Keep in sync with `io.spine.embedcode.gradle.dependency.PluginPublish.version`. + */ +val pluginPublishVersion = "2.1.1" + dependencies { implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation( + "com.gradle.plugin-publish:com.gradle.plugin-publish.gradle.plugin:" + + pluginPublishVersion, + ) } kotlin { diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 8471fdb..76d1b74 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -53,6 +53,8 @@ kotlin { tasks.named("compileJava") { options.release.set(BuildSettings.productionBytecodeVersion) + // Java 8 bytecode is intentional for the documented Gradle 7.6.3 floor. + options.compilerArgs.add("-Xlint:-options") } tasks.named("compileTestKotlin") { diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index bb7b934..df73505 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,6 +24,112 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.dependency.PluginPublish +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.external.javadoc.StandardJavadocDocletOptions +import org.gradle.plugin.compatibility.compatibility + plugins { id("jvm-module") + `java-gradle-plugin` + `maven-publish` +} + +apply(plugin = PluginPublish.id) + +base { + archivesName.set("embed-code-gradle-plugin") +} + +java { + withJavadocJar() + withSourcesJar() +} + +tasks.withType().configureEach { + from(rootProject.layout.projectDirectory.file("LICENSE")) { + into("META-INF") + } +} + +// Getter docs use concise "Returns..." prose instead of duplicate `@return` tags. +tasks.withType().configureEach { + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:-missing", true) +} + +tasks.test { + inputs.property( + "embedCodeGradle7JavaHome", + providers.environmentVariable("EMBED_CODE_GRADLE_7_JAVA_HOME") + .orElse(providers.environmentVariable("JAVA_HOME_17_X64")) + .orElse(""), + ) +} + +tasks.processResources { + val versionProperties = mapOf("embedCodeVersion" to project.version.toString()) + inputs.properties(versionProperties) + filesMatching("**/version.properties") { + expand(versionProperties) + } +} + +gradlePlugin { + website.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + vcsUrl.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + plugins { + create("embedCode") { + id = "io.spine.embed-code" + implementationClass = "io.spine.embedcode.gradle.EmbedCodePlugin" + displayName = "Embed Code Gradle Plugin" + description = + "Runs Embed Code from Gradle without a separately installed executable." + tags.set(listOf("documentation", "code-samples")) + compatibility { + features { + configurationCache = true + } + } + } + } +} + +publishing { + publications.withType().configureEach { + if (name == "pluginMaven") { + artifactId = "embed-code-gradle-plugin" + } + pom { + name.set("Embed Code Gradle Plugin") + description.set( + "Runs Embed Code from Gradle without a separately installed executable.", + ) + url.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("SpineEventEngine") + name.set("Spine Event Engine") + url.set("https://github.com/SpineEventEngine") + } + } + scm { + url.set("https://github.com/SpineEventEngine/embed-code-gradle-plugin") + connection.set( + "scm:git:https://github.com/SpineEventEngine/" + + "embed-code-gradle-plugin.git", + ) + developerConnection.set( + "scm:git:ssh://git@github.com/SpineEventEngine/" + + "embed-code-gradle-plugin.git", + ) + } + } + } } diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java new file mode 100644 index 0000000..dbece38 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.Directory; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; + +/** + * Configures Embed Code for a Gradle project. + * + *

The extension maps directly to Embed Code command-line options and does + * not create or require a YAML configuration file.

+ */ +public abstract class EmbedCodeExtension { + + /** Returns the release version to download and run, defaulting to the plugin version. */ + public abstract Property getVersion(); + + /** Returns the root directory containing source files used by embedding instructions. */ + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots keyed by the name used in embedding instructions. */ + public abstract MapProperty getNamedSources(); + + /** Returns named source directories with their task dependencies. */ + public abstract ConfigurableFileCollection getNamedSourceDirectories(); + + /** + * Adds a named source root. + * + * @param name the name referenced as {@code $name} in an embedding instruction + * @param directory the source root directory + */ + public void namedSource(String name, Directory directory) { + String normalizedName = name.trim(); + if (normalizedName.isEmpty()) { + throw new InvalidUserDataException("An Embed Code source name must not be empty."); + } + getNamedSources().put(normalizedName, directory.getAsFile().getAbsolutePath()); + getNamedSourceDirectories().from(directory); + } + + /** + * Adds a named source root supplied by another Gradle provider. + * + * @param name the name referenced as {@code $name} in an embedding instruction + * @param directory the source root provider, including its task dependency + */ + public void namedSource(String name, Provider directory) { + String normalizedName = name.trim(); + if (normalizedName.isEmpty()) { + throw new InvalidUserDataException("An Embed Code source name must not be empty."); + } + getNamedSources().put( + normalizedName, + directory.map(value -> value.getAsFile().getAbsolutePath()) + ); + getNamedSourceDirectories().from(directory); + } + + /** Returns the root directory containing Markdown or HTML documentation. */ + public abstract DirectoryProperty getDocsPath(); + + /** Returns glob patterns selecting documentation files to process. */ + public abstract ListProperty getDocIncludes(); + + /** Returns glob patterns selecting documentation files to skip. */ + public abstract ListProperty getDocExcludes(); + + /** Returns text inserted between joined fragment parts. */ + public abstract Property getSeparator(); + + /** Returns whether Embed Code should print informational log messages. */ + public abstract Property getInfo(); + + /** Returns whether Embed Code should print stack traces after panics. */ + public abstract Property getStacktrace(); + + /** + *

The plugin appends {@code /v/} to this URL. + * This property primarily supports release mirrors and functional testing.

+ * + * @return the base URL containing versioned Embed Code release directories + */ + public abstract Property getDownloadBaseUrl(); +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java new file mode 100644 index 0000000..c0d0d18 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +import org.gradle.api.GradleException; + +import java.util.Locale; +import java.util.Objects; + +/** A released executable selected for an operating system and architecture. */ +final class EmbedCodePlatform { + + private final String assetName; + private final String executableName; + + /** + * Creates a platform description. + * + * @param assetName the release asset to download + * @param executableName the installed executable name + */ + EmbedCodePlatform(String assetName, String executableName) { + this.assetName = assetName; + this.executableName = executableName; + } + + /** Returns the platform-specific release asset name. */ + String getAssetName() { + return assetName; + } + + /** Returns the executable name after extraction. */ + String getExecutableName() { + return executableName; + } + + /** Selects the release asset for {@code osName} and {@code architecture}. */ + static EmbedCodePlatform detect(String osName, String architecture) { + String os = osName.toLowerCase(Locale.ROOT); + String arch = architecture.toLowerCase(Locale.ROOT); + boolean isAmd64 = arch.equals("amd64") || arch.equals("x86_64"); + boolean isArm64 = arch.equals("aarch64") || arch.equals("arm64"); + + if (os.contains("mac") && isArm64) { + return new EmbedCodePlatform( + "embed-code-macos-arm64.zip", + "embed-code-macos-arm64" + ); + } + if (os.contains("mac") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-macos-x64.zip", + "embed-code-macos-x64" + ); + } + if (os.contains("linux") && isAmd64) { + return new EmbedCodePlatform("embed-code-linux", "embed-code-linux"); + } + if (os.contains("windows") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-windows.exe", + "embed-code-windows.exe" + ); + } + throw new GradleException( + "Embed Code does not publish a binary for operating system `" + osName + + "` and architecture `" + architecture + "`." + ); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof EmbedCodePlatform)) { + return false; + } + EmbedCodePlatform that = (EmbedCodePlatform) other; + return assetName.equals(that.assetName) + && executableName.equals(that.executableName); + } + + @Override + public int hashCode() { + return Objects.hash(assetName, executableName); + } + + @Override + public String toString() { + return "EmbedCodePlatform{" + + "assetName='" + assetName + '\'' + + ", executableName='" + executableName + '\'' + + '}'; + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java new file mode 100644 index 0000000..2b74d0c --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.TaskProvider; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; + +/** Registers automatic installation and execution tasks for Embed Code. */ +public final class EmbedCodePlugin implements Plugin { + + private static final String DEFAULT_DOWNLOAD_BASE_URL = + "https://github.com/SpineEventEngine/embed-code-go/releases/download"; + private static final String VERSION_RESOURCE = + "/io/spine/embedcode/gradle/version.properties"; + private static final String TASK_GROUP = "embed code"; + + /** Applies the plugin to {@code project}. */ + @Override + public void apply(Project project) { + String checkTaskName = availableTaskName(project, "checkEmbedding"); + String embedTaskName = availableTaskName(project, "embedCode"); + EmbedCodeExtension extension = project.getExtensions().create( + "embedCode", + EmbedCodeExtension.class + ); + extension.getVersion().convention(pluginVersion()); + extension.getDocIncludes().convention(Arrays.asList("**/*.md", "**/*.html")); + extension.getDocExcludes().convention(Collections.emptyList()); + extension.getNamedSources().convention(Collections.emptyMap()); + extension.getSeparator().convention("..."); + extension.getInfo().convention(false); + extension.getStacktrace().convention(false); + extension.getDownloadBaseUrl().convention(DEFAULT_DOWNLOAD_BASE_URL); + + EmbedCodePlatform platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch") + ); + TaskProvider installTask = project.getTasks().register( + "installEmbedCode", + InstallEmbedCodeTask.class, + task -> { + task.setDescription("Installs the requested Embed Code executable"); + task.getVersion().set(extension.getVersion()); + task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); + task.getAssetName().set(platform.getAssetName()); + task.getExecutableName().set(platform.getExecutableName()); + task.getExecutableFile().set( + project.getLayout().getBuildDirectory().file( + extension.getVersion().map( + version -> "embed-code/" + version + + '/' + platform.getExecutableName() + ) + ) + ); + } + ); + + registerExecutionTask( + project, + extension, + installTask, + checkTaskName, + "Checks embedded code snippets are up to date", + "check" + ); + registerExecutionTask( + project, + extension, + installTask, + embedTaskName, + "Updates embedded code snippets from source files", + "embed" + ); + } + + /** Registers one mode-specific execution task backed by {@code installTask}. */ + private static void registerExecutionTask( + Project project, + EmbedCodeExtension extension, + TaskProvider installTask, + String name, + String description, + String mode + ) { + project.getTasks().register(name, EmbedCodeTask.class, task -> { + task.setGroup(TASK_GROUP); + task.setDescription(description); + task.getMode().set(mode); + task.getCodePath().set(extension.getCodePath()); + task.getNamedSources().set(extension.getNamedSources()); + task.getNamedSourceDirectories().from(extension.getNamedSourceDirectories()); + task.getDocsPath().set(extension.getDocsPath()); + task.getDocIncludes().set(extension.getDocIncludes()); + task.getDocExcludes().set(extension.getDocExcludes()); + task.getSeparator().set(extension.getSeparator()); + task.getInfo().set(extension.getInfo()); + task.getStacktrace().set(extension.getStacktrace()); + task.getExecutableFile().set( + installTask.flatMap(InstallEmbedCodeTask::getExecutableFile) + ); + task.getWorkingDirectory().set(project.getLayout().getProjectDirectory()); + }); + } + + /** Returns {@code preferredName}, prepending underscores until the task name is unused. */ + private static String availableTaskName(Project project, String preferredName) { + String candidate = preferredName; + while (project.getTasks().getNames().contains(candidate)) { + candidate = '_' + candidate; + } + return candidate; + } + + /** Returns the Embed Code version packaged into the plugin at build time. */ + private static String pluginVersion() { + Properties properties = new Properties(); + try (InputStream resource = EmbedCodePlugin.class.getResourceAsStream(VERSION_RESOURCE)) { + if (resource == null) { + throw new GradleException( + "Embed Code plugin version resource is missing: " + VERSION_RESOURCE + ); + } + properties.load(resource); + } catch (IOException error) { + throw new GradleException("Could not read the Embed Code plugin version.", error); + } + + String version = properties.getProperty("version", "").trim(); + if (version.isEmpty()) { + throw new GradleException("The Embed Code plugin version must not be empty."); + } + return version; + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java new file mode 100644 index 0000000..43cc072 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java @@ -0,0 +1,285 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.work.DisableCachingByDefault; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** Runs Embed Code in either check or embed mode. */ +@DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") +public abstract class EmbedCodeTask extends DefaultTask { + + /** Returns process execution without project access at execution time. */ + @Inject + protected abstract ExecOperations getExecOperations(); + + /** Returns the execution mode assigned by the plugin. */ + @Input + public abstract Property getMode(); + + /** Returns the source root passed to {@code -code-path}. */ + @InputDirectory + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots included in an internally generated configuration. */ + @Input + public abstract MapProperty getNamedSources(); + + /** Returns named source directories with their producing task dependencies. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getNamedSourceDirectories(); + + /** Returns the documentation root passed to {@code -docs-path}. */ + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getDocsPath(); + + /** Returns documentation include patterns passed to {@code -doc-includes}. */ + @Input + public abstract ListProperty getDocIncludes(); + + /** Returns documentation exclude patterns passed to {@code -doc-excludes}. */ + @Input + public abstract ListProperty getDocExcludes(); + + /** Returns the fragment separator passed to {@code -separator}. */ + @Input + public abstract Property getSeparator(); + + /** Returns whether informational logging is enabled. */ + @Input + public abstract Property getInfo(); + + /** Returns whether panic stack traces are enabled. */ + @Input + public abstract Property getStacktrace(); + + /** Returns the installed platform executable. */ + @InputFile + @PathSensitive(PathSensitivity.NONE) + public abstract RegularFileProperty getExecutableFile(); + + /** Returns the process working directory. */ + @Internal + public abstract DirectoryProperty getWorkingDirectory(); + + /** Executes Embed Code with arguments derived from the Gradle extension. */ + @TaskAction + public void runEmbedCode() { + Map namedSources = new TreeMap<>(getNamedSources().get()); + boolean hasDirectSource = getCodePath().isPresent(); + boolean hasNamedSources = !namedSources.isEmpty(); + if (hasDirectSource == hasNamedSources) { + throw new GradleException( + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + ); + } + + List arguments = new ArrayList<>(); + arguments.add("-mode=" + getMode().get()); + if (hasNamedSources) { + arguments.add("-config-path=" + writeNamedSourceConfiguration(namedSources)); + } else { + arguments.add("-code-path=" + getCodePath().get().getAsFile().getAbsolutePath()); + arguments.add("-docs-path=" + getDocsPath().get().getAsFile().getAbsolutePath()); + if (!getDocIncludes().get().isEmpty()) { + arguments.add("-doc-includes=" + String.join(",", getDocIncludes().get())); + } + if (!getDocExcludes().get().isEmpty()) { + arguments.add("-doc-excludes=" + String.join(",", getDocExcludes().get())); + } + arguments.add("-separator=" + getSeparator().get()); + arguments.add("-info=" + getInfo().get()); + arguments.add("-stacktrace=" + getStacktrace().get()); + } + + getExecOperations().exec(spec -> { + spec.executable(getExecutableFile().get().getAsFile()); + spec.args(arguments); + spec.setWorkingDir(getWorkingDirectory().get().getAsFile()); + }); + } + + /** Writes the generated configuration used when named source roots are configured. */ + private Path writeNamedSourceConfiguration(Map namedSources) { + Map normalizedSources = new TreeMap<>(); + for (Map.Entry source : namedSources.entrySet()) { + Path path = Paths.get(source.getValue()); + if (!path.isAbsolute()) { + path = getWorkingDirectory().get().getAsFile().toPath().resolve(path); + } + path = path.normalize().toAbsolutePath(); + if (!Files.isDirectory(path)) { + throw new GradleException( + "Embed Code source `" + source.getKey() + "` is not a directory: " + path + ); + } + normalizedSources.put(source.getKey(), path.toString()); + } + + String json = createConfigurationJson( + normalizedSources, + getDocsPath().get().getAsFile().getAbsolutePath(), + getDocIncludes().get(), + getDocExcludes().get(), + getSeparator().get(), + getInfo().get(), + getStacktrace().get() + ); + Path configuration = getTemporaryDir().toPath().resolve("embed-code.json"); + try { + Files.write(configuration, json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new GradleException( + "Could not write the generated Embed Code configuration to " + + configuration + '.', + exception + ); + } + return configuration; + } + + /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + static String createConfigurationJson( + Map namedSources, + String docsPath, + List docIncludes, + List docExcludes, + String separator, + boolean info, + boolean stacktrace + ) { + StringBuilder json = new StringBuilder(); + json.append("{\n \"code-path\": [\n"); + int index = 0; + for (Map.Entry source : namedSources.entrySet()) { + if (index > 0) { + json.append(",\n"); + } + json.append(" {\"name\": "); + appendJsonString(json, source.getKey()); + json.append(", \"path\": "); + appendJsonString(json, source.getValue()); + json.append('}'); + index++; + } + json.append("\n ],\n \"docs-path\": "); + appendJsonString(json, docsPath); + json.append(",\n \"doc-includes\": "); + appendJsonArray(json, docIncludes); + json.append(",\n \"doc-excludes\": "); + appendJsonArray(json, docExcludes); + json.append(",\n \"separator\": "); + appendJsonString(json, separator); + json.append(",\n \"info\": ").append(info); + json.append(",\n \"stacktrace\": ").append(stacktrace); + json.append("\n}\n"); + return json.toString(); + } + + /** Appends a JSON array containing {@code values}. */ + private static void appendJsonArray(StringBuilder json, List values) { + json.append('['); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + json.append(", "); + } + appendJsonString(json, values.get(i)); + } + json.append(']'); + } + + /** Appends {@code value} as an escaped JSON string. */ + private static void appendJsonString(StringBuilder json, String value) { + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + switch (character) { + case '"': + json.append("\\\""); + break; + case '\\': + json.append("\\\\"); + break; + case '\b': + json.append("\\b"); + break; + case '\f': + json.append("\\f"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (character < 0x20) { + json.append(String.format(Locale.ROOT, "\\u%04x", (int) character)); + } else { + json.append(character); + } + } + } + json.append('"'); + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java new file mode 100644 index 0000000..e8a4e7a --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java @@ -0,0 +1,223 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLConnection; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Downloads and prepares the Embed Code executable selected for the host. + * + *

The output file gives Gradle normal up-to-date behavior, so a successfully + * installed version is reused by later invocations.

+ */ +@DisableCachingByDefault( + because = "The downloaded release asset is already reused as a task output" +) +public abstract class InstallEmbedCodeTask extends DefaultTask { + + private static final int CONNECT_TIMEOUT_MILLIS = 30_000; + private static final int READ_TIMEOUT_MILLIS = 120_000; + + /** Returns the Embed Code release version. */ + @Input + public abstract Property getVersion(); + + /** Returns the base URL containing versioned release directories. */ + @Input + public abstract Property getDownloadBaseUrl(); + + /** Returns the platform-specific release asset name. */ + @Input + public abstract Property getAssetName(); + + /** Returns the executable name expected inside an archive or used directly. */ + @Input + public abstract Property getExecutableName(); + + /** Returns the installed executable used by Embed Code execution tasks. */ + @OutputFile + public abstract RegularFileProperty getExecutableFile(); + + /** Downloads, extracts when necessary, and marks the executable runnable. */ + @TaskAction + public void install() { + String requestedVersion = getVersion().get().trim(); + if (requestedVersion.isEmpty()) { + throw new GradleException("Embed Code version must not be empty."); + } + + String releaseTag = requestedVersion.startsWith("v") + ? requestedVersion + : "v" + requestedVersion; + String asset = getAssetName().get(); + String baseUrl = trimTrailingSlashes(getDownloadBaseUrl().get()); + URI source = URI.create(baseUrl + '/' + releaseTag + '/' + asset); + Path destination = getExecutableFile().get().getAsFile().toPath(); + Path download = getTemporaryDir().toPath().resolve(asset); + Path preparedExecutable = getTemporaryDir().toPath() + .resolve(getExecutableName().get()); + + try { + Files.createDirectories(destination.getParent()); + getLogger().lifecycle("Downloading Embed Code {} from {}", requestedVersion, source); + download(source, download); + + if (asset.endsWith(".zip")) { + extractExecutable(download, getExecutableName().get(), preparedExecutable); + } else { + Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING); + } + + if (!preparedExecutable.toFile().setExecutable(true, false)) { + throw new GradleException( + "Could not make `" + preparedExecutable + "` executable." + ); + } + moveAtomically(preparedExecutable, destination); + } catch (IOException exception) { + throw new GradleException( + "Could not install Embed Code from " + source + '.', + exception + ); + } + } + + /** Downloads {@code source} into {@code destination}, reporting HTTP failures clearly. */ + private static void download(URI source, Path destination) { + URLConnection connection = null; + try { + connection = source.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + + if (connection instanceof HttpURLConnection) { + HttpURLConnection http = (HttpURLConnection) connection; + http.setInstanceFollowRedirects(true); + int status = http.getResponseCode(); + if (status < 200 || status > 299) { + throw new GradleException( + "Could not download Embed Code: HTTP " + status + + " from " + source + '.' + ); + } + } + + try (InputStream input = connection.getInputStream(); + OutputStream output = Files.newOutputStream(destination)) { + copy(input, output); + } + } catch (IOException exception) { + throw new GradleException( + "Could not download Embed Code from " + source + '.', + exception + ); + } finally { + if (connection instanceof HttpURLConnection) { + ((HttpURLConnection) connection).disconnect(); + } + } + } + + /** Extracts {@code entryName} from {@code archive} into {@code destination}. */ + private static void extractExecutable(Path archive, String entryName, Path destination) + throws IOException { + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry = zip.getNextEntry(); + while (entry != null) { + String fileName = entry.getName(); + int slash = fileName.lastIndexOf('/'); + if (slash >= 0) { + fileName = fileName.substring(slash + 1); + } + if (!entry.isDirectory() && fileName.equals(entryName)) { + try (OutputStream output = Files.newOutputStream(destination)) { + copy(zip, output); + } + return; + } + zip.closeEntry(); + entry = zip.getNextEntry(); + } + } + throw new GradleException( + "Archive `" + archive + "` does not contain `" + entryName + "`." + ); + } + + /** Copies all bytes from {@code input} into {@code output}. */ + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[8_192]; + int count = input.read(buffer); + while (count >= 0) { + output.write(buffer, 0, count); + count = input.read(buffer); + } + } + + /** Moves {@code source} to {@code destination}, atomically when supported. */ + private static void moveAtomically(Path source, Path destination) throws IOException { + try { + Files.move( + source, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Removes trailing slashes without changing a URL scheme. */ + private static String trimTrailingSlashes(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } +} diff --git a/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties new file mode 100644 index 0000000..d5e9750 --- /dev/null +++ b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties @@ -0,0 +1 @@ +version=${embedCodeVersion} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt new file mode 100644 index 0000000..607154d --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`EmbedCodePlatform` should") +internal class EmbedCodePlatformSpec { + + @Test + fun `select Apple silicon asset`() { + assertEquals( + EmbedCodePlatform("embed-code-macos-arm64.zip", "embed-code-macos-arm64"), + EmbedCodePlatform.detect("Mac OS X", "aarch64"), + ) + } + + @Test + fun `select Intel macOS asset`() { + assertEquals( + EmbedCodePlatform("embed-code-macos-x64.zip", "embed-code-macos-x64"), + EmbedCodePlatform.detect("Mac OS X", "x86_64"), + ) + } + + @Test + fun `select Linux asset`() { + assertEquals( + EmbedCodePlatform("embed-code-linux", "embed-code-linux"), + EmbedCodePlatform.detect("Linux", "amd64"), + ) + } + + @Test + fun `select Windows asset`() { + assertEquals( + EmbedCodePlatform("embed-code-windows.exe", "embed-code-windows.exe"), + EmbedCodePlatform.detect("Windows 11", "amd64"), + ) + } + + @Test + fun `reject platform without release binary`() { + val error = assertThrows(GradleException::class.java) { + EmbedCodePlatform.detect("Linux", "aarch64") + } + + assertEquals( + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`.", + error.message, + ) + } +} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt new file mode 100644 index 0000000..11983ff --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@DisplayName("`EmbedCodePlugin` should") +internal class EmbedCodePluginIgTest { + + @TempDir + private lateinit var projectDirectory: Path + + private lateinit var releaseDirectory: Path + + @BeforeEach + fun setUp() { + Files.createDirectories(projectDirectory.resolve("code")) + Files.createDirectories(projectDirectory.resolve("docs")) + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + "rootProject.name = \"test-project\"\n", + ) + + releaseDirectory = projectDirectory.resolve("releases") + createFakeRelease(releaseDirectory) + writeBuildFile() + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle configuration`() { + val result = runner(":checkEmbedding").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments shouldContain "-code-path=${projectDirectory.resolve("code").toRealPath()}" + arguments shouldContain "-docs-path=${projectDirectory.resolve("docs").toRealPath()}" + arguments shouldContain "-doc-includes=**/*.md,**/*.html" + arguments shouldContain "-doc-excludes=drafts/**,generated/**" + arguments shouldContain "-separator=---" + arguments shouldContain "-info=true" + arguments shouldContain "-stacktrace=true" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse the configuration cache`() { + runner(":checkEmbedding").build() + + val result = runner(":checkEmbedding").build() + + result.output shouldContain "Reusing configuration cache." + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + + @Test + fun `install platform release asset`() { + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + val installedExecutable = projectDirectory.resolve( + "build/embed-code/${bundledVersion()}/$executableName", + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `allow overriding the bundled Embed Code version`() { + val overrideVersion = "0.0.0-test" + createFakeRelease(releaseDirectory, overrideVersion) + writeBuildFile(overrideVersion) + + val result = runner(":checkEmbedding").build() + + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + Files.exists( + projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), + ) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 7_6_3`() { + val javaHome = System.getenv("EMBED_CODE_GRADLE_7_JAVA_HOME") + ?: System.getenv("JAVA_HOME_17_X64") + assumeTrue( + !javaHome.isNullOrBlank(), + "Set EMBED_CODE_GRADLE_7_JAVA_HOME to a JDK supported by Gradle 7.6.3.", + ) + + val result = runner(":checkEmbedding", useConfigurationCache = false) + .withGradleVersion("7.6.3") + .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse installation when running embed mode`() { + runner(":checkEmbedding").build() + releaseDirectory.toFile().deleteRecursively() + + val result = runner(":embedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.UP_TO_DATE + result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run with named source roots and generated configuration`() { + Files.createDirectories(projectDirectory.resolve("company-site")) + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile() + + val result = runner(":checkEmbedding").build() + + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments.single { it.startsWith("-config-path=") } + + val configuration = Files.readString(projectDirectory.resolve("generated-config.json")) + configuration shouldContain "\"name\": \"company-site\"" + val companySitePath = projectDirectory.resolve("company-site").toRealPath() + val browserPath = projectDirectory.resolve("browser").toRealPath() + configuration shouldContain "\"path\": \"$companySitePath\"" + configuration shouldContain "\"name\": \"jxbrowser\"" + configuration shouldContain "\"path\": \"$browserPath\"" + configuration shouldContain "\"docs-path\": \"${projectDirectory.toRealPath()}\"" + } + + @Test + fun `reject direct and named source roots together`() { + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile(includeDirectSource = true) + + val result = runner(":checkEmbedding").buildAndFail() + + result.output shouldContain + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + } + + @Test + fun `report missing release asset`() { + releaseDirectory.toFile().deleteRecursively() + + val result = runner(":checkEmbedding").buildAndFail() + + result.output shouldContain "Could not download Embed Code" + } + + @Test + fun `list only execution tasks under the Embed Code group`() { + val result = runner("tasks").build() + + result.output shouldContain "Embed code tasks" + result.output shouldContain "checkEmbedding - Checks embedded code snippets are up to date" + result.output shouldContain "embedCode - Updates embedded code snippets from source files" + result.output shouldNotContain "installEmbedCode" + + val allTasks = runner("tasks", "--all").build() + allTasks.output shouldContain + "installEmbedCode - Installs the requested Embed Code executable" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied checkEmbedding task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("checkEmbedding") + tasks.register("_checkEmbedding") + } + """.trimIndent(), + ) + + val result = runner(":__checkEmbedding").build() + + result.task(":__checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied embedCode task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("embedCode") + tasks.register("_embedCode") + } + """.trimIndent(), + ) + + val result = runner(":__embedCode").build() + + result.task(":__embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" + } + + /** Creates a runner using the plugin-under-test classpath. */ + private fun runner( + vararg arguments: String, + useConfigurationCache: Boolean = true, + ): GradleRunner { + val gradleArguments = arguments.toMutableList() + if (useConfigurationCache) { + gradleArguments.add("--configuration-cache") + } + gradleArguments.add("--stacktrace") + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withArguments(gradleArguments) + .withPluginClasspath() + } + + /** Writes a consuming build configured entirely through the plugin extension. */ + private fun writeBuildFile(version: String? = null) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + $versionConfiguration + downloadBaseUrl.set("$baseUrl") + codePath.set(layout.projectDirectory.dir("code")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("---") + info.set(true) + stacktrace.set(true) + } + """.trimIndent(), + ) + } + + /** Writes a consuming build with two named source roots and no YAML file. */ + private fun writeNamedSourcesBuildFile(includeDirectSource: Boolean = false) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val directSource = if (includeDirectSource) { + "codePath.set(layout.projectDirectory.dir(\"code\"))" + } else { + "" + } + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + downloadBaseUrl.set("$baseUrl") + $directSource + namedSource("company-site", layout.projectDirectory.dir("company-site")) + namedSource("jxbrowser", layout.projectDirectory.dir("browser")) + docsPath.set(layout.projectDirectory) + } + """.trimIndent(), + ) + } + + /** Creates a host-specific fake release asset that records received arguments. */ + private fun createFakeRelease(root: Path, version: String = bundledVersion()) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("v$version") + Files.createDirectories(versionDirectory) + val executable = projectDirectory.resolve(platform.executableName) + Files.writeString( + executable, + """ + #!/bin/sh + : > arguments.txt + for argument in "${'$'}@"; do + printf '%s\n' "${'$'}argument" >> arguments.txt + case "${'$'}argument" in + -mode=check) printf 'check\n' > mode.txt ;; + -mode=embed) printf 'embed\n' > mode.txt ;; + -config-path=*) cp "${'$'}{argument#-config-path=}" generated-config.json ;; + esac + done + """.trimIndent() + "\n", + ) + + val asset = versionDirectory.resolve(platform.assetName) + if (platform.assetName.endsWith(".zip")) { + ZipOutputStream(Files.newOutputStream(asset)).use { zip -> + zip.putNextEntry(ZipEntry(platform.executableName)) + Files.newInputStream(executable).use { it.copyTo(zip) } + zip.closeEntry() + } + } else { + Files.copy(executable, asset) + } + } + + /** Returns the Embed Code version bundled into the plugin resources. */ + private fun bundledVersion(): String { + val properties = Properties() + val resource = requireNotNull( + EmbedCodePlugin::class.java.getResourceAsStream( + "/io/spine/embedcode/gradle/version.properties", + ), + ) + resource.use { properties.load(it) } + return requireNotNull(properties.getProperty("version")).trim() + } +} + +private infix fun T.shouldBe(expected: T) { + assertEquals(expected, this) +} + +private infix fun Iterable.shouldContain(expected: T) { + assertTrue(any { it == expected }, "Expected collection to contain <$expected>.") +} + +private infix fun String.shouldContain(expected: String) { + assertTrue(contains(expected), "Expected text to contain <$expected>.") +} + +private infix fun String.shouldNotContain(expected: String) { + assertFalse(contains(expected), "Expected text not to contain <$expected>.") +}