diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0482a5e2f..06993e269 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,8 @@ name: Release # Once Maven Central serves both, the Gradle plugins go to the Gradle Plugin Portal, signed with the same # release key, and the IntelliJ plugin goes to the JetBrains Marketplace, verified against the IDEs it # supports first. After publishing, a draft GitHub release on the tag collects the runnable jar, the Gradle -# and IDE plugins and the native binaries. +# and IDE plugins, the native binaries and the WebAssembly module, which a job of its own builds on Oracle +# GraalVM. # # Needs seven repository secrets: JRELEASER_MAVENCENTRAL_USERNAME and JRELEASER_MAVENCENTRAL_PASSWORD (the # Central Portal user token), JRELEASER_GPG_SECRET_KEY and JRELEASER_GPG_PASSPHRASE, GRADLE_PUBLISH_KEY and @@ -194,6 +195,71 @@ jobs: if-no-files-found: ignore retention-days: 7 + # The formatter as a WebAssembly module, for the playground on openjavaformat.dev. Built on Oracle GraalVM, the + # only distribution with the Web Image backend (native-image --tool:svm-wasm), and tested in Node against the + # runnable jar of the same tag. Maven Central does not carry it, so only the draft GitHub release waits for it: a + # failure here holds back nothing else, and "Re-run failed jobs" builds it again and then opens the draft. + wasm: + name: wasm + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The jars, and the runnable jar the module is compared with, compile with Java 21 through JDK21_HOME, as in + # native-images. Installed before GraalVM, so that Gradle still runs on GraalVM. + - name: Install JDK 21 for the jars + id: jdk21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + + # Pinned to the patch: Web Image is experimental, and its options and output change between releases. + - name: Install Oracle GraalVM 25 for the module + uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8 + with: + java-version: '25.0.4' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Web Image runs wasm-opt from the PATH, and needs Binaryen 119 or later; Ubuntu's package is older. + - name: Install Binaryen + env: + BINARYEN_VERSION: version_133 + BINARYEN_SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489 + run: | + archive="binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz" + curl --fail --silent --show-error --location --output "${RUNNER_TEMP}/${archive}" \ + "https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${archive}" + echo "${BINARYEN_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}" + echo "${RUNNER_TEMP}/binaryen-${BINARYEN_VERSION}/bin" >> "$GITHUB_PATH" + + # The tests run the module as a page does, and Node 24 has the WebAssembly GC and exception handling it needs. + # No cache: the tests install no packages, and a release must not restore one another run could have written. + - name: Install Node.js 24 for the tests + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + + # wasmTest: the native image's smoke test, then every test case's input against the runnable jar. + - name: Build and test the module + run: ./gradlew -Pwasm=true :open-java-format-wasm:wasmTest :open-java-format-wasm:wasmZip + env: + JDK21_HOME: ${{ steps.jdk21.outputs.path }} + + - name: Hand the module to the GitHub release + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: wasm-module + path: open-java-format-wasm/build/distributions/open-java-format-wasm-*.zip + if-no-files-found: error + retention-days: 1 + # Both deployments are uploaded and validated by now. Publishing one is a JReleaser run given its ID, # which builds nothing. One leg per deployment, so that a re-run repeats only the leg that failed. publish: @@ -318,13 +384,13 @@ jobs: JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }} # What Maven Central does not carry — the runnable formatter jar, the Gradle plugins' jar, the IntelliJ - # plugin zip, the Eclipse plugin jar, and every platform's native binary as a plain download — goes into - # a draft GitHub release on the tag, each file signed with the release key. Only once both deployments - # are published; publishing the draft is a click on GitHub, and a re-run fails while a release for the - # tag exists. + # plugin zip, the Eclipse plugin jar, every platform's native binary as a plain download, and the + # WebAssembly module — goes into a draft GitHub release on the tag, each file signed with the release key. + # Only once both deployments are published and the module is built; publishing the draft is a click on + # GitHub, and a re-run fails while a release for the tag exists. github-release: name: draft GitHub release - needs: publish + needs: [publish, wasm] runs-on: ubuntu-latest permissions: contents: write @@ -347,8 +413,14 @@ jobs: pattern: native-image-* merge-multiple: true + - name: Collect the WebAssembly module + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: wasm-module + path: wasm-module + - name: Build the plugins and open the draft release - run: ./gradlew -PreleaseTarget=github -PnativeImages=native-images jreleaserRelease + run: ./gradlew -PreleaseTarget=github -PnativeImages=native-images -PwasmModule=wasm-module jreleaserRelease env: JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }} diff --git a/README.md b/README.md index 24c5933cf..697471167 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ and published from this repository. | [Maven Central](https://central.sonatype.com/namespace/dev.openjavaformat) | `dev.openjavaformat:open-java-format`, with `-spi`, `-native` and `-jdk-bootstrap`, and the Maven plugin `dev.openjavaformat:fmt-maven-plugin` from [its own repository](https://github.com/openjavaformat/fmt-maven-plugin) | | [Gradle Plugin Portal](https://plugins.gradle.org/plugin/dev.openjavaformat.java-format) | `dev.openjavaformat.java-format` | | [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/34359-open-java-format) | the IntelliJ IDEA plugin | -| [GitHub Releases](https://github.com/openjavaformat/open-java-format/releases/latest) | native binaries, a runnable jar, the Gradle, IntelliJ and Eclipse plugins, every file signed | +| [GitHub Releases](https://github.com/openjavaformat/open-java-format/releases/latest) | native binaries, a runnable jar, the Gradle, IntelliJ and Eclipse plugins, the WebAssembly module of [the playground](https://openjavaformat.dev/playground/), every file signed | Why the project exists is in the [manifesto](https://openjavaformat.dev/manifesto/). @@ -161,6 +161,23 @@ GraalVM has to come from outside Gradle: unpacking a JDK that Gradle downloaded preserve the symlink GraalVM ships at `bin/native-image`, and `nativeCompile` then fails on an empty, unexecutable file. +### WebAssembly module + +The playground on openjavaformat.dev runs the formatter in the browser, compiled to WebAssembly by +GraalVM's Web Image backend. Only the release workflow builds it, in its `wasm` job, and the zip goes +on the GitHub release; `./gradlew build` leaves it out. Web Image ships in Oracle GraalVM only, so the +module needs Oracle GraalVM 25 behind `GRAALVM_HOME`, Binaryen 119 or later (`wasm-opt`) on the +`PATH`, and Node.js 24 or later for the tests: + +```bash +./gradlew -Pwasm=true :open-java-format-wasm:wasmTest # build, then test in Node against the runnable jar +./gradlew -Pwasm=true :open-java-format-wasm:wasmDev # a bare page next to the module, to try it +jwebserver -d "$PWD/open-java-format-wasm/build/wasm-dev" +``` + +Without `-Pwasm=true` the subproject is not part of the build at all: it compiles against +`org.graalvm.webimage.api`, a module of Oracle GraalVM that no other JDK has. + ### Running CI locally The workflows run under act in Docker, so a change to `.github/workflows` can be checked before it diff --git a/buildSrc/src/main/groovy/open-java-format.release-conventions.gradle b/buildSrc/src/main/groovy/open-java-format.release-conventions.gradle index ee1ce062b..28663a599 100644 --- a/buildSrc/src/main/groovy/open-java-format.release-conventions.gradle +++ b/buildSrc/src/main/groovy/open-java-format.release-conventions.gradle @@ -13,8 +13,9 @@ plugins { // // `-PreleaseTarget=github` deploys nothing. It is for `jreleaserRelease`, which opens a draft GitHub // release on the tag with what Maven Central does not carry — the runnable formatter jar, the Gradle -// plugins' jar, the IntelliJ plugin zip, the Eclipse plugin jar and every platform's native binary as a -// plain download — each file signed, plus a checksum file. +// plugins' jar, the IntelliJ plugin zip, the Eclipse plugin jar, every platform's native binary as a +// plain download and the WebAssembly module for openjavaformat.dev's playground — each file signed, plus a +// checksum file. // // Publishing is a run of its own: with JRELEASER_MAVENCENTRAL_DEPLOYMENT_ID set, JReleaser publishes that // deployment, which an earlier run uploaded and the Portal validated, and nothing is built. The ID has to @@ -46,8 +47,9 @@ def stagingRepositories = releasedProjects.collect { path -> } // The GitHub release takes the binaries the per-platform jobs collected from -PnativeImages=, exactly -// as the native deployment does. +// as the native deployment does, and the WebAssembly module's zip that the wasm job built from -PwasmModule=. def nativeBinaries = [] +def wasmModules = [] if (releaseTarget == 'github') { def collectedNativeImages = providers.gradleProperty('nativeImages').getOrNull() if (collectedNativeImages == null) { @@ -60,6 +62,17 @@ if (releaseTarget == 'github') { if (nativeBinaries.isEmpty()) { throw new GradleException("No native images to release in ${collectedNativeImages}") } + + def collectedWasmModule = providers.gradleProperty('wasmModule').getOrNull() + if (collectedWasmModule == null) { + throw new GradleException('-PreleaseTarget=github needs -PwasmModule= holding open-java-format-wasm-.zip') + } + wasmModules = fileTree(file(collectedWasmModule)) { + include 'open-java-format-wasm-*.zip' + }.files.sort { it.name } + if (wasmModules.size() != 1) { + throw new GradleException("Expected one open-java-format-wasm-.zip in ${collectedWasmModule}, found ${wasmModules.size()}") + } } jreleaser { @@ -103,9 +116,9 @@ jreleaser { // Name templates, not Gradle's version: the root project sets its version only after applying this // plugin. The jars and the zip are built by the tasks jreleaserRelease depends on below. files { - nativeBinaries.each { binary -> + (nativeBinaries + wasmModules).each { file -> artifact { - path = binary.absolutePath + path = file.absolutePath } } if (releaseTarget == 'github') { diff --git a/open-java-format-wasm/build.gradle b/open-java-format-wasm/build.gradle new file mode 100644 index 000000000..451e7dbf5 --- /dev/null +++ b/open-java-format-wasm/build.gradle @@ -0,0 +1,148 @@ +import org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask + +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath 'org.graalvm.buildtools:native-gradle-plugin:1.1.17' + } +} + +apply plugin: 'org.graalvm.buildtools.native' + +description = 'open-java-format compiled to WebAssembly with GraalVM Web Image, for a web page' + +dependencies { + implementation project(':open-java-format') +} + +// Web Image, `native-image --tool:svm-wasm`, ships in Oracle GraalVM and in no other JDK, and so does the API +// WasmMain is written against: org.graalvm.webimage.api is a module of that JDK, not a Maven artifact. Gradle +// reports Oracle GraalVM's vendor as Oracle, the same as Oracle's plain JDK; nativeImageCapable tells the two apart. +// The jars this module depends on still compile for Java 21. +def oracleGraalVm = { spec -> + spec.languageVersion.set(JavaLanguageVersion.of(25)) + spec.vendor.set(JvmVendorSpec.ORACLE) + spec.nativeImageCapable.set(true) +} + +java { + toolchain(oracleGraalVm) +} + +def exports = [ + 'jdk.compiler/com.sun.tools.javac.file', + 'jdk.compiler/com.sun.tools.javac.main', + 'jdk.compiler/com.sun.tools.javac.parser', + 'jdk.compiler/com.sun.tools.javac.tree', + 'jdk.compiler/com.sun.tools.javac.util', + 'jdk.compiler/com.sun.tools.javac.code', + 'jdk.compiler/com.sun.tools.javac.api' +] + +// Initialized while the image is built, and kept in it. Locations as in open-java-format-native. javac's message +// bundles, and the diagnostic keys and lint categories made from them, end up in the image heap, where Web Image +// accepts only build-time initialized objects. Trees looks javac's methods up as MethodHandles in its static +// initializer: left to run time, that brings in Web Image's general MethodHandle support, and with it float and +// double compare-and-set, which it fails to compile ("unhandled compare" in WasmGCSingleThreadedAtomicsPhase). +// WasmMain$BuildVersion keeps the version passed below. +def buildTimeInitialized = [ + 'com.sun.tools.javac.file.Locations', + 'com.sun.tools.javac.resources', + 'com.sun.tools.javac.util.JCDiagnostic$DiagnosticType', + 'com.sun.tools.javac.util.JCDiagnostic$Error', + 'com.sun.tools.javac.util.JCDiagnostic$Fragment', + 'com.sun.tools.javac.util.JCDiagnostic$LintWarning', + 'com.sun.tools.javac.code.Lint$LintCategory', + 'com.palantir.javaformat.java.Trees', + 'com.palantir.javaformat.java.WasmMain$BuildVersion', +] + +graalvmNative { + // As in open-java-format-native: this project ships its own metadata. + metadataRepository { + enabled = false + } + + binaries.named('main') { + it.javaLauncher.set(javaToolchains.launcherFor(oracleGraalVm)) + // The wrapper is open-java-format.js, and it fetches open-java-format.js.wasm from next to itself. + it.imageName.set('open-java-format') + it.mainClass.set('com.palantir.javaformat.java.WasmMain') + it.sharedLibrary.set(false) + it.classpath.from(configurations.named('runtimeClasspath')) + it.configurationFileDirectories.from( + rootProject.file('open-java-format-native/src/main/resources/META-INF/native-image')) + it.buildArgs.addAll( + '--tool:svm-wasm', + '-H:+UnlockExperimentalVMOptions', + // The page starts the module itself, through GraalVM.run, so that it learns when the module is + // ready and when loading it failed. Started by the wrapper, a failure would only reach the console. + '-H:-AutoRunVM', + "--initialize-at-build-time=${buildTimeInitialized.join(',')}".toString(), + "-Dopen-java-format.version=${project.version}".toString(), + '-H:+ReportExceptionStackTraces') + it.jvmArgs.addAll(exports.collect { value -> "--add-exports=${value}=ALL-UNNAMED".toString() }) + } +} + +def nativeCompile = tasks.named('nativeCompile', BuildNativeImageTask) +def moduleFiles = nativeCompile.flatMap { it.outputDirectory }.map { dir -> + fileTree(dir) { include 'open-java-format.js', 'open-java-format.js.wasm' } +} +def wrapper = nativeCompile.flatMap { it.outputDirectory }.map { it.file('open-java-format.js').asFile.path } + +// The tests run the module in Node, as a page would run it. node has to be on the PATH. +def wasmSmokeTest = tasks.register('wasmSmokeTest', Exec) { + description = 'Formats the native image smoke test cases with the module, in Node.' + group = 'verification' + dependsOn nativeCompile + executable 'node' + args file('src/test/js/smoke.mjs') + argumentProviders.add({ [wrapper.get(), project.version.toString()] } as CommandLineArgumentProvider) +} + +def allJar = project(':open-java-format').tasks.named('allJar', Jar) +def java21 = javaToolchains.launcherFor { it.languageVersion.set(JavaLanguageVersion.of(21)) } + +def wasmDifferentialTest = tasks.register('wasmDifferentialTest', Exec) { + description = 'Formats every test case input with the module and with the runnable jar, and compares.' + group = 'verification' + dependsOn nativeCompile, allJar + executable 'node' + args file('src/test/js/differential.mjs') + argumentProviders.add({ + [ + wrapper.get(), + java21.get().executablePath.asFile.path, + allJar.get().archiveFile.get().asFile.path, + project(':open-java-format').file('src/test/resources/test-cases').path, + layout.buildDirectory.dir('wasm-differential').get().asFile.path, + ] + } as CommandLineArgumentProvider) +} + +tasks.register('wasmTest') { + description = 'Runs the module\'s smoke and differential tests in Node.' + group = 'verification' + dependsOn wasmSmokeTest, wasmDifferentialTest +} + +// The GitHub release's open-java-format-wasm-.zip: the wrapper and the module, as native-image names them, +// since the wrapper fetches the module by its own name plus .wasm. Not the .wat that native-image writes next to them. +tasks.register('wasmZip', Zip) { + description = 'Packs the module and its wrapper for the GitHub release.' + group = 'distribution' + from(moduleFiles) +} + +// `jwebserver -d open-java-format-wasm/build/wasm-dev` serves the bare dev page next to the module. +tasks.register('wasmDev', Sync) { + description = 'Puts the dev page next to the module, for trying it in a browser.' + group = 'build' + from(moduleFiles) + from('src/dev') + into(layout.buildDirectory.dir('wasm-dev')) +} diff --git a/open-java-format-wasm/src/dev/index.html b/open-java-format-wasm/src/dev/index.html new file mode 100644 index 000000000..b1ae276c2 --- /dev/null +++ b/open-java-format-wasm/src/dev/index.html @@ -0,0 +1,43 @@ + + + + + + + open-java-format on WebAssembly + + + +

Loading…

+
+ + +
+ + + + diff --git a/open-java-format-wasm/src/main/java/com/palantir/javaformat/java/WasmMain.java b/open-java-format-wasm/src/main/java/com/palantir/javaformat/java/WasmMain.java new file mode 100644 index 000000000..a89746034 --- /dev/null +++ b/open-java-format-wasm/src/main/java/com/palantir/javaformat/java/WasmMain.java @@ -0,0 +1,85 @@ +/* + * 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 + * + * http://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. + */ +package com.palantir.javaformat.java; + +import com.palantir.javaformat.java.JavaFormatterOptions.Style; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.graalvm.webimage.api.JS; +import org.graalvm.webimage.api.JSBoolean; +import org.graalvm.webimage.api.JSObject; +import org.graalvm.webimage.api.JSString; + +/** + * The formatter for a web page. Once the module has started, {@code globalThis.openJavaFormat.format(source)} formats + * a Java file exactly as {@code open-java-format -} does with no flags, and {@code globalThis.openJavaFormat.version} + * is the release it was built from. + * + *

In the formatter's package for {@link FormatFileCallable}, which is what {@link Main} runs on each file and is + * package-private. Main itself is not called: it reads standard input and writes to streams, where the page has a + * string to hand over and a result to take back. + */ +public final class WasmMain { + /** The command line's options for {@code open-java-format -}: the defaults, on standard input. */ + private static final CommandLineOptions STDIN = CommandLineOptionsParser.parse(List.of("-")); + + private static final JavaFormatterOptions OPTIONS = + JavaFormatterOptions.builder().style(Style.OJF).build(); + + private WasmMain() {} + + // A Java Function handed to @JS arrives in JavaScript as a function that takes and returns JavaScript values. + @JS( + args = {"format", "version"}, + value = "globalThis.openJavaFormat = {version: version, format: format};") + private static native void publish(Function format, JSString version); + + public static void main(String[] args) { + publish(WasmMain::format, JSString.of(BuildVersion.VALUE)); + } + + /** + * The release, which the image build passes as {@code -Dopen-java-format.version}. In the module + * {@code Package.getImplementationVersion()}, where the command line reads its version, is null. This class is + * initialized at build time, so the value is the property's at build time. + */ + static final class BuildVersion { + static final String VALUE = Objects.requireNonNull(System.getProperty("open-java-format.version")); + + private BuildVersion() {} + } + + private static JSObject format(JSString source) { + var result = JSObject.create(); + try { + var output = new FormatFileCallable(STDIN, source.asString(), OPTIONS).call(); + result.set("ok", JSBoolean.of(true)); + result.set("output", JSString.of(output)); + } catch (FormatterException e) { + // The lines the command line prints for standard input, without its ":" in front. + var diagnostics = + e.diagnostics().stream().map(FormatterDiagnostic::toString).collect(Collectors.joining("\n")); + result.set("ok", JSBoolean.of(false)); + result.set("error", JSString.of(diagnostics)); + } catch (RuntimeException e) { + // A formatter bug rather than bad input: the page shows the exception, the console gets the stack trace. + e.printStackTrace(); + result.set("ok", JSBoolean.of(false)); + result.set("error", JSString.of(e.toString())); + } + return result; + } +} diff --git a/open-java-format-wasm/src/test/js/differential.mjs b/open-java-format-wasm/src/test/js/differential.mjs new file mode 100644 index 000000000..3f691cea5 --- /dev/null +++ b/open-java-format-wasm/src/test/js/differential.mjs @@ -0,0 +1,99 @@ +/* + * 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 + * + * http://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. + */ + +// The module against the runnable jar of the same build, on the input of every test case: both must give the same +// bytes, or the same diagnostics. The jar formats all the inputs in one run with --replace, so the JVM starts once. +// Usage: node differential.mjs + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { load } from "./load.mjs"; + +const [wrapperPath, java, jar, casesRoot, workDir] = process.argv.slice(2); + +// As CaseFile.java reads a case: the input is what lies between the first two lines of six or more dashes. +const SEPARATOR = /-{6,}(?:\r\n|\r|\n|$)/g; + +function inputOf(text) { + const separators = [...text.matchAll(SEPARATOR)]; + return text.substring(separators[0].index + separators[0][0].length, separators[1].index); +} + +function caseFiles(dir) { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith(".case")) + .map(entry => join(entry.parentPath, entry.name)) + .sort(); +} + +// One .java file per case, named after it, so that the jar's diagnostics name the case too. +rmSync(workDir, { recursive: true, force: true }); +const inputs = caseFiles(casesRoot).map(caseFile => { + const name = relative(casesRoot, caseFile).replaceAll(/[\\/]/g, "__").replace(/\.case$/, ".java"); + const path = join(workDir, name); + const input = inputOf(readFileSync(caseFile, "utf8")); + mkdirSync(workDir, { recursive: true }); + writeFileSync(path, input); + return { name, path, input }; +}); + +const jvm = spawnSync(java, ["-jar", jar, "--replace", ...inputs.map(input => input.path)], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, +}); +if (jvm.error) { + throw jvm.error; +} +// What the jar could not format, by file: its diagnostics with the file name taken off, as the module reports them. +const jvmErrors = new Map(); +for (const line of jvm.stderr.split("\n").filter(line => line.length > 0)) { + const input = inputs.find(input => line.startsWith(`${input.path}:`)); + if (input === undefined) { + throw new Error(`The jar printed a line that names no input: ${line}`); + } + const diagnostic = line.substring(input.path.length + 1); + jvmErrors.set(input.name, [...(jvmErrors.get(input.name) ?? []), diagnostic]); +} + +const openJavaFormat = await load(wrapperPath); +const differences = []; +let failedOnBoth = 0; +const started = performance.now(); +for (const input of inputs) { + const result = openJavaFormat.format(input.input); + const jvmError = jvmErrors.get(input.name); + if (jvmError !== undefined) { + if (result.ok) { + differences.push(`${input.name}: the jar failed (${jvmError.join("; ")}), the module formatted it`); + } else if (result.error !== jvmError.join("\n")) { + differences.push(`${input.name}: the jar says ${jvmError.join("; ")}, the module ${result.error}`); + } else { + failedOnBoth++; + } + } else if (!result.ok) { + differences.push(`${input.name}: the jar formatted it, the module failed: ${result.error}`); + } else if (result.output !== readFileSync(input.path, "utf8")) { + differences.push(`${input.name}: the outputs differ`); + } +} +const millis = performance.now() - started; + +console.log(`${inputs.length} inputs in ${Math.round(millis)} ms, ${failedOnBoth} of them not formattable on both`); +if (differences.length > 0) { + console.error(differences.join("\n")); + console.error(`${differences.length} of ${inputs.length} inputs differ between the module and the jar`); + process.exit(1); +} +console.log("The module and the jar agree on every input"); diff --git a/open-java-format-wasm/src/test/js/load.mjs b/open-java-format-wasm/src/test/js/load.mjs new file mode 100644 index 000000000..da5509d64 --- /dev/null +++ b/open-java-format-wasm/src/test/js/load.mjs @@ -0,0 +1,30 @@ +/* + * 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 + * + * http://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. + */ + +// Starts the module in Node the way a page does: the wrapper as a classic script, then GraalVM.run, whose promise +// settles once WasmMain has published globalThis.openJavaFormat. + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import vm from "node:vm"; + +export async function load(wrapperPath) { + // The wrapper finds Node's fs through a global require, which an ES module does not have. + globalThis.require = createRequire(import.meta.url); + vm.runInThisContext(readFileSync(wrapperPath, "utf8"), { filename: wrapperPath }); + const config = new GraalVM.Config(); + config.wasm_path = `${wrapperPath}.wasm`; + await GraalVM.run([], config); + return globalThis.openJavaFormat; +} diff --git a/open-java-format-wasm/src/test/js/smoke.mjs b/open-java-format-wasm/src/test/js/smoke.mjs new file mode 100644 index 000000000..633cfae25 --- /dev/null +++ b/open-java-format-wasm/src/test/js/smoke.mjs @@ -0,0 +1,54 @@ +/* + * 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 + * + * http://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. + */ + +// The native image's smoke test in ci.yml, on the module: a file that needs formatting, a formatted one, one that does +// not parse and one in Java 25 syntax. Usage: node smoke.mjs + +import assert from "node:assert/strict"; +import { load } from "./load.mjs"; + +const [wrapperPath, version] = process.argv.slice(2); + +const started = performance.now(); +const openJavaFormat = await load(wrapperPath); +console.log(`Started in ${Math.round(performance.now() - started)} ms`); + +assert.equal(openJavaFormat.version, version); + +// The result also carries a symbol of Web Image's own; the contract is ok and output, or ok and error. +function format(source) { + const result = openJavaFormat.format(source); + return result.ok ? { ok: true, output: result.output } : { ok: false, error: result.error }; +} + +assert.deepEqual(format("class A{int x;}\n"), { ok: true, output: "class A {\n int x;\n}\n" }); +assert.deepEqual(format("class A {\n int x;\n}\n"), { ok: true, output: "class A {\n int x;\n}\n" }); + +const broken = format("class B {\n"); +assert.equal(broken.ok, false); +assert.match(broken.error, /^\d+:\d+: error: /); + +// A compact source file with a module import and an unnamed pattern. +assert.deepEqual( + format( + "import module java.base;\nrecord Box(int a,int b){}\n" + + "void main(){Object o=new Box(1,2);if(o instanceof Box(_,_)){IO.println(List.of(1));}}\n"), + { + ok: true, + output: "import module java.base;\n\nrecord Box(int a, int b) {}\n\nvoid main() {\n" + + " Object o = new Box(1, 2);\n if (o instanceof Box(_, _)) {\n IO.println(List.of(1));\n" + + " }\n}\n", + }); + +console.log(`open-java-format ${openJavaFormat.version}: smoke test passed`); diff --git a/settings.gradle b/settings.gradle index 468e6f982..5fca69411 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,12 @@ include ':open-java-format-jdk-bootstrap' include ':open-java-format-benchmarks' include ':open-java-format-native' +// The WebAssembly module compiles against Oracle GraalVM, which nobody who only wants the jars has, so it is part of +// the build only with -Pwasm=true. The release workflow builds it that way; nothing else does. +if (Boolean.parseBoolean(providers.gradleProperty('wasm').getOrElse('false'))) { + include ':open-java-format-wasm' +} + boolean isCiServer = System.getenv().containsKey('CI') buildCache { local {