From 7a3c1221dce8b906041693e1d1438b8d412b538f Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sat, 7 Nov 2026 11:19:31 +0300 Subject: [PATCH 1/2] Build the formatter as a WebAssembly module A new subproject, open-java-format-wasm, compiles the formatter with GraalVM's Web Image backend (native-image --tool:svm-wasm) into open-java-format.js and open-java-format.js.wasm, for a playground on openjavaformat.dev that formats code in the browser (#21). WasmMain publishes globalThis.openJavaFormat.format(source), which runs FormatFileCallable with the command line's default options, so a page gets exactly what open-java-format - writes, or its diagnostics. Web Image ships in Oracle GraalVM only, and WasmMain compiles against org.graalvm.webimage.api, a module of that JDK. settings.gradle therefore includes the subproject only with -Pwasm=true, and ./gradlew build never looks for it. The image needed no change to the formatter. javac's message bundles and the diagnostic keys made from them are initialized at build time, as Web Image keeps them in the image heap, and so is Trees: its static initializer creates MethodHandles, and at run time those bring in float and double compare-and-set, which Web Image fails to compile. The wrapper is built with -H:-AutoRunVM, so that a page starts the module through GraalVM.run and learns from its promise whether it loaded. wasmTest runs the module in Node: the native image's smoke cases, and every test-case input against the runnable jar of the same build, which have to agree byte for byte. wasmZip packs the two files for the GitHub release, and wasmDev puts a bare page next to them. The starting point was cobr123/open-java-format-wasm-demo, whose author offered it for the site. --- open-java-format-wasm/build.gradle | 148 ++++++++++++++++++ open-java-format-wasm/src/dev/index.html | 43 +++++ .../palantir/javaformat/java/WasmMain.java | 85 ++++++++++ .../src/test/js/differential.mjs | 99 ++++++++++++ open-java-format-wasm/src/test/js/load.mjs | 30 ++++ open-java-format-wasm/src/test/js/smoke.mjs | 54 +++++++ settings.gradle | 6 + 7 files changed, 465 insertions(+) create mode 100644 open-java-format-wasm/build.gradle create mode 100644 open-java-format-wasm/src/dev/index.html create mode 100644 open-java-format-wasm/src/main/java/com/palantir/javaformat/java/WasmMain.java create mode 100644 open-java-format-wasm/src/test/js/differential.mjs create mode 100644 open-java-format-wasm/src/test/js/load.mjs create mode 100644 open-java-format-wasm/src/test/js/smoke.mjs 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 { From 991f2d12defe249fc86fd9e53339239b29f1cc0a Mon Sep 17 00:00:00 2001 From: Alex Abashev Date: Sat, 7 Nov 2026 11:19:37 +0300 Subject: [PATCH 2/2] Attach the WebAssembly module to the GitHub release A wasm job in release.yml builds the module on Oracle GraalVM 25.0.4, with Binaryen version_133 for wasm-opt and Node 24 for the tests, runs wasmTest and hands open-java-format-wasm-.zip to the draft GitHub release, which takes it from -PwasmModule=

as it takes the binaries from -PnativeImages=, signed and in the checksums like every other file there. Only github-release waits for the job. Maven Central does not carry the module, so a failed build holds back nothing else, and "Re-run failed jobs" builds it again and opens the draft. Nothing in ci.yml builds it: few people will use it, and a pull request is not worth its minutes. The Oracle GraalVM version is pinned to the patch, because Web Image is experimental and changes between releases. setup-node runs without its cache, which a release must not restore. --- .github/workflows/release.yml | 86 +++++++++++++++++-- README.md | 19 +++- ...pen-java-format.release-conventions.gradle | 23 +++-- 3 files changed, 115 insertions(+), 13 deletions(-) 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') {