diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 58ed417..3310d81 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,17 +1,38 @@ name: Check -on: pull_request +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 15 steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 + + - name: Set Up Java 17 for Compatibility Tests + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Save Java 17 Toolchain + shell: bash + run: echo "EMBED_CODE_JAVA_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" - - name: Set Up Java + - name: Set Up Java 25 uses: actions/setup-java@v5 with: distribution: temurin @@ -21,4 +42,10 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew build + shell: bash + env: + EMBED_CODE_REAL_TEST: ${{ github.event_name == 'workflow_dispatch' && matrix.os == 'ubuntu-latest' }} + run: >- + ./gradlew + -Dorg.gradle.java.installations.paths="$EMBED_CODE_JAVA_17_HOME" + 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..fbea672 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# 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 latest Embed Code release +from GitHub Releases. Plugin and application versions are independent. The +other properties use the same defaults as the Embed Code command-line +application. + +| Property | Default | Purpose | +|--------------------------------|--------------------------------|----------------------------------------------| +| `version` | Latest GitHub release | Pins a specific executable release when set. | +| `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. | + +For CI and reproducible builds, pin the executable version while keeping the +applied plugin version unchanged: + +```kotlin +embedCode { + version.set("1.2.4") +} +``` + +Leaving `version` unset follows the latest release and requires a network +request on every invocation; this is convenient for local use but is not +recommended for CI. + +### 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. Without an explicit `version`, it downloads the current latest +release on every invocation. A pinned version uses Gradle's normal up-to-date +behavior and reuses its installed executable. + +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. This fallback covers tasks +registered before this plugin is applied. A build must not register either +preferred name later in the same project. 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 and Windows installation paths run in CI: + +- Linux AMD64. +- Windows AMD64. +- macOS AMD64 and ARM64. + +## Compatibility + +The plugin requires Gradle 8.14.4 or newer. Its published classes require Java +17, and the JVM running the build must also be supported by the selected Gradle +version. Compatibility is tested with Gradle 8.14.4, Gradle 9.0.0, and the +current wrapper version, Gradle 9.6.1. + +The plugin implementation, build scripts, and tests are written in Kotlin. +Consumers do not need to install Kotlin or apply a Kotlin plugin because Gradle +provides the Kotlin runtime. The project uses the Kotlin 2.4.10 compiler but +targets Kotlin 2.0 language and API levels because Gradle 8.14.4 embeds Kotlin +2.0.21. Published classes target Java 17 bytecode. + +The build uses a JDK 25 toolchain. TestKit runs on a Java 17 toolchain so that +the same suite can exercise the minimum Gradle version and Gradle 9.0.0. + +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 regular functional tests create local fake release assets and run them with +Gradle 8.14.4, Gradle 9.0.0, and the wrapper version. JDK 17 and JDK 25 must +both be discoverable as Gradle toolchains when running the complete suite +locally. + +Manually dispatch the `Check` workflow to run an additional Linux smoke test +against the latest real release. That test exercises the real CLI flags and +verifies that Embed Code's YAML parser accepts the generated JSON configuration +used for named source roots. + +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 is configured in `version.gradle.kts`. Embed +Code application versions are resolved independently at execution time. + +## Publish + +The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Its +publication version does not need to match an Embed Code application version. +By default, every published plugin version follows the latest stable GitHub +release; consumers can pin an application version through the extension. + +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 cc106c3..4fb1bdd 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -36,8 +36,20 @@ plugins { */ val kotlinVersion = "2.4.10" +/** + * 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/io/spine/embedcode/gradle/BuildSettings.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt index 116ef5b..b535b0a 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt @@ -33,10 +33,9 @@ object BuildSettings { const val javaVersion = 25 /** - * JVM bytecode version produced for published code. + * JVM bytecode version produced by the project. * - * Java 8 bytecode keeps the plugin loadable by the minimum supported Gradle - * version, 7.6.3, while builds and tests use Java 25. + * Java 17 is supported by Gradle 8.14.4 and required by Gradle 9. */ - const val productionBytecodeVersion = 8 + const val bytecodeVersion = 17 } diff --git a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt index 123470c..a4d1a16 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/JUnit.kt @@ -29,7 +29,7 @@ package io.spine.embedcode.gradle.dependency /** JUnit dependencies used by tests. */ object JUnit { - const val version = "6.1.1" + const val version = "6.1.2" private const val group = "org.junit.jupiter" // https://github.com/junit-team/junit5 diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 8471fdb..f9fd07c 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -27,16 +27,14 @@ import io.spine.embedcode.gradle.BuildSettings import io.spine.embedcode.gradle.dependency.JUnit import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { `java-library` kotlin("jvm") } -fun jvmTarget(version: Int): JvmTarget = JvmTarget.fromTarget( - if (version == 8) "1.$version" else version.toString(), -) +fun jvmTarget(version: Int): JvmTarget = JvmTarget.fromTarget(version.toString()) java { toolchain { @@ -46,17 +44,17 @@ java { kotlin { compilerOptions { - jvmTarget.set(jvmTarget(BuildSettings.productionBytecodeVersion)) + jvmTarget.set(jvmTarget(BuildSettings.bytecodeVersion)) + // Gradle 8.14.4 embeds Kotlin 2.0.21. Keep plugin metadata and + // standard-library API usage compatible with that runtime. + languageVersion.set(KotlinVersion.KOTLIN_2_0) + apiVersion.set(KotlinVersion.KOTLIN_2_0) freeCompilerArgs.add("-Xjsr305=strict") } } -tasks.named("compileJava") { - options.release.set(BuildSettings.productionBytecodeVersion) -} - -tasks.named("compileTestKotlin") { - compilerOptions.jvmTarget.set(jvmTarget(BuildSettings.javaVersion)) +tasks.withType().configureEach { + options.release.set(BuildSettings.bytecodeVersion) } dependencies { @@ -66,4 +64,9 @@ dependencies { tasks.test { useJUnitPlatform() + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(BuildSettings.bytecodeVersion)) + }, + ) } diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index bb7b934..635d653 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,6 +24,96 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.dependency.Kotlin +import io.spine.embedcode.gradle.dependency.PluginPublish +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.plugin.compatibility.compatibility + plugins { id("jvm-module") + `java-gradle-plugin` + `maven-publish` +} + +apply(plugin = PluginPublish.id) + +dependencies { + // Gradle supplies Kotlin at runtime, so the plugin does not publish the standard library. + compileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") + testCompileOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") +} + +base { + archivesName.set("embed-code-gradle-plugin") +} + +java { + withJavadocJar() + withSourcesJar() +} + +tasks.withType().configureEach { + from(rootProject.layout.projectDirectory.file("LICENSE")) { + into("META-INF") + } +} + +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/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt new file mode 100644 index 0000000..be3d61e --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt @@ -0,0 +1,127 @@ +/* + * 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 { + + private val configuredSourceNames = mutableSetOf() + + /** An optional release version, with the latest release used when absent. */ + public abstract val version: Property + + /** The root directory containing source files used by embedding instructions. */ + public abstract val codePath: DirectoryProperty + + /** Named source roots keyed by the name used in embedding instructions. */ + public abstract val namedSources: MapProperty + + /** Named source directories with their task dependencies. */ + public abstract val namedSourceDirectories: ConfigurableFileCollection + + /** + * Adds a named source root. + * + * @param name the name referenced as `$name` in an embedding instruction + * @param directory the source root directory + */ + public fun namedSource(name: String, directory: Directory) { + val normalizedName = registerSourceName(name) + namedSources.put(normalizedName, directory.asFile.absolutePath) + namedSourceDirectories.from(directory) + } + + /** + * Adds a named source root supplied by another Gradle provider. + * + * @param name the name referenced as `$name` in an embedding instruction + * @param directory the source root provider, including its task dependency + */ + public fun namedSource(name: String, directory: Provider) { + val normalizedName = registerSourceName(name) + namedSources.put( + normalizedName, + directory.map { value -> value.asFile.absolutePath }, + ) + namedSourceDirectories.from(directory) + } + + /** The root directory containing Markdown or HTML documentation. */ + public abstract val docsPath: DirectoryProperty + + /** Glob patterns selecting documentation files to process. */ + public abstract val docIncludes: ListProperty + + /** Glob patterns selecting documentation files to skip. */ + public abstract val docExcludes: ListProperty + + /** Text inserted between joined fragment parts. */ + public abstract val separator: Property + + /** Whether Embed Code should print informational log messages. */ + public abstract val info: Property + + /** Whether Embed Code should print stack traces after panics. */ + public abstract val stacktrace: Property + + /** + * The base URL of the Embed Code releases. + * + * The plugin appends `/latest/download/` when no version is + * configured, or `/download/v/` for an explicit + * version. This property primarily supports release mirrors and functional + * testing. + */ + public abstract val downloadBaseUrl: Property + + private fun registerSourceName(name: String): String { + val normalizedName = name.trim() + if (normalizedName.isEmpty()) { + throw InvalidUserDataException("An Embed Code source name must not be empty.") + } + if (!configuredSourceNames.add(normalizedName)) { + throw InvalidUserDataException( + "Embed Code source `$normalizedName` is already configured.", + ) + } + return normalizedName + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt new file mode 100644 index 0000000..8dd09dc --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt @@ -0,0 +1,83 @@ +/* + * 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 + +/** A released executable selected for an operating system and architecture. */ +internal data class EmbedCodePlatform( + val assetName: String, + val executableName: String, +) { + + companion object { + + /** Returns the stable installed executable name for [osName]. */ + fun installedExecutableName(osName: String): String = + if (osName.lowercase(Locale.ROOT).contains("windows")) { + "embed-code.exe" + } else { + "embed-code" + } + + /** Selects the release asset for [osName] and [architecture]. */ + fun detect(osName: String, architecture: String): EmbedCodePlatform { + val os = osName.lowercase(Locale.ROOT) + val arch = architecture.lowercase(Locale.ROOT) + val isAmd64 = arch == "amd64" || arch == "x86_64" + val isArm64 = arch == "aarch64" || arch == "arm64" + + return when { + os.contains("mac") && isArm64 -> EmbedCodePlatform( + "embed-code-macos-arm64.zip", + "embed-code-macos-arm64", + ) + + os.contains("mac") && isAmd64 -> EmbedCodePlatform( + "embed-code-macos-x64.zip", + "embed-code-macos-x64", + ) + + os.contains("linux") && isAmd64 -> EmbedCodePlatform( + "embed-code-linux", + "embed-code-linux", + ) + + os.contains("windows") && isAmd64 -> EmbedCodePlatform( + "embed-code-windows.exe", + "embed-code-windows.exe", + ) + + else -> throw GradleException( + "Embed Code does not publish a binary for operating system `$osName` " + + "and architecture `$architecture`.", + ) + } + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt new file mode 100644 index 0000000..9d69888 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -0,0 +1,134 @@ +/* + * 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.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** Registers automatic installation and execution tasks for Embed Code. */ +public class EmbedCodePlugin : Plugin { + + /** Applies the plugin to [project]. */ + override fun apply(project: Project) { + val checkTaskName = availableTaskName(project, "checkEmbedding") + val embedTaskName = availableTaskName(project, "embedCode") + val extension = project.extensions.create( + "embedCode", + EmbedCodeExtension::class.java, + ) + extension.docIncludes.convention(listOf("**/*.md", "**/*.html")) + extension.docExcludes.convention(emptyList()) + extension.namedSources.convention(emptyMap()) + extension.separator.convention("...") + extension.info.convention(false) + extension.stacktrace.convention(false) + extension.downloadBaseUrl.convention(DEFAULT_DOWNLOAD_BASE_URL) + + val operatingSystem = System.getProperty("os.name").orEmpty() + val architecture = System.getProperty("os.arch").orEmpty() + val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) + val installTask = project.tasks.register( + "installEmbedCode", + InstallEmbedCodeTask::class.java, + ) { task -> + task.description = "Installs the requested Embed Code executable" + task.version.set(extension.version) + task.downloadBaseUrl.set(extension.downloadBaseUrl) + task.operatingSystem.set(operatingSystem) + task.architecture.set(architecture) + task.executableFile.set( + project.layout.buildDirectory.file( + extension.version.map { version -> + "embed-code/$version/$installedExecutableName" + }.orElse("embed-code/latest/$installedExecutableName"), + ), + ) + task.outputs.upToDateWhen { task.version.isPresent } + } + + 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", + ) + } + + private companion object { + + const val DEFAULT_DOWNLOAD_BASE_URL = + "https://github.com/SpineEventEngine/embed-code-go/releases" + const val TASK_GROUP = "embed code" + + /** Registers one mode-specific execution task backed by [installTask]. */ + fun registerExecutionTask( + project: Project, + extension: EmbedCodeExtension, + installTask: TaskProvider, + name: String, + description: String, + mode: String, + ) { + project.tasks.register(name, EmbedCodeTask::class.java) { task -> + task.group = TASK_GROUP + task.description = description + task.mode.set(mode) + task.codePath.set(extension.codePath) + task.namedSources.set(extension.namedSources) + task.namedSourceDirectories.from(extension.namedSourceDirectories) + task.docsPath.set(extension.docsPath) + task.docIncludes.set(extension.docIncludes) + task.docExcludes.set(extension.docExcludes) + task.separator.set(extension.separator) + task.info.set(extension.info) + task.stacktrace.set(extension.stacktrace) + task.executableFile.set(installTask.flatMap { it.executableFile }) + task.workingDirectory.set(project.layout.projectDirectory) + } + } + + /** Returns [preferredName], prepending underscores until it is unused. */ + fun availableTaskName(project: Project, preferredName: String): String { + var candidate = preferredName + while (project.tasks.names.contains(candidate)) { + candidate = "_$candidate" + } + return candidate + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt new file mode 100644 index 0000000..8f8306d --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -0,0 +1,269 @@ +/* + * 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 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.Locale +import java.util.TreeMap +import javax.inject.Inject + +/** Runs Embed Code in either check or embed mode. */ +@DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") +public abstract class EmbedCodeTask : DefaultTask() { + + /** Process execution without project access at execution time. */ + @get:Inject + protected abstract val execOperations: ExecOperations + + /** The execution mode assigned by the plugin. */ + @get:Input + public abstract val mode: Property + + /** The source root passed to `-code-path`. */ + @get:InputDirectory + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val codePath: DirectoryProperty + + /** Named source roots included in an internally generated configuration. */ + @get:Input + public abstract val namedSources: MapProperty + + /** Named source directories with their producing task dependencies. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val namedSourceDirectories: ConfigurableFileCollection + + /** The documentation root passed to `-docs-path`. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val docsPath: DirectoryProperty + + /** Documentation include patterns passed to `-doc-includes`. */ + @get:Input + public abstract val docIncludes: ListProperty + + /** Documentation exclude patterns passed to `-doc-excludes`. */ + @get:Input + public abstract val docExcludes: ListProperty + + /** The fragment separator passed to `-separator`. */ + @get:Input + public abstract val separator: Property + + /** Whether informational logging is enabled. */ + @get:Input + public abstract val info: Property + + /** Whether panic stack traces are enabled. */ + @get:Input + public abstract val stacktrace: Property + + /** The installed platform executable. */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + public abstract val executableFile: RegularFileProperty + + /** The process working directory. */ + @get:Internal + public abstract val workingDirectory: DirectoryProperty + + /** Executes Embed Code with arguments derived from the Gradle extension. */ + @TaskAction + public fun runEmbedCode() { + val configuredSources = TreeMap(namedSources.get()) + val hasDirectSource = codePath.isPresent + val hasNamedSources = configuredSources.isNotEmpty() + if (hasDirectSource == hasNamedSources) { + throw GradleException( + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code.", + ) + } + + val arguments = mutableListOf() + arguments.add("-mode=${mode.get()}") + if (hasNamedSources) { + arguments.add("-config-path=${writeNamedSourceConfiguration(configuredSources)}") + } else { + arguments.add("-code-path=${codePath.get().asFile.absolutePath}") + arguments.add("-docs-path=${docsPath.get().asFile.absolutePath}") + if (docIncludes.get().isNotEmpty()) { + arguments.add("-doc-includes=${docIncludes.get().joinToString(",")}") + } + if (docExcludes.get().isNotEmpty()) { + arguments.add("-doc-excludes=${docExcludes.get().joinToString(",")}") + } + arguments.add("-separator=${separator.get()}") + arguments.add("-info=${info.get()}") + arguments.add("-stacktrace=${stacktrace.get()}") + } + + execOperations.exec { spec -> + spec.executable(executableFile.get().asFile) + spec.args(arguments) + spec.setWorkingDir(workingDirectory.get().asFile) + } + } + + /** Writes the generated configuration used when named source roots are configured. */ + private fun writeNamedSourceConfiguration(configuredSources: Map): Path { + val normalizedSources = TreeMap() + for (source in configuredSources.entries) { + var path = Paths.get(source.value) + if (!path.isAbsolute) { + path = workingDirectory.get().asFile.toPath().resolve(path) + } + path = path.normalize().toAbsolutePath() + if (!Files.isDirectory(path)) { + throw GradleException( + "Embed Code source `${source.key}` is not a directory: $path", + ) + } + normalizedSources[source.key] = path.toString() + } + + val json = createConfigurationJson( + normalizedSources, + docsPath.get().asFile.absolutePath, + docIncludes.get(), + docExcludes.get(), + separator.get(), + info.get(), + stacktrace.get(), + ) + val configuration = temporaryDir.toPath().resolve("embed-code.json") + try { + Files.write(configuration, json.toByteArray(StandardCharsets.UTF_8)) + } catch (exception: IOException) { + throw GradleException( + "Could not write the generated Embed Code configuration to $configuration.", + exception, + ) + } + return configuration + } + + private companion object { + + /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + fun createConfigurationJson( + namedSources: Map, + docsPath: String, + docIncludes: List, + docExcludes: List, + separator: String, + info: Boolean, + stacktrace: Boolean, + ): String { + val json = StringBuilder() + json.append("{\n \"code-path\": [\n") + var index = 0 + for (source in namedSources.entries) { + if (index > 0) { + json.append(",\n") + } + json.append(" {\"name\": ") + appendJsonString(json, source.key) + json.append(", \"path\": ") + appendJsonString(json, source.value) + 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 [values]. */ + fun appendJsonArray(json: StringBuilder, values: List) { + json.append('[') + for (index in values.indices) { + if (index > 0) { + json.append(", ") + } + appendJsonString(json, values[index]) + } + json.append(']') + } + + /** Appends [value] as an escaped JSON string. */ + fun appendJsonString(json: StringBuilder, value: String) { + json.append('"') + for (character in value) { + when (character) { + '"' -> json.append("\\\"") + '\\' -> json.append("\\\\") + '\b' -> json.append("\\b") + '\u000C' -> json.append("\\f") + '\n' -> json.append("\\n") + '\r' -> json.append("\\r") + '\t' -> json.append("\\t") + else -> { + if (character < '\u0020') { + json.append(String.format(Locale.ROOT, "\\u%04x", character.code)) + } else { + json.append(character) + } + } + } + } + json.append('"') + } + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt new file mode 100644 index 0000000..0e5c589 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -0,0 +1,232 @@ +/* + * 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.Optional +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.ZipInputStream + +/** + * Downloads and prepares the Embed Code executable selected for the host. + * + * An explicitly selected version is reused using Gradle's normal up-to-date + * behavior. The latest release is downloaded on every invocation so that it + * cannot remain stale behind an existing output. + */ +@DisableCachingByDefault(because = "Release assets come from external URLs that may change") +public abstract class InstallEmbedCodeTask : DefaultTask() { + + /** An optional Embed Code release version. */ + @get:Input + @get:Optional + public abstract val version: Property + + /** The base URL of the Embed Code releases. */ + @get:Input + public abstract val downloadBaseUrl: Property + + /** The operating system used to select a release asset. */ + @get:Input + public abstract val operatingSystem: Property + + /** The architecture used to select a release asset. */ + @get:Input + public abstract val architecture: Property + + /** The installed executable used by Embed Code execution tasks. */ + @get:OutputFile + public abstract val executableFile: RegularFileProperty + + /** Downloads, extracts when necessary, and marks the executable runnable. */ + @TaskAction + public fun install() { + val requestedVersion = version.orNull?.trim() + if (requestedVersion != null && requestedVersion.isEmpty()) { + throw GradleException("Embed Code version must not be empty.") + } + val platform = EmbedCodePlatform.detect( + operatingSystem.get(), + architecture.get(), + ) + val asset = platform.assetName + val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) + val source = releaseAsset(baseUrl, requestedVersion, asset) + val destination = executableFile.get().asFile.toPath() + val download = temporaryDir.toPath().resolve(asset) + val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) + + try { + Files.createDirectories(destination.parent) + val release = requestedVersion ?: "latest release" + logger.lifecycle("Downloading Embed Code {} from {}", release, source) + download(source, download) + + if (asset.endsWith(".zip")) { + extractExecutable(download, platform.executableName, preparedExecutable) + } else { + Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING) + } + + if (!preparedExecutable.toFile().setExecutable(true, false)) { + throw GradleException("Could not make `$preparedExecutable` executable.") + } + moveAtomically(preparedExecutable, destination) + } catch (exception: IOException) { + throw GradleException("Could not install Embed Code from $source.", exception) + } + } + + private companion object { + + const val CONNECT_TIMEOUT_MILLIS = 30_000 + const val READ_TIMEOUT_MILLIS = 120_000 + const val BUFFER_SIZE = 8_192 + + /** Returns the release asset URI for the latest or explicitly requested version. */ + fun releaseAsset(baseUrl: String, requestedVersion: String?, asset: String): URI { + if (requestedVersion == null) { + return URI.create("$baseUrl/latest/download/$asset") + } + val releaseTag = if (requestedVersion.startsWith("v")) { + requestedVersion + } else { + "v$requestedVersion" + } + return URI.create("$baseUrl/download/$releaseTag/$asset") + } + + /** Downloads [source] into [destination], reporting HTTP failures clearly. */ + fun download(source: URI, destination: Path) { + var connection: URLConnection? = null + try { + connection = source.toURL().openConnection() + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + + if (connection is HttpURLConnection) { + connection.instanceFollowRedirects = true + val status = connection.responseCode + if (status < 200 || status > 299) { + throw GradleException( + "Could not download Embed Code: HTTP $status from $source.", + ) + } + } + + connection.getInputStream().use { input -> + Files.newOutputStream(destination).use { output -> + copy(input, output) + } + } + } catch (exception: IOException) { + throw GradleException("Could not download Embed Code from $source.", exception) + } finally { + if (connection is HttpURLConnection) { + connection.disconnect() + } + } + } + + /** Extracts [entryName] from [archive] into [destination]. */ + @Throws(IOException::class) + fun extractExecutable(archive: Path, entryName: String, destination: Path) { + ZipInputStream(Files.newInputStream(archive)).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + val entryPath = entry.name + val slash = entryPath.lastIndexOf('/') + val fileName = if (slash >= 0) { + entryPath.substring(slash + 1) + } else { + entryPath + } + if (!entry.isDirectory && fileName == entryName) { + Files.newOutputStream(destination).use { output -> + copy(zip, output) + } + return + } + zip.closeEntry() + entry = zip.nextEntry + } + } + throw GradleException("Archive `$archive` does not contain `$entryName`.") + } + + /** Copies all bytes from [input] into [output]. */ + @Throws(IOException::class) + fun copy(input: InputStream, output: OutputStream) { + val buffer = ByteArray(BUFFER_SIZE) + var count = input.read(buffer) + while (count >= 0) { + output.write(buffer, 0, count) + count = input.read(buffer) + } + } + + /** Moves [source] to [destination], atomically when supported. */ + @Throws(IOException::class) + fun moveAtomically(source: Path, destination: Path) { + try { + Files.move( + source, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + + /** Removes trailing slashes without changing a URL scheme. */ + fun trimTrailingSlashes(value: String): String { + var end = value.length + while (end > 0 && value[end - 1] == '/') { + end-- + } + return value.substring(0, end) + } + } +} 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..80b53a4 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -0,0 +1,92 @@ +/* + * 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 `use a stable executable name on Unix`() { + assertEquals("embed-code", EmbedCodePlatform.installedExecutableName("Linux")) + } + + @Test + fun `keep the executable suffix on Windows`() { + assertEquals("embed-code.exe", EmbedCodePlatform.installedExecutableName("Windows 11")) + } + + @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..e06cdcd --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -0,0 +1,540 @@ +/* + * 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 com.sun.net.httpserver.HttpServer +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +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(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + + @Test + fun `install platform release asset`() { + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + val installedExecutable = projectDirectory.resolve( + "build/embed-code/latest/$executableName", + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `allow overriding the latest 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.installedExecutableName( + System.getProperty("os.name"), + ) + Files.exists( + projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), + ) shouldBe true + } + + @Test + fun `defer unsupported platform failure until installation`() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + tasks.named("installEmbedCode") { + operatingSystem.set("Linux") + architecture.set("aarch64") + } + """.trimIndent(), + ) + + runner("tasks").build() + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`." + } + + @Test + fun `accept trailing slashes in the release base URL`() { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + "///" + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + } + + @Test + fun `report an HTTP status returned for a release asset`() { + val server = HttpServer.create( + InetSocketAddress("127.0.0.1", 0), + 0, + ) + server.createContext("/") { exchange -> + exchange.sendResponseHeaders(503, -1) + exchange.close() + } + server.start() + try { + val baseUrl = "http://127.0.0.1:${server.address.port}/releases" + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "HTTP 503" + } finally { + server.stop(0) + } + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 8_14_4`() { + runCheckModeWithGradle("8.14.4") + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 9_0_0`() { + runCheckModeWithGradle("9.0.0") + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse installation when running embed mode`() { + writeBuildFile(TEST_RELEASE_VERSION) + 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 an empty named source`() { + writeNamedSourcesBuildFile(firstSourceName = " ", includeSecondSource = false) + + val result = runner("tasks").buildAndFail() + + result.output shouldContain "An Embed Code source name must not be empty." + } + + @Test + fun `reject a duplicate named source`() { + writeNamedSourcesBuildFile(secondSourceName = "company-site") + + val result = runner("tasks").buildAndFail() + + result.output shouldContain "Embed Code source `company-site` is already configured." + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run a real Embed Code release with generated configuration`() { + assumeTrue( + System.getenv("EMBED_CODE_REAL_TEST").toBoolean(), + "Set EMBED_CODE_REAL_TEST=true to run this smoke test.", + ) + Files.writeString( + projectDirectory.resolve("code/Hello.java"), + "class Hello {\n static final String MESSAGE = \"Hello\";\n}\n", + ) + val documentation = projectDirectory.resolve("docs/example.md") + Files.writeString( + documentation, + """ + # Example + + + ```java + class Outdated {} + ``` + """.trimIndent() + "\n", + ) + writeRealReleaseBuildFile() + + val embedResult = runner(":embedCode").build() + val checkResult = runner(":checkEmbedding").build() + + embedResult.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + checkResult.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(documentation) shouldContain "static final String MESSAGE = \"Hello\";" + } + + @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() + } + + /** Runs check mode with [gradleVersion]. */ + private fun runCheckModeWithGradle(gradleVersion: String) { + val result = runner(":checkEmbedding") + .withGradleVersion(gradleVersion) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + /** Writes a consuming build configured entirely through the plugin extension. */ + private fun writeBuildFile( + version: String? = null, + downloadBaseUrl: String = 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("$downloadBaseUrl") + 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, + firstSourceName: String = "company-site", + secondSourceName: String = "jxbrowser", + includeSecondSource: Boolean = true, + ) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val directSource = if (includeDirectSource) { + "codePath.set(layout.projectDirectory.dir(\"code\"))" + } else { + "" + } + val secondSource = if (includeSecondSource) { + "namedSource(\"$secondSourceName\", layout.projectDirectory.dir(\"browser\"))" + } else { + "" + } + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + downloadBaseUrl.set("$baseUrl") + $directSource + namedSource("$firstSourceName", layout.projectDirectory.dir("company-site")) + $secondSource + docsPath.set(layout.projectDirectory) + } + """.trimIndent(), + ) + } + + /** Writes a consuming build that exercises a real release and generated JSON config. */ + private fun writeRealReleaseBuildFile() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + namedSource("sample", layout.projectDirectory.dir("code")) + docsPath.set(layout.projectDirectory.dir("docs")) + } + """.trimIndent(), + ) + } + + /** Creates a host-specific fake release asset that records received arguments. */ + private fun createFakeRelease(root: Path, version: String = TEST_RELEASE_VERSION) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("download/v$version") + val latestDirectory = root.resolve("latest/download") + Files.createDirectories(versionDirectory) + Files.createDirectories(latestDirectory) + 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) + } + Files.copy( + asset, + latestDirectory.resolve(platform.assetName), + StandardCopyOption.REPLACE_EXISTING, + ) + } + + private companion object { + const val TEST_RELEASE_VERSION = "1.2.4-test" + } +} + +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>.") +}