diff --git a/PROJECT.md b/PROJECT.md index 76886b56..14c90158 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -23,6 +23,8 @@ them inside code fences, and checks whether existing snippets are up-to-date. - `embedding/commentfilter/`: language-aware comment-retention filtering. - `fragmentation/`: whole-file, named-fragment, and line-pattern extraction; source lookup; partition assembly; encoding checks; and caches. +- `gradle-plugin/`: Gradle plugin build, automatic release-binary installation, + check/embed tasks, Kotlin DSL configuration, and TestKit tests. - `files/`: filesystem validation and file helpers. - `indent/`: shared indentation measurement and removal. - `logging/`: `slog` handler, clickable file references, panic reporting, and @@ -39,6 +41,8 @@ them inside code fences, and checks whether existing snippets are up-to-date. - `README.md`: project entry point, short run/build instructions, and links to the complete guide. +- `gradle-plugin/README.md`: Gradle plugin application, configuration, task, + compatibility, and development guide. - `showcase/README.md`: complete user guide entry point and runnable workflow. - `showcase/configuration/README.md`: command-line flags, YAML configuration, source roots, include/exclude patterns, and multiple embedding targets. @@ -56,7 +60,9 @@ this file. Keep the root README short. This repository is configured with these GitHub workflows: - `check`: runs linting, the normal Go test suite, and the showcase end-to-end - tests across supported platforms. + tests across supported platforms. It also verifies the Gradle plugin on each + supported runner platform and against the minimum Gradle version, then + assembles its Maven publications on Linux. - `coverage`: uploads Go coverage to Codecov, where `codecov.yml` requires 90% patch coverage for pull requests. The workflow also runs on pushes to `master` to populate base coverage for later pull-request diffs and the diff --git a/README.md b/README.md index ff149d76..78838d43 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Use `-mode=embed` when documentation should be rewritten with current source content. See the [configuration guide](showcase/configuration/README.md) for all command-line flags and YAML options. +Gradle projects can use the [Embed Code Gradle plugin](gradle-plugin/README.md) +to download the platform binary automatically and configure both modes in +`build.gradle.kts` without an additional YAML file. + ## Build Use Go `1.26.4`. @@ -81,6 +85,13 @@ Run the executable showcase: go test -tags showcase ./showcase ``` +Run the Gradle plugin checks: + +```bash +cd gradle-plugin +./gradlew check +``` + ## Documentation The main user guide is the [showcase](showcase/README.md). diff --git a/gradle-plugin/.gitignore b/gradle-plugin/.gitignore new file mode 100644 index 00000000..2352321c --- /dev/null +++ b/gradle-plugin/.gitignore @@ -0,0 +1,3 @@ +/.gradle/ +/.kotlin/ +/build/ diff --git a/gradle-plugin/README.md b/gradle-plugin/README.md new file mode 100644 index 00000000..61da5f91 --- /dev/null +++ b/gradle-plugin/README.md @@ -0,0 +1,224 @@ +# Embed Code Gradle Plugin + +The `io.spine.embed-code` plugin runs Embed Code without requiring developers +or CI jobs to download an executable manually. It selects the released binary +for the current platform, installs it under the project's `build/` directory, +and exposes separate `checkEmbedding` and `embedCode` tasks. + +## Apply and Configure + +After the plugin is published, apply its released version: + +```kotlin +plugins { + id("io.spine.embed-code") version "1.2.4" +} +``` + +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-go/gradle-plugin") +} +``` + +The consuming `build.gradle.kts` can then apply `id("io.spine.embed-code")` +without a version while using that included build. + +Configure Embed Code directly in `build.gradle.kts`; no `embed-code.yml` file +is required: + +```kotlin +embedCode { + codePath.set(layout.projectDirectory.dir("src/main/java")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("...") + info.set(false) + stacktrace.set(false) +} +``` + +`docsPath` is required. Configure either one unnamed `codePath` or one or more +named sources. By default, the plugin downloads the Embed Code release with the +same version as the plugin. The other properties use the same defaults as the +Embed Code command-line application. + +| Property | Default | Purpose | +| --- | --- | --- | +| `version` | Plugin version | Selects the executable release. | +| `codePath` | Required without named sources | Sets one unnamed source root. | +| `namedSource(name, directory)` | Required without `codePath` | Adds a `$name/` source root. | +| `docsPath` | Required | Sets the documentation root to scan. | +| `docIncludes` | `**/*.md`, `**/*.html` | Selects documentation files. | +| `docExcludes` | Empty | Skips matching documentation files. | +| `separator` | `...` | Separates joined fragment parts. | +| `info` | `false` | Enables informational logging. | +| `stacktrace` | `false` | Prints stack traces after panics. | +| `downloadBaseUrl` | GitHub Releases | Selects a release mirror or test repository. | + +If a matching CLI release has a problem, override only the executable version +while keeping the applied plugin version unchanged: + +```kotlin +embedCode { + version.set("1.2.3") +} +``` + +### Named Source Roots + +Use `namedSource` when documentation embeds code from multiple modules: + +```kotlin +embedCode { + namedSource( + "company-site", + layout.projectDirectory.dir("company-site"), + ) + namedSource( + "jxbrowser", + layout.projectDirectory.dir("browser"), + ) + docsPath.set(layout.projectDirectory) +} +``` + +Embedding instructions select these roots with `$company-site/` and +`$jxbrowser/`. The plugin writes the corresponding Embed Code configuration +into the Gradle task's temporary directory and passes it to the executable; +the project does not need an `embed-code.yml` file. + +`codePath` and `namedSource(...)` are mutually exclusive. Multiple independent +documentation targets are not exposed by this Gradle DSL. + +## Run + +Check that documentation already contains current source snippets: + +```bash +./gradlew :checkEmbedding +``` + +Update documentation in place: + +```bash +./gradlew :embedCode +``` + +Both tasks belong to the `embed code` group. `installEmbedCode` is an ungrouped +internal preparation task, so it is hidden from the normal `tasks` report but +remains visible with `tasks --all`. Gradle runs it automatically before either +execution task and reuses its output until the requested version, platform, +download URL, or build directory changes. + +The plugin prefers the `checkEmbedding` and `embedCode` task names. If one is +already occupied, it prepends underscores until it finds an available name, for +example `_checkEmbedding` or `__checkEmbedding`. Existing tasks are unchanged; +use the `tasks` report to see the selected names. The leading `:` in the +commands above selects the root task explicitly; without it, a multi-project +build may also run every subproject task with the same name. + +The plugin supports the platforms for which Embed Code currently publishes +release assets: + +- Linux AMD64. +- Windows AMD64. +- macOS AMD64 and ARM64. + +## Compatibility + +The published plugin implementation targets Java 8 bytecode. The tested range +is Gradle 6.9.4 through 9.5.0. The JVM used to run Gradle must also satisfy the +selected Gradle version's own Java compatibility requirements. + +The plugin build uses Kotlin DSL and Kotlin tests, while its published classes +are Java. Keeping Kotlin 2.x off the consumer plugin classpath allows older +Gradle Kotlin DSL compilers to load the plugin. + +The plugin declares support for Gradle's configuration cache. Functional tests +run plugin tasks with `--configuration-cache` and verify cache reuse. + +## Develop + +Run compilation, plugin validation, unit tests, and TestKit functional tests: + +```bash +cd gradle-plugin +./gradlew check +``` + +The functional tests create local fake release assets. They do not download or +execute a real GitHub release. + +Publish the current plugin version to the local Maven repository when testing +it from another checkout: + +```bash +./gradlew 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 "1.2.4" +} +``` + +The plugin publication version and its default Embed Code executable version +are both read from the repository's root `VERSION` file. + +## Publish + +The plugin is configured for the [Gradle Plugin Portal][plugin-portal]. Before +publishing, verify that the matching `v` GitHub release contains all +platform executables. The plugin uses its own version as the default executable +version, so publishing it before the binaries would leave new installations +without a downloadable asset. + +Request validation from the Plugin Portal without publishing a version: + +```bash +./gradlew 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 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/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..356c8793 --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,135 @@ +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.external.javadoc.StandardJavadocDocletOptions +import org.gradle.plugin.compatibility.compatibility +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.gradle.plugin.publish) + `java-gradle-plugin` + `maven-publish` +} + +val versionFile = layout.projectDirectory.file("../VERSION") +val embedCodeVersion = providers.fileContents( + providers.provider { versionFile }, +).asText.map { it.trim() }.get() +require(embedCodeVersion.isNotEmpty()) { + "The Embed Code version in ../VERSION must not be empty." +} + +group = "io.spine.tools" +version = embedCodeVersion + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +tasks.named("compileJava") { + options.release.set(8) +} + +tasks.named("compileTestKotlin") { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) +} + +tasks.named("jar") { + from(layout.projectDirectory.file("../LICENSE")) { + into("META-INF") + } +} + +// Getter docs use concise "Returns..." prose instead of duplicate `@return` tags. +tasks.withType().configureEach { + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:-missing", true) +} + +dependencies { + testImplementation(libs.junit.jupiter) + testImplementation(libs.kotest.assertions.core) + testRuntimeOnly(libs.junit.launcher) +} + +tasks.test { + useJUnitPlatform() + inputs.property( + "embedCodeGradle6JavaHome", + providers.environmentVariable("EMBED_CODE_GRADLE_6_JAVA_HOME").orElse(""), + ) +} + +tasks.processResources { + val versionProperties = mapOf("embedCodeVersion" to project.version.toString()) + inputs.properties(versionProperties) + filesMatching("**/version.properties") { + expand(versionProperties) + } +} + +gradlePlugin { + website.set( + "https://github.com/SpineEventEngine/embed-code-go/blob/master/gradle-plugin/README.md", + ) + vcsUrl.set("https://github.com/SpineEventEngine/embed-code-go") + 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 { + 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-go") + 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-go") + connection.set( + "scm:git:https://github.com/SpineEventEngine/embed-code-go.git", + ) + developerConnection.set( + "scm:git:ssh://git@github.com/SpineEventEngine/embed-code-go.git", + ) + } + } + } +} diff --git a/gradle-plugin/gradle.properties b/gradle-plugin/gradle.properties new file mode 100644 index 00000000..60413e3a --- /dev/null +++ b/gradle-plugin/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m +org.gradle.parallel=true +kotlin.code.style=official +kotlin.stdlib.default.dependency=false diff --git a/gradle-plugin/gradle/libs.versions.toml b/gradle-plugin/gradle/libs.versions.toml new file mode 100644 index 00000000..38432dde --- /dev/null +++ b/gradle-plugin/gradle/libs.versions.toml @@ -0,0 +1,15 @@ +[versions] +junit = "5.14.4" +kotest = "6.2.2" +kotlin = "2.4.0" +platform = "1.14.4" +plugin-publish = "2.1.1" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "platform" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } + +[plugins] +gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "plugin-publish" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.jar b/gradle-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..b1b8ef56 Binary files /dev/null and b/gradle-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..31a44d20 --- /dev/null +++ b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradle-plugin/gradlew b/gradle-plugin/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/gradle-plugin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradle-plugin/gradlew.bat b/gradle-plugin/gradlew.bat new file mode 100644 index 00000000..24c62d56 --- /dev/null +++ b/gradle-plugin/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts new file mode 100644 index 00000000..01ffe2c3 --- /dev/null +++ b/gradle-plugin/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "embed-code-gradle-plugin" diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java new file mode 100644 index 00000000..8056e184 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeExtension.java @@ -0,0 +1,90 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.Directory; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; + +/** + * Configures Embed Code for a Gradle project. + * + *

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

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

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

+ * + * @return the base URL containing versioned Embed Code release directories + */ + public abstract Property getDownloadBaseUrl(); +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java new file mode 100644 index 00000000..1858ea28 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlatform.java @@ -0,0 +1,94 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.GradleException; + +import java.util.Locale; +import java.util.Objects; + +/** A released executable selected for an operating system and architecture. */ +final class EmbedCodePlatform { + + private final String assetName; + private final String executableName; + + /** + * Creates a platform description. + * + * @param assetName the release asset to download + * @param executableName the installed executable name + */ + EmbedCodePlatform(String assetName, String executableName) { + this.assetName = assetName; + this.executableName = executableName; + } + + /** Returns the platform-specific release asset name. */ + String getAssetName() { + return assetName; + } + + /** Returns the executable name after extraction. */ + String getExecutableName() { + return executableName; + } + + /** Selects the release asset for {@code osName} and {@code architecture}. */ + static EmbedCodePlatform detect(String osName, String architecture) { + String os = osName.toLowerCase(Locale.ROOT); + String arch = architecture.toLowerCase(Locale.ROOT); + boolean isAmd64 = arch.equals("amd64") || arch.equals("x86_64"); + boolean isArm64 = arch.equals("aarch64") || arch.equals("arm64"); + + if (os.contains("mac") && isArm64) { + return new EmbedCodePlatform( + "embed-code-macos-arm64.zip", + "embed-code-macos-arm64" + ); + } + if (os.contains("mac") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-macos-x64.zip", + "embed-code-macos-x64" + ); + } + if (os.contains("linux") && isAmd64) { + return new EmbedCodePlatform("embed-code-linux", "embed-code-linux"); + } + if (os.contains("windows") && isAmd64) { + return new EmbedCodePlatform( + "embed-code-windows.exe", + "embed-code-windows.exe" + ); + } + throw new GradleException( + "Embed Code does not publish a binary for operating system `" + osName + + "` and architecture `" + architecture + "`." + ); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof EmbedCodePlatform)) { + return false; + } + EmbedCodePlatform that = (EmbedCodePlatform) other; + return assetName.equals(that.assetName) + && executableName.equals(that.executableName); + } + + @Override + public int hashCode() { + return Objects.hash(assetName, executableName); + } + + @Override + public String toString() { + return "EmbedCodePlatform{" + + "assetName='" + assetName + '\'' + + ", executableName='" + executableName + '\'' + + '}'; + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java new file mode 100644 index 00000000..14a249b2 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodePlugin.java @@ -0,0 +1,141 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.tasks.TaskProvider; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; + +/** Registers automatic installation and execution tasks for Embed Code. */ +public final class EmbedCodePlugin implements Plugin { + + private static final String DEFAULT_DOWNLOAD_BASE_URL = + "https://github.com/SpineEventEngine/embed-code-go/releases/download"; + private static final String VERSION_RESOURCE = + "/io/spine/embedcode/gradle/version.properties"; + private static final String TASK_GROUP = "embed code"; + + /** Applies the plugin to {@code project}. */ + @Override + public void apply(Project project) { + String checkTaskName = availableTaskName(project, "checkEmbedding"); + String embedTaskName = availableTaskName(project, "embedCode"); + EmbedCodeExtension extension = project.getExtensions().create( + "embedCode", + EmbedCodeExtension.class + ); + extension.getVersion().convention(pluginVersion()); + extension.getDocIncludes().convention(Arrays.asList("**/*.md", "**/*.html")); + extension.getDocExcludes().convention(Collections.emptyList()); + extension.getNamedSources().convention(Collections.emptyMap()); + extension.getSeparator().convention("..."); + extension.getInfo().convention(false); + extension.getStacktrace().convention(false); + extension.getDownloadBaseUrl().convention(DEFAULT_DOWNLOAD_BASE_URL); + + EmbedCodePlatform platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch") + ); + TaskProvider installTask = project.getTasks().register( + "installEmbedCode", + InstallEmbedCodeTask.class, + task -> { + task.setDescription("Installs the requested Embed Code executable"); + task.getVersion().set(extension.getVersion()); + task.getDownloadBaseUrl().set(extension.getDownloadBaseUrl()); + task.getAssetName().set(platform.getAssetName()); + task.getExecutableName().set(platform.getExecutableName()); + task.getExecutableFile().set( + project.getLayout().getBuildDirectory().file( + extension.getVersion().map( + version -> "embed-code/" + version + + '/' + platform.getExecutableName() + ) + ) + ); + } + ); + + registerExecutionTask( + project, + extension, + installTask, + checkTaskName, + "Checks embedded code snippets are up to date", + "check" + ); + registerExecutionTask( + project, + extension, + installTask, + embedTaskName, + "Updates embedded code snippets from source files", + "embed" + ); + } + + /** Registers one mode-specific execution task backed by {@code installTask}. */ + private static void registerExecutionTask( + Project project, + EmbedCodeExtension extension, + TaskProvider installTask, + String name, + String description, + String mode + ) { + project.getTasks().register(name, EmbedCodeTask.class, task -> { + task.setGroup(TASK_GROUP); + task.setDescription(description); + task.getMode().set(mode); + task.getCodePath().set(extension.getCodePath()); + task.getNamedSources().set(extension.getNamedSources()); + task.getNamedSourceDirectories().from(extension.getNamedSourceDirectories()); + task.getDocsPath().set(extension.getDocsPath()); + task.getDocIncludes().set(extension.getDocIncludes()); + task.getDocExcludes().set(extension.getDocExcludes()); + task.getSeparator().set(extension.getSeparator()); + task.getInfo().set(extension.getInfo()); + task.getStacktrace().set(extension.getStacktrace()); + task.getExecutableFile().set( + installTask.flatMap(InstallEmbedCodeTask::getExecutableFile) + ); + task.getWorkingDirectory().set(project.getLayout().getProjectDirectory()); + }); + } + + /** Returns {@code preferredName}, prepending underscores until the task name is unused. */ + private static String availableTaskName(Project project, String preferredName) { + String candidate = preferredName; + while (project.getTasks().getNames().contains(candidate)) { + candidate = '_' + candidate; + } + return candidate; + } + + /** Returns the Embed Code version packaged into the plugin at build time. */ + private static String pluginVersion() { + Properties properties = new Properties(); + try (InputStream resource = EmbedCodePlugin.class.getResourceAsStream(VERSION_RESOURCE)) { + if (resource == null) { + throw new GradleException( + "Embed Code plugin version resource is missing: " + VERSION_RESOURCE + ); + } + properties.load(resource); + } catch (IOException error) { + throw new GradleException("Could not read the Embed Code plugin version.", error); + } + + String version = properties.getProperty("version", "").trim(); + if (version.isEmpty()) { + throw new GradleException("The Embed Code plugin version must not be empty."); + } + return version; + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java new file mode 100644 index 00000000..94a831c5 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/EmbedCodeTask.java @@ -0,0 +1,259 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.work.DisableCachingByDefault; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** Runs Embed Code in either check or embed mode. */ +@DisableCachingByDefault(because = "Embed Code checks or updates documentation files in place") +public abstract class EmbedCodeTask extends DefaultTask { + + /** Returns process execution without project access at execution time. */ + @Inject + protected abstract ExecOperations getExecOperations(); + + /** Returns the execution mode assigned by the plugin. */ + @Input + public abstract Property getMode(); + + /** Returns the source root passed to {@code -code-path}. */ + @InputDirectory + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getCodePath(); + + /** Returns named source roots included in an internally generated configuration. */ + @Input + public abstract MapProperty getNamedSources(); + + /** Returns named source directories with their producing task dependencies. */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getNamedSourceDirectories(); + + /** Returns the documentation root passed to {@code -docs-path}. */ + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getDocsPath(); + + /** Returns documentation include patterns passed to {@code -doc-includes}. */ + @Input + public abstract ListProperty getDocIncludes(); + + /** Returns documentation exclude patterns passed to {@code -doc-excludes}. */ + @Input + public abstract ListProperty getDocExcludes(); + + /** Returns the fragment separator passed to {@code -separator}. */ + @Input + public abstract Property getSeparator(); + + /** Returns whether informational logging is enabled. */ + @Input + public abstract Property getInfo(); + + /** Returns whether panic stack traces are enabled. */ + @Input + public abstract Property getStacktrace(); + + /** Returns the installed platform executable. */ + @InputFile + @PathSensitive(PathSensitivity.NONE) + public abstract RegularFileProperty getExecutableFile(); + + /** Returns the process working directory. */ + @Internal + public abstract DirectoryProperty getWorkingDirectory(); + + /** Executes Embed Code with arguments derived from the Gradle extension. */ + @TaskAction + public void runEmbedCode() { + Map namedSources = new TreeMap<>(getNamedSources().get()); + boolean hasDirectSource = getCodePath().isPresent(); + boolean hasNamedSources = !namedSources.isEmpty(); + if (hasDirectSource == hasNamedSources) { + throw new GradleException( + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + ); + } + + List arguments = new ArrayList<>(); + arguments.add("-mode=" + getMode().get()); + if (hasNamedSources) { + arguments.add("-config-path=" + writeNamedSourceConfiguration(namedSources)); + } else { + arguments.add("-code-path=" + getCodePath().get().getAsFile().getAbsolutePath()); + arguments.add("-docs-path=" + getDocsPath().get().getAsFile().getAbsolutePath()); + if (!getDocIncludes().get().isEmpty()) { + arguments.add("-doc-includes=" + String.join(",", getDocIncludes().get())); + } + if (!getDocExcludes().get().isEmpty()) { + arguments.add("-doc-excludes=" + String.join(",", getDocExcludes().get())); + } + arguments.add("-separator=" + getSeparator().get()); + arguments.add("-info=" + getInfo().get()); + arguments.add("-stacktrace=" + getStacktrace().get()); + } + + getExecOperations().exec(spec -> { + spec.executable(getExecutableFile().get().getAsFile()); + spec.args(arguments); + spec.setWorkingDir(getWorkingDirectory().get().getAsFile()); + }); + } + + /** Writes the generated configuration used when named source roots are configured. */ + private Path writeNamedSourceConfiguration(Map namedSources) { + Map normalizedSources = new TreeMap<>(); + for (Map.Entry source : namedSources.entrySet()) { + Path path = Paths.get(source.getValue()); + if (!path.isAbsolute()) { + path = getWorkingDirectory().get().getAsFile().toPath().resolve(path); + } + path = path.normalize().toAbsolutePath(); + if (!Files.isDirectory(path)) { + throw new GradleException( + "Embed Code source `" + source.getKey() + "` is not a directory: " + path + ); + } + normalizedSources.put(source.getKey(), path.toString()); + } + + String json = createConfigurationJson( + normalizedSources, + getDocsPath().get().getAsFile().getAbsolutePath(), + getDocIncludes().get(), + getDocExcludes().get(), + getSeparator().get(), + getInfo().get(), + getStacktrace().get() + ); + Path configuration = getTemporaryDir().toPath().resolve("embed-code.json"); + try { + Files.write(configuration, json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new GradleException( + "Could not write the generated Embed Code configuration to " + + configuration + '.', + exception + ); + } + return configuration; + } + + /** Creates a JSON document accepted by Embed Code's YAML configuration parser. */ + static String createConfigurationJson( + Map namedSources, + String docsPath, + List docIncludes, + List docExcludes, + String separator, + boolean info, + boolean stacktrace + ) { + StringBuilder json = new StringBuilder(); + json.append("{\n \"code-path\": [\n"); + int index = 0; + for (Map.Entry source : namedSources.entrySet()) { + if (index > 0) { + json.append(",\n"); + } + json.append(" {\"name\": "); + appendJsonString(json, source.getKey()); + json.append(", \"path\": "); + appendJsonString(json, source.getValue()); + json.append('}'); + index++; + } + json.append("\n ],\n \"docs-path\": "); + appendJsonString(json, docsPath); + json.append(",\n \"doc-includes\": "); + appendJsonArray(json, docIncludes); + json.append(",\n \"doc-excludes\": "); + appendJsonArray(json, docExcludes); + json.append(",\n \"separator\": "); + appendJsonString(json, separator); + json.append(",\n \"info\": ").append(info); + json.append(",\n \"stacktrace\": ").append(stacktrace); + json.append("\n}\n"); + return json.toString(); + } + + /** Appends a JSON array containing {@code values}. */ + private static void appendJsonArray(StringBuilder json, List values) { + json.append('['); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + json.append(", "); + } + appendJsonString(json, values.get(i)); + } + json.append(']'); + } + + /** Appends {@code value} as an escaped JSON string. */ + private static void appendJsonString(StringBuilder json, String value) { + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + switch (character) { + case '"': + json.append("\\\""); + break; + case '\\': + json.append("\\\\"); + break; + case '\b': + json.append("\\b"); + break; + case '\f': + json.append("\\f"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (character < 0x20) { + json.append(String.format(Locale.ROOT, "\\u%04x", (int) character)); + } else { + json.append(character); + } + } + } + json.append('"'); + } +} diff --git a/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java new file mode 100644 index 00000000..7bdbe3a5 --- /dev/null +++ b/gradle-plugin/src/main/java/io/spine/embedcode/gradle/InstallEmbedCodeTask.java @@ -0,0 +1,197 @@ +package io.spine.embedcode.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLConnection; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Downloads and prepares the Embed Code executable selected for the host. + * + *

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

+ */ +@DisableCachingByDefault( + because = "The downloaded release asset is already reused as a task output" +) +public abstract class InstallEmbedCodeTask extends DefaultTask { + + private static final int CONNECT_TIMEOUT_MILLIS = 30_000; + private static final int READ_TIMEOUT_MILLIS = 120_000; + + /** Returns the Embed Code release version. */ + @Input + public abstract Property getVersion(); + + /** Returns the base URL containing versioned release directories. */ + @Input + public abstract Property getDownloadBaseUrl(); + + /** Returns the platform-specific release asset name. */ + @Input + public abstract Property getAssetName(); + + /** Returns the executable name expected inside an archive or used directly. */ + @Input + public abstract Property getExecutableName(); + + /** Returns the installed executable used by Embed Code execution tasks. */ + @OutputFile + public abstract RegularFileProperty getExecutableFile(); + + /** Downloads, extracts when necessary, and marks the executable runnable. */ + @TaskAction + public void install() { + String requestedVersion = getVersion().get().trim(); + if (requestedVersion.isEmpty()) { + throw new GradleException("Embed Code version must not be empty."); + } + + String releaseTag = requestedVersion.startsWith("v") + ? requestedVersion + : "v" + requestedVersion; + String asset = getAssetName().get(); + String baseUrl = trimTrailingSlashes(getDownloadBaseUrl().get()); + URI source = URI.create(baseUrl + '/' + releaseTag + '/' + asset); + Path destination = getExecutableFile().get().getAsFile().toPath(); + Path download = getTemporaryDir().toPath().resolve(asset); + Path preparedExecutable = getTemporaryDir().toPath() + .resolve(getExecutableName().get()); + + try { + Files.createDirectories(destination.getParent()); + getLogger().lifecycle("Downloading Embed Code {} from {}", requestedVersion, source); + download(source, download); + + if (asset.endsWith(".zip")) { + extractExecutable(download, getExecutableName().get(), preparedExecutable); + } else { + Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING); + } + + if (!preparedExecutable.toFile().setExecutable(true, false)) { + throw new GradleException( + "Could not make `" + preparedExecutable + "` executable." + ); + } + moveAtomically(preparedExecutable, destination); + } catch (IOException exception) { + throw new GradleException( + "Could not install Embed Code from " + source + '.', + exception + ); + } + } + + /** Downloads {@code source} into {@code destination}, reporting HTTP failures clearly. */ + private static void download(URI source, Path destination) { + URLConnection connection = null; + try { + connection = source.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + + if (connection instanceof HttpURLConnection) { + HttpURLConnection http = (HttpURLConnection) connection; + http.setInstanceFollowRedirects(true); + int status = http.getResponseCode(); + if (status < 200 || status > 299) { + throw new GradleException( + "Could not download Embed Code: HTTP " + status + + " from " + source + '.' + ); + } + } + + try (InputStream input = connection.getInputStream(); + OutputStream output = Files.newOutputStream(destination)) { + copy(input, output); + } + } catch (IOException exception) { + throw new GradleException( + "Could not download Embed Code from " + source + '.', + exception + ); + } finally { + if (connection instanceof HttpURLConnection) { + ((HttpURLConnection) connection).disconnect(); + } + } + } + + /** Extracts {@code entryName} from {@code archive} into {@code destination}. */ + private static void extractExecutable(Path archive, String entryName, Path destination) + throws IOException { + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry = zip.getNextEntry(); + while (entry != null) { + String fileName = entry.getName(); + int slash = fileName.lastIndexOf('/'); + if (slash >= 0) { + fileName = fileName.substring(slash + 1); + } + if (!entry.isDirectory() && fileName.equals(entryName)) { + try (OutputStream output = Files.newOutputStream(destination)) { + copy(zip, output); + } + return; + } + zip.closeEntry(); + entry = zip.getNextEntry(); + } + } + throw new GradleException( + "Archive `" + archive + "` does not contain `" + entryName + "`." + ); + } + + /** Copies all bytes from {@code input} into {@code output}. */ + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[8_192]; + int count = input.read(buffer); + while (count >= 0) { + output.write(buffer, 0, count); + count = input.read(buffer); + } + } + + /** Moves {@code source} to {@code destination}, atomically when supported. */ + private static void moveAtomically(Path source, Path destination) throws IOException { + try { + Files.move( + source, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Removes trailing slashes without changing a URL scheme. */ + private static String trimTrailingSlashes(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } +} diff --git a/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties new file mode 100644 index 00000000..d5e9750f --- /dev/null +++ b/gradle-plugin/src/main/resources/io/spine/embedcode/gradle/version.properties @@ -0,0 +1 @@ +version=${embedCodeVersion} diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt new file mode 100644 index 00000000..a7deee78 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePlatformSpec.kt @@ -0,0 +1,46 @@ +package io.spine.embedcode.gradle + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.gradle.api.GradleException +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`EmbedCodePlatform` should") +internal class EmbedCodePlatformSpec { + + @Test + fun `select Apple silicon asset`() { + EmbedCodePlatform.detect("Mac OS X", "aarch64") shouldBe + EmbedCodePlatform("embed-code-macos-arm64.zip", "embed-code-macos-arm64") + } + + @Test + fun `select Intel macOS asset`() { + EmbedCodePlatform.detect("Mac OS X", "x86_64") shouldBe + EmbedCodePlatform("embed-code-macos-x64.zip", "embed-code-macos-x64") + } + + @Test + fun `select Linux asset`() { + EmbedCodePlatform.detect("Linux", "amd64") shouldBe + EmbedCodePlatform("embed-code-linux", "embed-code-linux") + } + + @Test + fun `select Windows asset`() { + EmbedCodePlatform.detect("Windows 11", "amd64") shouldBe + EmbedCodePlatform("embed-code-windows.exe", "embed-code-windows.exe") + } + + @Test + fun `reject platform without release binary`() { + val error = shouldThrow { + EmbedCodePlatform.detect("Linux", "aarch64") + } + + error.message shouldBe + "Embed Code does not publish a binary for operating system `Linux`" + + " and architecture `aarch64`." + } +} 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 00000000..6f513c47 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodePluginIgTest.kt @@ -0,0 +1,357 @@ +package io.spine.embedcode.gradle + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@DisplayName("`EmbedCodePlugin` should") +internal class EmbedCodePluginIgTest { + + @TempDir + private lateinit var projectDirectory: Path + + private lateinit var releaseDirectory: Path + + @BeforeEach + fun setUp() { + Files.createDirectories(projectDirectory.resolve("code")) + Files.createDirectories(projectDirectory.resolve("docs")) + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + "rootProject.name = \"test-project\"\n", + ) + + releaseDirectory = projectDirectory.resolve("releases") + createFakeRelease(releaseDirectory) + writeBuildFile() + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle configuration`() { + val result = runner(":checkEmbedding").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments shouldContain "-code-path=${projectDirectory.resolve("code").toRealPath()}" + arguments shouldContain "-docs-path=${projectDirectory.resolve("docs").toRealPath()}" + arguments shouldContain "-doc-includes=**/*.md,**/*.html" + arguments shouldContain "-doc-excludes=drafts/**,generated/**" + arguments shouldContain "-separator=---" + arguments shouldContain "-info=true" + arguments shouldContain "-stacktrace=true" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse the configuration cache`() { + runner(":checkEmbedding").build() + + val result = runner(":checkEmbedding").build() + + result.output shouldContain "Reusing configuration cache." + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + } + + @Test + fun `install platform release asset`() { + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + val installedExecutable = projectDirectory.resolve( + "build/embed-code/${bundledVersion()}/$executableName", + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `allow overriding the bundled Embed Code version`() { + val overrideVersion = "0.0.0-test" + createFakeRelease(releaseDirectory, overrideVersion) + writeBuildFile(overrideVersion) + + val result = runner(":checkEmbedding").build() + + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + val executableName = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ).executableName + Files.exists( + projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), + ) shouldBe true + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run check mode with Gradle 6_9_4`() { + val javaHome = System.getenv("EMBED_CODE_GRADLE_6_JAVA_HOME") + assumeTrue( + !javaHome.isNullOrBlank(), + "Set EMBED_CODE_GRADLE_6_JAVA_HOME to a JDK supported by Gradle 6.9.4.", + ) + + val result = runner(":checkEmbedding", useConfigurationCache = false) + .withGradleVersion("6.9.4") + .withEnvironment(System.getenv() + ("JAVA_HOME" to javaHome)) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reuse installation when running embed mode`() { + runner(":checkEmbedding").build() + releaseDirectory.toFile().deleteRecursively() + + val result = runner(":embedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.UP_TO_DATE + result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `run with named source roots and generated configuration`() { + Files.createDirectories(projectDirectory.resolve("company-site")) + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile() + + val result = runner(":checkEmbedding").build() + + result.task(":checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + val arguments = Files.readAllLines(projectDirectory.resolve("arguments.txt")) + arguments shouldContain "-mode=check" + arguments.single { it.startsWith("-config-path=") } + + val configuration = Files.readString(projectDirectory.resolve("generated-config.json")) + configuration shouldContain "\"name\": \"company-site\"" + val companySitePath = projectDirectory.resolve("company-site").toRealPath() + val browserPath = projectDirectory.resolve("browser").toRealPath() + configuration shouldContain "\"path\": \"$companySitePath\"" + configuration shouldContain "\"name\": \"jxbrowser\"" + configuration shouldContain "\"path\": \"$browserPath\"" + configuration shouldContain "\"docs-path\": \"${projectDirectory.toRealPath()}\"" + } + + @Test + fun `reject direct and named source roots together`() { + Files.createDirectories(projectDirectory.resolve("browser")) + writeNamedSourcesBuildFile(includeDirectSource = true) + + val result = runner(":checkEmbedding").buildAndFail() + + result.output shouldContain + "Configure exactly one of `codePath` or `namedSource(...)` for Embed Code." + } + + @Test + fun `report missing release asset`() { + releaseDirectory.toFile().deleteRecursively() + + val result = runner(":checkEmbedding").buildAndFail() + + result.output shouldContain "Could not download Embed Code" + } + + @Test + fun `list only execution tasks under the Embed Code group`() { + val result = runner("tasks").build() + + result.output shouldContain "Embed code tasks" + result.output shouldContain "checkEmbedding - Checks embedded code snippets are up to date" + result.output shouldContain "embedCode - Updates embedded code snippets from source files" + result.output shouldNotContain "installEmbedCode" + + val allTasks = runner("tasks", "--all").build() + allTasks.output shouldContain + "installEmbedCode - Installs the requested Embed Code executable" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied checkEmbedding task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("checkEmbedding") + tasks.register("_checkEmbedding") + } + """.trimIndent(), + ) + + val result = runner(":__checkEmbedding").build() + + result.task(":__checkEmbedding")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "check" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `prepend underscores to an occupied embedCode task name`() { + Files.writeString( + projectDirectory.resolve("settings.gradle.kts"), + """ + rootProject.name = "test-project" + + gradle.beforeProject { + tasks.register("embedCode") + tasks.register("_embedCode") + } + """.trimIndent(), + ) + + val result = runner(":__embedCode").build() + + result.task(":__embedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" + } + + /** Creates a runner using the plugin-under-test classpath. */ + private fun runner( + vararg arguments: String, + useConfigurationCache: Boolean = true, + ): GradleRunner { + val gradleArguments = arguments.toMutableList() + if (useConfigurationCache) { + gradleArguments.add("--configuration-cache") + } + gradleArguments.add("--stacktrace") + return GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withArguments(gradleArguments) + .withPluginClasspath() + } + + /** Writes a consuming build configured entirely through the plugin extension. */ + private fun writeBuildFile(version: String? = null) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + $versionConfiguration + downloadBaseUrl.set("$baseUrl") + codePath.set(layout.projectDirectory.dir("code")) + docsPath.set(layout.projectDirectory.dir("docs")) + docIncludes.set(listOf("**/*.md", "**/*.html")) + docExcludes.set(listOf("drafts/**", "generated/**")) + separator.set("---") + info.set(true) + stacktrace.set(true) + } + """.trimIndent(), + ) + } + + /** Writes a consuming build with two named source roots and no YAML file. */ + private fun writeNamedSourcesBuildFile(includeDirectSource: Boolean = false) { + val baseUrl = releaseDirectory.toUri().toString().trimEnd('/') + val directSource = if (includeDirectSource) { + "codePath.set(layout.projectDirectory.dir(\"code\"))" + } else { + "" + } + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + plugins { + id("io.spine.embed-code") + } + + embedCode { + downloadBaseUrl.set("$baseUrl") + $directSource + namedSource("company-site", layout.projectDirectory.dir("company-site")) + namedSource("jxbrowser", layout.projectDirectory.dir("browser")) + docsPath.set(layout.projectDirectory) + } + """.trimIndent(), + ) + } + + /** Creates a host-specific fake release asset that records received arguments. */ + private fun createFakeRelease(root: Path, version: String = bundledVersion()) { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val versionDirectory = root.resolve("v$version") + Files.createDirectories(versionDirectory) + val executable = projectDirectory.resolve(platform.executableName) + Files.writeString( + executable, + """ + #!/bin/sh + : > arguments.txt + for argument in "${'$'}@"; do + printf '%s\n' "${'$'}argument" >> arguments.txt + case "${'$'}argument" in + -mode=check) printf 'check\n' > mode.txt ;; + -mode=embed) printf 'embed\n' > mode.txt ;; + -config-path=*) cp "${'$'}{argument#-config-path=}" generated-config.json ;; + esac + done + """.trimIndent() + "\n", + ) + + val asset = versionDirectory.resolve(platform.assetName) + if (platform.assetName.endsWith(".zip")) { + ZipOutputStream(Files.newOutputStream(asset)).use { zip -> + zip.putNextEntry(ZipEntry(platform.executableName)) + Files.newInputStream(executable).use { it.copyTo(zip) } + zip.closeEntry() + } + } else { + Files.copy(executable, asset) + } + } + + /** Returns the Embed Code version generated from the plugin project's VERSION file. */ + private fun bundledVersion(): String { + val properties = Properties() + val resource = requireNotNull( + EmbedCodePlugin::class.java.getResourceAsStream( + "/io/spine/embedcode/gradle/version.properties", + ), + ) + resource.use { properties.load(it) } + return requireNotNull(properties.getProperty("version")).trim() + } +}