diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 58ed417..87eb376 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,17 +1,41 @@ name: Check -on: pull_request +on: + pull_request: + +permissions: + contents: read + +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 +45,8 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew build + shell: bash + 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..c672a2e --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +[![Build on Ubuntu and Windows][build-badge]][gh-actions] +[![license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](http://www.apache.org/licenses/LICENSE-2.0) + +# Embed Code Gradle plugin + +Gradle plugin for [Embed Code][embed-code], an application that keeps code +examples in Markdown and HTML synchronized with their source files. + +The plugin downloads the released Embed Code executable for the current +platform, so developers and CI jobs do not have to install it manually. It +adds two tasks: + +- `checkEmbedding` checks that embedded code is up to date. +- `embedCode` updates embedded code in place. + +## Requirements + +- Java 17 or a newer version supported by the selected Gradle version. +- Gradle 8.14.4 or newer. +- Linux AMD64, Windows AMD64, or macOS AMD64/ARM64. + +Consumers do not need to install Kotlin or apply a Kotlin plugin. +The plugin is written in Kotlin, but uses the Kotlin runtime supplied by Gradle. + +## How to use + +This section describes how to use the plugin. For information about the Embed +Code application itself, see its [documentation][embed-code]. + +Add the following configuration to the project's `build.gradle.kts`: + +```kotlin +plugins { + id("io.spine.embed-code") version "0.1.0" // Specify the actual version here. +} + +embedCode { + + // Specify the directory containing source files referenced by embedding instructions. + // + // This property is required unless `namedSource(...)` is used. + // + codePath.set(layout.projectDirectory.dir("src/main/java")) + + // Specify the directory containing Markdown or HTML documentation. + // + // This property is required. + // + docsPath.set(layout.projectDirectory.dir("docs")) + + // Configure documentation files to include and exclude. + // + // This section is optional. The default includes are `**/*.md` and + // `**/*.html`; the default excludes list is empty. + // + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + + // Configure other Embed Code command-line options. + // + // This section is optional. The values below are the defaults. + // + separator.set("...") + info.set(false) + stacktrace.set(false) +} +``` + +Use named source roots when documentation embeds code from multiple modules: + +```kotlin +embedCode { + namedSource( + "model", + layout.projectDirectory.dir("model"), + ) + namedSource( + "database", + layout.projectDirectory.dir("database"), + ) + docsPath.set(layout.projectDirectory) +} +``` + +Embedding instructions refer to these roots with `$model/` and +`$database/`. `codePath` and `namedSource(...)` are mutually exclusive. + +By default, the plugin checks the latest Embed Code release before running a task. +It reuses the executable in `build/embed-code/latest` while the release +version remains unchanged and downloads a new executable only after a new +release is published. When the release check fails, for example without +network access, the plugin reuses the previously installed executable. + +To use a specific Embed Code application release, add its version to the extension: + +```kotlin +embedCode { + version.set("1.2.4") +} +``` + +Check that documentation is up to date: + +```bash +./gradlew :checkEmbedding +``` + +Update documentation: + +```bash +./gradlew :embedCode +``` + +The plugin prefers the `checkEmbedding` and `embedCode` task names. If a name +is already occupied when the plugin is applied, underscores are prepended until +an available name is found, for example `_embedCode` or `__embedCode`. +The fallback cannot account for a conflicting task registered later. + +## Development + +Run compilation, plugin validation, and the complete test suite: + +```bash +./gradlew check +``` + +Fast unit tests run under `test`. TestKit coverage runs separately under +`functionalTest`; the `check` task includes both. + +To test the plugin in another project, publish it to the local Maven repository: + +```bash +./gradlew :gradle-plugin:publishToMavenLocal +``` + +In this case, add `mavenLocal()` to `pluginManagement.repositories` in the +consuming project's `settings.gradle.kts`. Adding it only to the regular +`repositories` block does not make Gradle plugin markers available to the +`plugins` block. + +## License + +The plugin is available under the [Apache License 2.0](LICENSE). + +[build-badge]: https://github.com/SpineEventEngine/embed-code-gradle-plugin/actions/workflows/check.yml/badge.svg +[embed-code]: https://github.com/SpineEventEngine/embed-code-go +[gh-actions]: https://github.com/SpineEventEngine/embed-code-gradle-plugin/actions 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..d4ddee0 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/BuildSettings.kt @@ -26,17 +26,18 @@ package io.spine.embedcode.gradle -/** Build-wide Java and bytecode targets. */ +/** + * Build-wide Java and bytecode targets. + */ object BuildSettings { /** Java toolchain version used to build and test the project. */ 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..895cb24 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 @@ -26,10 +26,12 @@ package io.spine.embedcode.gradle.dependency -/** JUnit dependencies used by tests. */ +/** + * 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/io/spine/embedcode/gradle/dependency/Kotlin.kt b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt index 51e35c3..3df0da5 100644 --- a/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt +++ b/buildSrc/src/main/kotlin/io/spine/embedcode/gradle/dependency/Kotlin.kt @@ -26,7 +26,9 @@ package io.spine.embedcode.gradle.dependency -/** Kotlin dependencies used by the project. */ +/** + * Kotlin dependencies used by the project. + */ object Kotlin { const val version = "2.4.10" diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 8471fdb..5291c7a 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 { @@ -45,18 +43,19 @@ java { } kotlin { + explicitApi() 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 +65,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..45aa95f 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -24,6 +24,139 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.embedcode.gradle.BuildSettings +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}") + // Unit tests no longer inherit TestKit's Gradle and Kotlin runtime; supply both explicitly. + testRuntimeOnly("org.jetbrains.kotlin:kotlin-stdlib:${Kotlin.version}") + testRuntimeOnly(gradleApi()) +} + +val functionalTestSourceSet = sourceSets.create("functionalTest") +functionalTestSourceSet.compileClasspath += sourceSets.main.get().output +functionalTestSourceSet.runtimeClasspath += sourceSets.main.get().output + +kotlin { + target.compilations.getByName("functionalTest") { + associateWith(target.compilations.getByName("main")) + } +} + +configurations[functionalTestSourceSet.implementationConfigurationName].extendsFrom( + configurations.testImplementation.get(), +) +configurations[functionalTestSourceSet.compileOnlyConfigurationName].extendsFrom( + configurations.testCompileOnly.get(), +) +configurations[functionalTestSourceSet.runtimeOnlyConfigurationName].extendsFrom( + configurations.testRuntimeOnly.get(), +) + +val functionalTest = tasks.register("functionalTest") { + description = "Runs TestKit functional tests." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = functionalTestSourceSet.output.classesDirs + classpath = functionalTestSourceSet.runtimeClasspath + useJUnitPlatform() + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(BuildSettings.bytecodeVersion)) + }, + ) + shouldRunAfter(tasks.test) +} + +tasks.check { + dependsOn(functionalTest) +} + +base { + archivesName.set("embed-code-gradle-plugin") +} + +java { + withJavadocJar() + withSourcesJar() +} + +tasks.withType().configureEach { + from(rootProject.layout.projectDirectory.file("LICENSE")) { + into("META-INF") + } +} + +gradlePlugin { + testSourceSets(functionalTestSourceSet) + 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/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt new file mode 100644 index 0000000..14eb780 --- /dev/null +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -0,0 +1,715 @@ +/* + * 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.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.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@DisplayName("`EmbedCodePlugin` should") +internal class EmbedCodePluginSpec { + + @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 `log main execution points at info level`() { + val result = runner(":checkEmbedding", "--info").build() + + result.output shouldContain "Applying the Embed Code plugin to project `:`." + result.output shouldContain + "Registered Embed Code tasks `checkEmbedding` and `embedCode` in project `:`." + result.output shouldContain "Preparing the Embed Code executable for operating system" + result.output shouldContain "Preparing Embed Code `check` mode" + result.output shouldContain "Using source root" + result.output shouldContain "Starting Embed Code `check` mode with executable" + result.output shouldContain "Embed Code `check` mode completed successfully." + } + + @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 + fun `reuse latest executable when the release version is unchanged`() { + val latestTag = AtomicReference(TEST_RELEASE_TAG) + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() + val server = startReleaseServer(latestTag, versionChecks, downloads) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + + runner(":installEmbedCode").build() + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + versionChecks.get() shouldBe 2 + downloads.get() shouldBe 1 + result.output shouldContain "Reusing Embed Code v$TEST_RELEASE_VERSION" + } finally { + server.stop(0) + } + } + + @Test + fun `use a resolved latest release tag without modification`() { + val releaseTag = "release-$TEST_RELEASE_VERSION" + createFakeRelease(releaseDirectory, tag = releaseTag) + val downloads = AtomicInteger() + val server = startReleaseServer( + AtomicReference(releaseTag), + AtomicInteger(), + downloads, + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + + runner(":installEmbedCode").build() + + downloads.get() shouldBe 1 + Files.readString( + projectDirectory.resolve("build/embed-code/latest/version.txt"), + ).trim() shouldBe releaseTag + } finally { + server.stop(0) + } + } + + @Test + fun `download latest executable when the release version changes`() { + val nextVersion = "1.2.5-test" + createFakeRelease(releaseDirectory, nextVersion) + val latestTag = AtomicReference(TEST_RELEASE_TAG) + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() + val server = startReleaseServer(latestTag, versionChecks, downloads) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + + latestTag.set("v$nextVersion") + runner(":installEmbedCode").build() + + versionChecks.get() shouldBe 2 + downloads.get() shouldBe 2 + Files.readString( + projectDirectory.resolve("build/embed-code/latest/version.txt"), + ).trim() shouldBe "v$nextVersion" + } finally { + server.stop(0) + } + } + + @Test + fun `reuse latest executable in offline mode`() { + val latestTag = AtomicReference(TEST_RELEASE_TAG) + val server = startReleaseServer( + latestTag, + AtomicInteger(), + AtomicInteger(), + ) + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + try { + runner(":installEmbedCode").build() + } finally { + server.stop(0) + } + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Reusing cached Embed Code executable" + } + + @Test + fun `reuse cached executable when the latest release check fails`() { + val latestTag = AtomicReference(TEST_RELEASE_TAG) + val server = startReleaseServer( + latestTag, + AtomicInteger(), + AtomicInteger(), + ) + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + try { + runner(":installEmbedCode").build() + } finally { + server.stop(0) + } + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Could not check the latest Embed Code release" + result.output shouldContain "Reusing the cached executable" + } + + @Test + fun `report a failed latest release check without a cached executable`() { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val baseUrl = "http://127.0.0.1:${server.address.port}/releases" + server.stop(0) + writeBuildFile(downloadBaseUrl = baseUrl) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "Could not resolve the latest Embed Code release" + } + + @Test + fun `report a missing latest executable in offline mode`() { + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain + "Cannot install the latest Embed Code release in offline mode because " + + "no cached executable exists" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `trim an overridden 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 + 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" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied installEmbedCode task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("installEmbedCode") + tasks.register("_installEmbedCode") + } + """.trimIndent(), + ) + + val result = runner(":checkEmbedding").build() + + result.task(":__installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + + /** + * Creates a runner using the plugin-under-test classpath. + */ + private fun runner( + vararg arguments: String, + ): GradleRunner { + val gradleArguments = arguments.toMutableList() + 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(), + ) + } + + /** + * Creates a host-specific fake release asset that records received arguments. + */ + private fun createFakeRelease( + root: Path, + version: String = TEST_RELEASE_VERSION, + tag: String = "v$version", + ) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("download/$tag") + 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, + ) + } + + /** + * Starts a release server whose latest endpoint redirects to a mutable tag. + */ + private fun startReleaseServer( + latestTag: AtomicReference, + versionChecks: AtomicInteger, + downloads: AtomicInteger, + ): HttpServer { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/releases/latest") { exchange -> + versionChecks.incrementAndGet() + if (exchange.requestMethod != "HEAD") { + exchange.sendResponseHeaders(405, -1) + } else { + exchange.responseHeaders.add( + "Location", + "/releases/tag/${latestTag.get()}", + ) + exchange.sendResponseHeaders(302, -1) + } + exchange.close() + } + server.createContext("/releases/download/") { exchange -> + downloads.incrementAndGet() + val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") + val asset = releaseDirectory.resolve("download").resolve(relativePath).normalize() + if (!asset.startsWith(releaseDirectory.resolve("download")) || !Files.isRegularFile(asset)) { + exchange.sendResponseHeaders(404, -1) + } else { + val content = Files.readAllBytes(asset) + exchange.sendResponseHeaders(200, content.size.toLong()) + exchange.responseBody.use { output -> output.write(content) } + } + exchange.close() + } + server.start() + return server + } + + private val HttpServer.releaseBaseUrl: String + get() = "http://127.0.0.1:${address.port}/releases" + + private companion object { + const val TEST_RELEASE_VERSION = "1.2.4-test" + const val TEST_RELEASE_TAG = "v1.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>.") +} 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..ff19a61 --- /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. */ + internal abstract val namedSources: MapProperty + + /** Named source directories with their task dependencies. */ + internal 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/EmbedCodeJson.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt new file mode 100644 index 0000000..f20e5cd --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeJson.kt @@ -0,0 +1,103 @@ +/* + * 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 java.util.Locale + +/** + * Creates a JSON document accepted by Embed Code's YAML configuration parser. + */ +internal 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() +} + +private fun appendJsonArray(json: StringBuilder, values: List) { + json.append('[') + for (index in values.indices) { + if (index > 0) { + json.append(", ") + } + appendJsonString(json, values[index]) + } + json.append(']') +} + +private 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/EmbedCodePlatform.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt new file mode 100644 index 0000000..818a319 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlatform.kt @@ -0,0 +1,89 @@ +/* + * 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..cb8801d --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -0,0 +1,157 @@ +/* + * 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) { + project.logger.info("Applying the Embed Code plugin to project `{}`.", project.path) + val checkTaskName = availableTaskName(project, "checkEmbedding") + val embedTaskName = availableTaskName(project, "embedCode") + val installTaskName = availableTaskName(project, "installEmbedCode") + 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() + // The installed file name always tracks the host operating system. + // Overriding the task's `operatingSystem` input changes asset selection only. + val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) + val requestedVersion = extension.version.map { version -> version.trim() } + val installTask = project.tasks.register( + installTaskName, + InstallEmbedCodeTask::class.java, + ) { task -> + task.description = "Installs the requested Embed Code executable" + task.version.set(requestedVersion) + task.downloadBaseUrl.set(extension.downloadBaseUrl) + task.operatingSystem.set(operatingSystem) + task.architecture.set(architecture) + task.offline.set(project.gradle.startParameter.isOffline) + task.executableFile.set( + project.layout.buildDirectory.file( + requestedVersion.map { version -> + "embed-code/$version/$installedExecutableName" + }.orElse("embed-code/latest/$installedExecutableName"), + ), + ) + task.resolvedVersionFile.set( + project.layout.buildDirectory.file("embed-code/latest/version.txt"), + ) + 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", + ) + project.logger.info( + "Registered Embed Code tasks `{}` and `{}` in project `{}`.", + checkTaskName, + embedTaskName, + project.path, + ) + } + + 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..9f0c3c0 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeTask.kt @@ -0,0 +1,229 @@ +/* + * 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.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. + * + * The absolute path values make this input machine-specific. The task intentionally + * declares no outputs and disables caching because it checks or modifies documentation. + */ + @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 executionMode = mode.get() + val processDirectory = workingDirectory.get().asFile + logger.info( + "Preparing Embed Code `{}` mode in `{}`.", + executionMode, + processDirectory, + ) + 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=$executionMode") + if (hasNamedSources) { + arguments.add("-config-path=${writeNamedSourceConfiguration(configuredSources)}") + } else { + val sourceDirectory = codePath.get().asFile + val documentationDirectory = docsPath.get().asFile + logger.info( + "Using source root `{}` and documentation root `{}`.", + sourceDirectory, + documentationDirectory, + ) + arguments.add("-code-path=${sourceDirectory.absolutePath}") + arguments.add("-docs-path=${documentationDirectory.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()}") + } + + val executable = executableFile.get().asFile + logger.info( + "Starting Embed Code `{}` mode with executable `{}`.", + executionMode, + executable, + ) + execOperations.exec { spec -> + spec.executable(executable) + spec.args(arguments) + spec.setWorkingDir(processDirectory) + } + logger.info("Embed Code `{}` mode completed successfully.", executionMode) + } + + /** + * 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, + ) + } + logger.info( + "Generated Embed Code configuration at `{}` for {} named source roots.", + configuration, + configuredSources.size, + ) + return configuration + } + +} 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..fcd14d8 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -0,0 +1,392 @@ +/* + * 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.LocalState +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. For the latest release, the remote version is checked before an + * existing executable is replaced. When the check fails, for example without + * network access, a previously installed executable is reused. + */ +@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 + + /** Whether Gradle is running without network access. */ + @get:Input + public abstract val offline: Property + + /** The installed executable used by Embed Code execution tasks. */ + @get:OutputFile + public abstract val executableFile: RegularFileProperty + + /** Stores the release version represented by the latest executable. */ + @get:LocalState + public abstract val resolvedVersionFile: RegularFileProperty + + /** + * Downloads, extracts when necessary, and marks the executable runnable. + */ + @TaskAction + public fun install() { + val requestedVersion = version.orNull + if (requestedVersion != null && requestedVersion.isEmpty()) { + throw GradleException("Embed Code version must not be empty.") + } + val hostOperatingSystem = operatingSystem.get() + val hostArchitecture = architecture.get() + logger.info( + "Preparing the Embed Code executable for operating system `{}` and architecture `{}`.", + hostOperatingSystem, + hostArchitecture, + ) + val platform = EmbedCodePlatform.detect(hostOperatingSystem, hostArchitecture) + val asset = platform.assetName + val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) + val destination = executableFile.get().asFile.toPath() + val versionFile = resolvedVersionFile.get().asFile.toPath() + if (requestedVersion == null && offline.get()) { + reuseOfflineInstallation(destination) + return + } + val resolvedVersion = if (requestedVersion == null) { + logger.info("Resolving the latest Embed Code release from {}.", baseUrl) + try { + resolveLatestVersion(baseUrl) + } catch (exception: GradleException) { + if (!Files.isRegularFile(destination)) { + throw exception + } + logger.warn( + "Could not check the latest Embed Code release ({}). " + + "Reusing the cached executable from `{}`.", + exception.message, + destination, + ) + return + } + } else { + null + } + if (resolvedVersion != null) { + logger.info("Resolved the latest Embed Code release as {}.", resolvedVersion) + } + if ( + resolvedVersion != null && + Files.isRegularFile(destination) && + readResolvedVersion(versionFile) == resolvedVersion + ) { + logger.lifecycle("Reusing Embed Code {} from {}", resolvedVersion, destination) + return + } + val selectedReleaseTag = if (requestedVersion != null) { + releaseTagForVersion(requestedVersion) + } else { + resolvedVersion + } + val source = releaseAsset(baseUrl, selectedReleaseTag, asset) + 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) + if (resolvedVersion != null) { + writeResolvedVersion(versionFile, resolvedVersion) + } + logger.info( + "Installed Embed Code {} at {}.", + selectedReleaseTag ?: "latest release", + destination, + ) + } catch (exception: IOException) { + throw GradleException("Could not install Embed Code from $source.", exception) + } + } + + /** + * Reuses an installed executable while Gradle is offline. + */ + private fun reuseOfflineInstallation(destination: Path) { + if (!Files.isRegularFile(destination)) { + throw GradleException( + "Cannot install the latest Embed Code release in offline mode because " + + "no cached executable exists at `$destination`.", + ) + } + logger.lifecycle("Reusing cached Embed Code executable from {} in offline mode", destination) + } + + 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 tag of the release targeted by the latest-release redirect. + * + * Non-HTTP release mirrors cannot expose an HTTP redirect, so they keep + * using the latest asset URL directly. + */ + fun resolveLatestVersion(baseUrl: String): String? { + val source = URI.create("$baseUrl/latest") + val connection = source.toURL().openConnection() + if (connection !is HttpURLConnection) { + return null + } + try { + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + connection.instanceFollowRedirects = false + connection.requestMethod = "HEAD" + val status = connection.responseCode + if (status < 300 || status > 399) { + throw GradleException( + "Could not resolve the latest Embed Code release: " + + "HTTP $status from $source.", + ) + } + val location = connection.getHeaderField("Location") + ?: throw GradleException( + "Could not resolve the latest Embed Code release: " + + "the redirect from $source has no Location header.", + ) + val releaseUri = source.resolve(location) + val tag = releaseUri.path.substringAfterLast('/') + if (tag.isEmpty()) { + throw GradleException( + "Could not resolve the latest Embed Code release from `$releaseUri`.", + ) + } + return tag + } catch (exception: IOException) { + throw GradleException( + "Could not resolve the latest Embed Code release from $source.", + exception, + ) + } finally { + connection.disconnect() + } + } + + /** + * Returns the release asset URI for the latest release or [releaseTag]. + */ + fun releaseAsset(baseUrl: String, releaseTag: String?, asset: String): URI { + if (releaseTag == null) { + return URI.create("$baseUrl/latest/download/$asset") + } + return URI.create("$baseUrl/download/$releaseTag/$asset") + } + + /** + * Returns the release tag corresponding to a user-configured [version]. + */ + fun releaseTagForVersion(version: String): String { + return if (version.startsWith("v")) version else "v$version" + } + + /** + * 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() + } + } + } + + /** + * Returns the recorded latest release version, if available. + */ + fun readResolvedVersion(versionFile: Path): String? { + return try { + Files.readString(versionFile).trim().ifEmpty { null } + } catch (_: IOException) { + null + } + } + + /** + * Records [version] after its executable has been installed. + */ + @Throws(IOException::class) + fun writeResolvedVersion(versionFile: Path, version: String) { + Files.createDirectories(versionFile.parent) + val temporaryFile = versionFile.resolveSibling("${versionFile.fileName}.tmp") + Files.writeString(temporaryFile, "$version\n") + moveAtomically(temporaryFile, versionFile) + } + + /** + * 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/EmbedCodeJsonSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt new file mode 100644 index 0000000..5ec1fe4 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeJsonSpec.kt @@ -0,0 +1,65 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`createConfigurationJson` should") +internal class EmbedCodeJsonSpec { + + @Test + fun `escape names paths and options`() { + val json = createConfigurationJson( + linkedMapOf("quoted\"\nsource" to "C:\\work\\\"quoted\nfile"), + "C:\\docs\nline", + listOf("**/\"quoted\".md", "line\nbreak"), + listOf("drafts\\**"), + "---\n---", + info = true, + stacktrace = false, + ) + + assertEquals( + """ + { + "code-path": [ + {"name": "quoted\"\nsource", "path": "C:\\work\\\"quoted\nfile"} + ], + "docs-path": "C:\\docs\nline", + "doc-includes": ["**/\"quoted\".md", "line\nbreak"], + "doc-excludes": ["drafts\\**"], + "separator": "---\n---", + "info": true, + "stacktrace": false + } + """.trimIndent() + "\n", + json, + ) + } +} 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, + ) + } +}