diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e107cdf..eb6ec11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: '25' cache: maven - name: Run tests @@ -40,17 +40,18 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: '25' cache: maven - name: Package server JAR run: mvn --batch-mode -DskipTests package - - name: Set up JDK for Gradle + # Gradle 9.0 does not run on Java 25 yet; the plugin itself targets the IDE's 17 toolchain. + - name: Set up JDK 17 for Gradle uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin @@ -88,11 +89,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: '25' cache: maven - name: Package server JAR @@ -114,3 +115,56 @@ jobs: with: name: vscode-extension-${{ matrix.target }} path: vscode-extension/*.vsix + + native-binary: + name: Build native binary (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + needs: server + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: windows-latest + target: win32-x64 + - os: macos-15-intel + target: darwin-x64 + - os: macos-latest + target: darwin-arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up GraalVM for JDK 25 + uses: graalvm/setup-graalvm@27b360dbb857524eaf0b4c4cef364d38e5b4f173 # v1.6.7 + with: + distribution: graalvm-community + java-version: '25' + cache: maven + native-image-job-reports: 'true' + + - name: Build native binary + run: mvn --batch-mode -DskipTests -Pnative package + + - name: Smoke test the binary against the demo project + shell: bash + run: ./.github/workflows/native_smoke_test.sh doc/demo/example target/openfasttrace-language-server + + - name: Name the binary after version and platform + shell: bash + run: | + version=$(sed -n 's/.*\(.*\)<\/version>.*/\1/p' pom.xml | head -n 1) + extension="" + if [[ "${{ runner.os }}" == "Windows" ]]; then extension=".exe"; fi + mkdir -p target/native + mv "target/openfasttrace-language-server${extension}" \ + "target/native/openfasttrace-language-server-${version}-${{ matrix.target }}${extension}" + ls -l target/native + + - name: Upload native binary + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: native-binary-${{ matrix.target }} + path: target/native/* diff --git a/.github/workflows/github_release.sh b/.github/workflows/github_release.sh old mode 100644 new mode 100755 index 9f3211c..ee2511c --- a/.github/workflows/github_release.sh +++ b/.github/workflows/github_release.sh @@ -67,7 +67,16 @@ if [[ ${#vscode_extensions[@]} -eq 0 ]]; then exit 1 fi -echo "Calculate sha256sum for plugin archive, server JAR and VS Code extension" +# One native binary per platform, for editors without a Java runtime (ADR 0016). +mapfile -t native_binaries < <(find "$base_dir/target/native" -maxdepth 1 -type f \ + -name "openfasttrace-language-server-${project_version}-*" ! -name '*.sha256' 2>/dev/null | sort) +readonly native_binaries +if [[ ${#native_binaries[@]} -eq 0 ]]; then + echo "Could not find any native binary in $base_dir/target/native" >&2 + exit 1 +fi + +echo "Calculate sha256sum for plugin archive, server JAR, VS Code extensions and native binaries" # checksum for plugin file_dir="$(dirname "$artifact_path")" @@ -99,6 +108,18 @@ for extension in "${vscode_extensions[@]}"; do done readonly vscode_upload_files +# checksums for every native binary +native_upload_files=() +for binary in "${native_binaries[@]}"; do + native_dir="$(dirname "$binary")" + native_name="$(basename "$binary")" + cd "$native_dir" + sha256sum "$native_name" > "${native_name}.sha256" + cd "$base_dir" + native_upload_files+=("$binary" "$native_dir/${native_name}.sha256") +done +readonly native_upload_files + readonly title="Release $project_version" readonly tag="$project_version" echo "Creating release:" @@ -111,10 +132,12 @@ echo "Server JAR : $server_jar" echo "Server sha256: $checksum_server_path" echo "VS Code ext. : ${#vscode_extensions[@]} package(s)" printf ' %s\n' "${vscode_extensions[@]}" +echo "Native binary: ${#native_binaries[@]} file(s)" +printf ' %s\n' "${native_binaries[@]}" release_url=$(gh release create --latest --title "$title" --notes-file "$changes_file" --target main "$tag" \ "$artifact_path" "$checksum_plugin_path" "$server_jar" "$checksum_server_path" \ - "${vscode_upload_files[@]}") + "${vscode_upload_files[@]}" "${native_upload_files[@]}") readonly release_url echo "Release URL: $release_url" diff --git a/.github/workflows/native_smoke_test.sh b/.github/workflows/native_smoke_test.sh new file mode 100755 index 0000000..20e906c --- /dev/null +++ b/.github/workflows/native_smoke_test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Drives one LSP session against a server over stdio and checks that the server answers. +# +# Usage: native_smoke_test.sh +# +# The session opens the workspace, waits for the trace diagnostics, asks for all workspace +# symbols, then shuts the server down. It fails when any answer is missing or the server does +# not exit. Meant for the native binary, where a missing reflection registration only shows +# at run time, but works for `java -jar` as well. + +set -o errexit +set -o nounset +set -o pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +workspace="$1" +shift + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) root_uri="file:///$(cd "$workspace" && pwd -W)" ;; + *) root_uri="file://$(cd "$workspace" && pwd -P)" ;; +esac + +message() { + local body="$1" + # The bodies are ASCII, so the character count equals the byte count. + printf 'Content-Length: %d\r\n\r\n%s' "${#body}" "$body" +} + +session() { + message '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":"'"$root_uri"'","capabilities":{},"workspaceFolders":[{"uri":"'"$root_uri"'","name":"workspace"}]}}' + message '{"jsonrpc":"2.0","method":"initialized","params":{}}' + sleep 5 + message '{"jsonrpc":"2.0","id":2,"method":"workspace/symbol","params":{"query":""}}' + sleep 2 + message '{"jsonrpc":"2.0","id":3,"method":"shutdown"}' + sleep 1 + message '{"jsonrpc":"2.0","method":"exit"}' +} + +stdout_file="$(mktemp)" +stderr_file="$(mktemp)" +trap 'rm -f "$stdout_file" "$stderr_file"' EXIT + +echo "Starting: $*" +echo "Workspace: $root_uri" +session | "$@" >"$stdout_file" 2>"$stderr_file" & +server_pid=$! + +for _ in $(seq 1 60); do + if ! kill -0 "$server_pid" 2>/dev/null; then + break + fi + sleep 1 +done + +if kill -0 "$server_pid" 2>/dev/null; then + echo "Server did not exit within 60 seconds" >&2 + kill "$server_pid" 2>/dev/null || true + cat "$stderr_file" >&2 + exit 1 +fi + +exit_code=0 +wait "$server_pid" || exit_code=$? + +failed=0 +check() { + local description="$1" pattern="$2" + if grep -q -- "$pattern" "$stdout_file"; then + echo "ok $description" + else + echo "FAIL $description (no match for '$pattern')" >&2 + failed=1 + fi +} + +check "initialize answered with capabilities" '"id":1,"result":{"capabilities"' +check "trace diagnostics published for the workspace" '"method":"textDocument/publishDiagnostics"' +check "workspace symbols returned" '"id":2,"result":\[{' +check "shutdown answered" '"id":3,"result":null' + +if [[ $exit_code -ne 0 ]]; then + echo "FAIL server exited with code $exit_code" >&2 + failed=1 +fi + +if [[ $failed -ne 0 ]]; then + echo "--- server stderr:" >&2 + cat "$stderr_file" >&2 + echo "--- server stdout (first 2000 bytes):" >&2 + head -c 2000 "$stdout_file" >&2 + echo >&2 + exit 1 +fi + +echo "Smoke test passed" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18b8cdd..269db18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,11 @@ jobs: script: | core.setFailed('This workflow must run on main. Current ref: ${{ github.ref }}') - - name: Set up JDK 17 for Maven + - name: Set up JDK 25 for Maven uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: '25' cache: maven - name: Build standalone server JAR @@ -67,9 +67,67 @@ jobs: name: vscode-extension-${{ matrix.target }} path: vscode-extension/*.vsix + native-binaries: + name: Build native binary (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: windows-latest + target: win32-x64 + - os: macos-15-intel + target: darwin-x64 + - os: macos-latest + target: darwin-arm64 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Fail if not running on main branch + if: ${{ github.ref != 'refs/heads/main' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + core.setFailed('This workflow must run on main. Current ref: ${{ github.ref }}') + + - name: Set up GraalVM for JDK 25 + uses: graalvm/setup-graalvm@27b360dbb857524eaf0b4c4cef364d38e5b4f173 # v1.6.7 + with: + distribution: graalvm-community + java-version: '25' + cache: maven + + - name: Build native binary + run: mvn --batch-mode -DskipTests -Pnative package + + - name: Smoke test the binary against the demo project + shell: bash + run: ./.github/workflows/native_smoke_test.sh doc/demo/example target/openfasttrace-language-server + + - name: Name the binary after version and platform + shell: bash + run: | + version=$(sed -n 's/.*\(.*\)<\/version>.*/\1/p' pom.xml | head -n 1) + extension="" + if [[ "${{ runner.os }}" == "Windows" ]]; then extension=".exe"; fi + mkdir -p target/native + mv "target/openfasttrace-language-server${extension}" \ + "target/native/openfasttrace-language-server-${version}-${{ matrix.target }}${extension}" + + - name: Upload native binary + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: native-binary-${{ matrix.target }} + path: target/native/* + release: runs-on: ubuntu-latest - needs: vscode-packages + needs: [vscode-packages, native-binaries] defaults: run: shell: bash @@ -87,16 +145,17 @@ jobs: script: | core.setFailed('This workflow must run on main. Current ref: ${{ github.ref }}') - - name: Set up JDK 17 for Maven + - name: Set up JDK 25 for Maven uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: '25' cache: maven - name: Build standalone server JAR run: mvn --batch-mode -DskipTests package + # Gradle 9.0 does not run on Java 25 yet; the plugin itself targets the IDE's 17 toolchain. - name: Set up JDK 17 for Gradle uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: @@ -115,8 +174,15 @@ jobs: path: vscode-extension merge-multiple: true + - name: Download native binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: native-binary-* + path: target/native + merge-multiple: true + - name: Create GitHub release - run: bash ./.github/workflows/github_release.sh + run: ./.github/workflows/github_release.sh env: GH_TOKEN: ${{ github.token }} @@ -134,5 +200,3 @@ jobs: echo "Publishing $package" npx --yes ovsx publish "$package" done - - diff --git a/README.md b/README.md index f60854a..21d46d7 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,35 @@ or from a downloaded package: Each package carries its own Java runtime, so the file name ends in the platform, for example `-win32-x64` or `-darwin-arm64`. +### Vim, Neovim and other editors + +The [releases](https://github.com/fgorke/openfasttrace-language-server/releases/) page also +carries the server as a native binary that needs no Java installation: +`openfasttrace-language-server--` for the same five platforms, with `.exe` +on Windows. Download it, make it executable, and put it on the `PATH` as +`openfasttrace-language-server`. It speaks LSP over stdio, so any LSP client can launch it. + +For [vim-lsp](https://github.com/prabirshrestha/vim-lsp): + +```vim +if executable('openfasttrace-language-server') + augroup lsp_openfasttrace + autocmd! + autocmd User lsp_setup call lsp#register_server({ + \ 'name': 'openfasttrace', + \ 'cmd': {server_info->['openfasttrace-language-server']}, + \ 'allowlist': ['markdown', 'rst', 'java', 'kotlin', 'python', 'c', 'cpp', 'rust', 'go'], + \ }) + augroup END +endif +``` + +Add the file types your project traces to `allowlist`. The server indexes the whole workspace +regardless, the list only decides in which buffers Vim talks to it. + +On a platform without a released binary, the standalone JAR from the same page runs with +Java 25 or later: `java -jar openfasttrace-language-server--standalone.jar`. + ## Try it [doc/demo/](doc/demo/) holds a small coffee maker project that visits every feature @@ -97,7 +126,7 @@ first. Answers are evaluated pseudonymously and used only for the thesis. | Topic | Details | |---------------|--------------------------------------------------------------------------------------| | Status | Bachelor thesis project, work in progress | -| Built on | Java 17, [LSP4J](https://github.com/eclipse-lsp4j/lsp4j) 1.0.0 (LSP 3.18), OFT 4.9.0 | +| Built on | Java 25, [LSP4J](https://github.com/eclipse-lsp4j/lsp4j) 1.0.0 (LSP 3.18), OFT 4.9.0 | | License | [GPL-3.0-or-later](LICENSE) | | Specification | [requirements](doc/spec/requirements.md) ยท [features](doc/spec/features.md) | | Decisions | [architecture decision records](doc/decisions/) | @@ -105,11 +134,12 @@ first. Answers are evaluated pseudonymously and used only for the thesis. ## Building from source
-Server, plugin and extension +Server, native binary, plugin and extension ### Prerequisites -* Java 17 or later, Maven 3.6 or later +* JDK 25 or later, Maven 3.6 or later +* [GraalVM](https://www.graalvm.org/) for JDK 25 to build the native binary * Node.js and npm for the VS Code extension * Gradle comes as a wrapper in `intellij-plugin/` @@ -129,6 +159,20 @@ so any editor with an LSP client can launch it directly: java -jar target/openfasttrace-language-server-*-standalone.jar ``` +### Native binary + +```bash +JAVA_HOME=/path/to/graalvm mvn -Pnative -DskipTests package +.github/workflows/native_smoke_test.sh doc/demo/example target/openfasttrace-language-server +``` + +The first command needs GraalVM as the JDK and takes a few minutes. It produces +`target/openfasttrace-language-server` (`.exe` on Windows), a single file that runs +without Java. The second command drives one LSP session against it and fails if an answer +is missing, which is how a forgotten reflection registration shows up. Build options and the +metadata live in `src/main/resources/META-INF/native-image/`, so +`native-image -jar target/openfasttrace-language-server-*-standalone.jar` works as well. + ### IntelliJ plugin ```bash diff --git a/doc/decisions/0002-use-java-17-as-minimum-runtime.md b/doc/decisions/0002-use-java-17-as-minimum-runtime.md index add0cd0..789de5d 100644 --- a/doc/decisions/0002-use-java-17-as-minimum-runtime.md +++ b/doc/decisions/0002-use-java-17-as-minimum-runtime.md @@ -1,5 +1,5 @@ --- -status: accepted +status: superseded by [ADR-0015](0015-use-java-25-as-minimum-runtime.md) date: 2026-05-13 decision-makers: Felix Gorke --- diff --git a/doc/decisions/0011-bundle-java-runtimes-instead-of-a-native-image.md b/doc/decisions/0011-bundle-java-runtimes-instead-of-a-native-image.md index 7a0c385..1485da7 100644 --- a/doc/decisions/0011-bundle-java-runtimes-instead-of-a-native-image.md +++ b/doc/decisions/0011-bundle-java-runtimes-instead-of-a-native-image.md @@ -41,3 +41,7 @@ Downloading on first start keeps the package small, but adds a download and a ca ### Confirmation The extension prefers a configured `oft.java.path`, then the bundled runtime, then the `PATH`. + +## More Information + +[ADR 0016](0016-provide-a-native-binary-for-editors-without-a-java-runtime.md) revisits the GraalVM option for editors that bring no runtime of their own. This decision stays in force for the IntelliJ plugin and the VS Code extension. diff --git a/doc/decisions/0015-use-java-25-as-minimum-runtime.md b/doc/decisions/0015-use-java-25-as-minimum-runtime.md new file mode 100644 index 0000000..dc9cb74 --- /dev/null +++ b/doc/decisions/0015-use-java-25-as-minimum-runtime.md @@ -0,0 +1,41 @@ +--- +status: accepted +date: 2026-10-10 +decision-makers: Oliver Kopp +--- + +# Use Java 25 as Minimum Runtime +`adr~use-java-25-as-minimum-runtime~1` + +Needs: impl + +## Context and Problem Statement + +[ADR 0002](0002-use-java-17-as-minimum-runtime.md) chose Java 17 so that anyone with a current JDK could run the server. Since [ADR 0011](0011-bundle-java-runtimes-instead-of-a-native-image.md) no client depends on a JDK on the machine: the IntelliJ plugin runs the server on the runtime of the IDE, the VS Code extension bundles one, and the native binary of [ADR 0016](0016-provide-a-native-binary-for-editors-without-a-java-runtime.md) needs none at all. The minimum runtime therefore constrains only the build and the IDE runtime, and Java 25 is the current LTS release ([whichjdk.com](https://whichjdk.com/) gives an overview of the release cadence, support windows and distributions). + +## Decision Drivers + +* The native binary is built with GraalVM for JDK 25, so the build runs on 25 anyway. +* IntelliJ 2026.1, the oldest IDE the plugin supports, runs on JetBrains Runtime 25. +* OpenFastTrace 4.9.0 is built for Java 17 and runs unchanged on 25. + +## Considered Options + +* Stay on Java 17 +* Java 21 (LTS) +* Java 25 (LTS) + +## Decision Outcome + +Chosen option: **Java 25**, configured via `maven.compiler.release=25`. Nothing a user installs depends on the choice any more, so the server takes the current LTS and its language features instead of carrying the 17 restriction for nobody. + +### Consequences + +* Good, because the server, the native image and the bundled VS Code runtime are built from one JDK. +* Good, because the IntelliJ plugin is unaffected: every supported IDE build ships JetBrains Runtime 25. +* Bad, because the standalone JAR no longer runs on Java 17 to 24. Whoever launches the JAR directly needs Java 25 or uses the native binary. +* Neutral, because OpenFastTrace stays on 17 and keeps working on 25. + +### Confirmation + +CI builds and tests with JDK 25. JaCoCo 0.8.14 was the first release that instruments Java 25 class files, the build uses 0.8.15. diff --git a/doc/decisions/0016-provide-a-native-binary-for-editors-without-a-java-runtime.md b/doc/decisions/0016-provide-a-native-binary-for-editors-without-a-java-runtime.md new file mode 100644 index 0000000..7bac3a4 --- /dev/null +++ b/doc/decisions/0016-provide-a-native-binary-for-editors-without-a-java-runtime.md @@ -0,0 +1,48 @@ +--- +status: accepted +date: 2026-10-10 +decision-makers: Oliver Kopp +--- + +# Provide a Native Binary for Editors Without a Java Runtime +`adr~provide-a-native-binary-for-editors-without-a-java-runtime~1` + +Needs: impl + +## Context and Problem Statement + +[ADR 0011](0011-bundle-java-runtimes-instead-of-a-native-image.md) freed the two IDE clients from a Java installation. Editors such as Vim with [vim-lsp](https://github.com/prabirshrestha/vim-lsp), Neovim, Emacs or Helix have neither a JVM nor an extension package that could carry one. Their users had to install Java and launch the standalone JAR by hand, and with [ADR 0015](0015-use-java-25-as-minimum-runtime.md) that Java now has to be 25. + +ADR 0011 rejected GraalVM because LSP4J ships no Native Image metadata, so every protocol type Gson touches through reflection would be missing from the image. + +## Considered Options + +* Keep requiring Java on the `PATH` for these editors +* Publish a jlink runtime together with the JAR per platform +* Compile the server to a native binary with GraalVM Native Image + +## Decision Outcome + +Chosen option: **a native binary per platform, built with GraalVM Native Image**. + +The reflection problem is solved in the server itself: a GraalVM `Feature` ([`NativeImageSupport`](../../src/main/java/org/itsallcode/openfasttrace/lsp/nativeimage/NativeImageSupport.java)) walks the classpath while the image is built and registers every class of LSP4J, tinylog and the server for reflection, and registers the dynamic proxy LSP4J uses for the client. That stays correct when LSP4J adds types, unlike a reflection configuration recorded from one session. Resources OpenFastTrace reads and the build options live next to it in `META-INF/native-image/`, so `native-image -jar` on the standalone JAR works as well. + +A jlink bundle would have been five archives of 24 MB each that still need an unpacking step and a launcher script per platform. + +### Consequences + +* Good, because the editor starts one file that needs nothing installed and is ready in milliseconds. +* Good, because the binary runs on any x86-64 or AArch64 machine, not only on CPUs as new as the build machine (`-march=compatibility`). +* Bad, because the release carries five more artifacts and each takes minutes to build. +* Bad, because a reflection gap shows only at run time. The smoke test in CI exists for that. +* Neutral, because the IntelliJ plugin and the VS Code extension keep the approach of ADR 0011. The VS Code extension could switch to the binary later and shrink from 24 MB to the size of the binary. + +### Confirmation + +CI builds the binary on all five platforms and runs [`native_smoke_test.sh`](../../.github/workflows/native_smoke_test.sh) against it: one LSP session over stdio against the demo project that checks the `initialize` answer, the published trace diagnostics, a `workspace/symbol` answer and a clean exit. + +## More Information + +The binaries are released as `openfasttrace-language-server--`, with the same platform names as the VS Code packages. + +GraalVM Community for macOS on Intel ended with JDK 25.0.1, so `darwin-x64` is built with that release while the other platforms take the latest 25.x. The build therefore avoids anything newer releases introduced: the GraalVM metadata repository is switched off in the `native` profile, and the metadata the server needs is registered by `NativeImageSupport` or listed in `META-INF/native-image/`. diff --git a/pom.xml b/pom.xml index b04ff00..cda1651 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,8 @@ - 17 + + 25 ${java.version} UTF-8 UTF-8 @@ -41,6 +42,8 @@ 3.6.0 3.6.2 0.8.15 + 1.1.14 + 25.4.4.1.1 @@ -112,6 +115,17 @@ ${openfasttrace.version} + + + org.graalvm.sdk + nativeimage + ${graalvm.version} + provided + + org.junit.jupiter @@ -205,4 +219,50 @@ + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + ${native-maven-plugin.version} + true + + + build-native + package + + compile-no-fork + + + + + openfasttrace-language-server + org.itsallcode.openfasttrace.lsp.ServerLauncher + false + + + false + + + + + + + diff --git a/src/main/java/org/itsallcode/openfasttrace/lsp/nativeimage/NativeImageSupport.java b/src/main/java/org/itsallcode/openfasttrace/lsp/nativeimage/NativeImageSupport.java new file mode 100644 index 0000000..65219f1 --- /dev/null +++ b/src/main/java/org/itsallcode/openfasttrace/lsp/nativeimage/NativeImageSupport.java @@ -0,0 +1,112 @@ +package org.itsallcode.openfasttrace.lsp.nativeimage; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.stream.Stream; + +import org.eclipse.lsp4j.jsonrpc.Endpoint; +import org.eclipse.lsp4j.services.LanguageClient; +import org.graalvm.nativeimage.hosted.Feature; +import org.graalvm.nativeimage.hosted.RuntimeProxyCreation; +import org.graalvm.nativeimage.hosted.RuntimeReflection; + +/** + * Tells GraalVM Native Image what the server reaches through reflection at run time. + *

+ * LSP4J serializes every protocol type with Gson, which reads fields and calls constructors + * reflectively, and it ships no metadata for Native Image. tinylog instantiates its writers by + * class name. Rather than maintaining a recorded list that silently misses whatever a session did + * not exercise, this feature registers every class of those libraries and of the server itself + * while the image is built, so a newer LSP4J release with more types keeps working. + *

+ * Gson falls back to {@code Unsafe} allocation for types without a no-arg constructor, which a + * native image refuses. In LSP4J 1.0.0 that concerns five protocol types: the diagnostic report + * wrappers and {@code InlineValue} come with their own JSON adapters, and + * {@code TypeHierarchyItem} gets an {@code InstanceCreator} in {@code ServerLauncher}. + *

+ * The feature is activated through {@code META-INF/native-image/.../native-image.properties}, + * so it also applies when someone runs {@code native-image -jar} on the standalone JAR. + */ +// [impl->adr~provide-a-native-binary-for-editors-without-a-java-runtime~1] +public final class NativeImageSupport implements Feature { + private static final List REFLECTIVE_PACKAGES = List.of( + "org.eclipse.lsp4j.", + "org.tinylog.", + "org.itsallcode.openfasttrace.lsp."); + + @Override + public String getDescription() { + return "Registers LSP4J, tinylog and server classes for reflection and the LSP client proxy"; + } + + @Override + public void beforeAnalysis(final BeforeAnalysisAccess access) { + // LSP4J hands the server a java.lang.reflect.Proxy for the client, see ServiceEndpoints.toServiceObject + RuntimeProxyCreation.register(LanguageClient.class, Endpoint.class); + + for (final Path entry : access.getApplicationClassPath()) { + forEachClassName(entry, className -> { + if (isReflective(className)) { + registerForReflection(access.findClassByName(className)); + } + }); + } + } + + private static boolean isReflective(final String className) { + return REFLECTIVE_PACKAGES.stream().anyMatch(className::startsWith); + } + + private static void registerForReflection(final Class clazz) { + if (clazz == null) { + return; + } + try { + // The bulk registerAllDeclared*() variants only make the members visible to getDeclared*() + // queries. Gson and LSP4J have to invoke them, so register the members themselves. + RuntimeReflection.register(clazz); + RuntimeReflection.register(clazz.getDeclaredConstructors()); + RuntimeReflection.register(clazz.getDeclaredMethods()); + RuntimeReflection.register(clazz.getDeclaredFields()); + } catch (final LinkageError e) { + // Optional dependency missing, e.g. LSP4J's websocket classes without a websocket API on the classpath. + } + } + + private static void forEachClassName(final Path entry, final Consumer consumer) { + if (Files.isDirectory(entry)) { + forEachClassInDirectory(entry, consumer); + } else if (entry.toString().endsWith(".jar")) { + forEachClassInJar(entry, consumer); + } + } + + private static void forEachClassInDirectory(final Path directory, final Consumer consumer) { + try (Stream files = Files.walk(directory)) { + files.map(file -> directory.relativize(file).toString().replace(directory.getFileSystem().getSeparator(), "/")) + .forEach(relativePath -> acceptClassFile(relativePath, consumer)); + } catch (final IOException e) { + throw new UncheckedIOException("Failed to list classes in " + directory, e); + } + } + + private static void forEachClassInJar(final Path jar, final Consumer consumer) { + try (JarFile jarFile = new JarFile(jar.toFile())) { + jarFile.stream().forEach(jarEntry -> acceptClassFile(jarEntry.getName(), consumer)); + } catch (final IOException e) { + throw new UncheckedIOException("Failed to list classes in " + jar, e); + } + } + + private static void acceptClassFile(final String path, final Consumer consumer) { + if (!path.endsWith(".class") || path.endsWith("module-info.class") || path.endsWith("package-info.class")) { + return; + } + consumer.accept(path.substring(0, path.length() - ".class".length()).replace('/', '.')); + } +} diff --git a/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/native-image.properties b/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/native-image.properties new file mode 100644 index 0000000..22f4705 --- /dev/null +++ b/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/native-image.properties @@ -0,0 +1,9 @@ +# Picked up by GraalVM Native Image from every classpath entry that carries this file, so both +# `mvn -Pnative package` and `native-image -jar openfasttrace-language-server-*-standalone.jar` +# build a working binary without further options. +# +# -march=compatibility: the released binaries run on any x86-64 or AArch64 machine, not only on +# CPUs as new as the build machine. +Args = --features=org.itsallcode.openfasttrace.lsp.nativeimage.NativeImageSupport \ + --no-fallback \ + -march=compatibility diff --git a/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/reachability-metadata.json new file mode 100644 index 0000000..ff39598 --- /dev/null +++ b/src/main/resources/META-INF/native-image/org.itsallcode.openfasttrace/openfasttrace-language-server/reachability-metadata.json @@ -0,0 +1,11 @@ +{ + "comment": "Resources the server and OpenFastTrace read at run time. Reflection is registered by NativeImageSupport.", + "resources": [ + { "glob": "tinylog.properties" }, + { "glob": "META-INF/services/org.tinylog.*" }, + { "glob": "version.properties" }, + { "glob": "usage.txt" }, + { "glob": "css/*" }, + { "glob": "openfasttrace_logo*" } + ] +} diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 9d6b7c8..933d93a 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -24,7 +24,7 @@ The server starts automatically when a supported file is opened. No configuratio Every released package bundles a Java runtime, so nothing else needs to be installed. Packages exist for `win32-x64`, `linux-x64`, `linux-arm64`, `darwin-x64` and `darwin-arm64`. -On a platform outside that list, the extension falls back to Java 17 or later on the `PATH`. Setting `oft.java.path` always takes precedence over the bundled runtime. +On a platform outside that list, the extension falls back to Java 25 or later on the `PATH`. Setting `oft.java.path` always takes precedence over the bundled runtime. ## Settings diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index ef0514a..297df40 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -91,7 +91,7 @@ function startupErrorMessage(javaPath: string, error: unknown): string { if (usesPathJava && isMissingExecutable) { return ( "OpenFastTrace: no Java found. This build of the extension does not bundle a Java " + - "runtime, so it needs Java 17 or later on the PATH, or a path configured in " + + "runtime, so it needs Java 25 or later on the PATH, or a path configured in " + "'oft.java.path'. Installing the platform-specific build of this extension avoids that." ); }