From 0b850f1846f1a0c8ff1ef5bc6d705664439d8a7f Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 17:33:05 +0200 Subject: [PATCH 01/14] Add Gradle Language Server support with gRPC-driven build model This commit introduces comprehensive Gradle build file intelligence to the Zed Java extension: - **gradle-lsp-bridge** (`gradle-bridge/`): A new native binary that bridges LSP (stdio) to the Microsoft Gradle Language Server (Unix socket / Windows named pipe) and drives the shipped `gradle-server.jar` over gRPC. This provides plugin-aware completions, closures, and build-script diagnostics. Build outcomes (successful models or evaluation errors) surface as diagnostics merged with the language server's syntax errors. - **proxy-common**: Extracted shared primitives (LSP framing, parent-process monitor) into a new workspace crate, eliminating duplication between `java-lsp-proxy` and `gradle-lsp-bridge`. - **gradle-sync workflow**: At server initialization and on every save of a Gradle build file or `gradle-wrapper.properties`, the bridge performs a `GetBuild` RPC to a long-lived, warm `gradle-server` process, coalescing bursts into a single pending rerun. The resulting model (plugins, closures, script classpaths) is forwarded to the language server via `workspace/executeCommand`, matching the VS Code extension's contract. - **Groovy grammar**: Added `tree-sitter-groovy` to support `.gradle` / `.gradle.kts` syntax highlighting and queries. - **GradleLs component**: Downloads the Gradle Language Server from the VS Code extension VSIX. - **GradleBridge component**: Downloads the `gradle-lsp-bridge` binary as a per-platform asset alongside the JDTLS proxy. - **Language registration**: `gradle` language (`.gradle` files) is now recognized and configured. --- .github/workflows/release-proxy.yml | 40 +- Cargo.lock | 448 ++++++++++++++- Cargo.toml | 10 +- README.md | 140 +++-- extension.toml | 8 + gradle-bridge/Cargo.toml | 20 + gradle-bridge/proto/gradle.proto | 199 +++++++ gradle-bridge/src/channel.rs | 412 ++++++++++++++ gradle-bridge/src/gen/gradle.rs | 526 ++++++++++++++++++ gradle-bridge/src/grpc.rs | 495 ++++++++++++++++ gradle-bridge/src/main.rs | 231 ++++++++ gradle-bridge/src/proto.rs | 10 + gradle-bridge/src/sync.rs | 153 +++++ gradle-bridge/src/transport.rs | 164 ++++++ justfile | 13 +- languages/gradle/config.toml | 13 + languages/gradle/highlights.scm | 260 +++++++++ languages/gradle/indents.scm | 35 ++ languages/gradle/locals.scm | 6 + proxy-common/Cargo.toml | 21 + proxy-common/src/lib.rs | 16 + {proxy => proxy-common}/src/lsp.rs | 0 {proxy => proxy-common}/src/platform/mod.rs | 0 {proxy => proxy-common}/src/platform/unix.rs | 0 .../src/platform/windows.rs | 0 proxy/Cargo.toml | 9 +- proxy/src/decompile.rs | 3 +- proxy/src/http.rs | 2 +- proxy/src/log.rs | 2 +- proxy/src/main.rs | 6 +- src/config.rs | 23 + src/gradle_bridge.rs | 219 ++++++++ src/gradle_ls.rs | 123 ++++ src/gradle_ls_server.rs | 203 +++++++ src/java.rs | 17 +- 35 files changed, 3760 insertions(+), 67 deletions(-) create mode 100644 gradle-bridge/Cargo.toml create mode 100644 gradle-bridge/proto/gradle.proto create mode 100644 gradle-bridge/src/channel.rs create mode 100644 gradle-bridge/src/gen/gradle.rs create mode 100644 gradle-bridge/src/grpc.rs create mode 100644 gradle-bridge/src/main.rs create mode 100644 gradle-bridge/src/proto.rs create mode 100644 gradle-bridge/src/sync.rs create mode 100644 gradle-bridge/src/transport.rs create mode 100644 languages/gradle/config.toml create mode 100644 languages/gradle/highlights.scm create mode 100644 languages/gradle/indents.scm create mode 100644 languages/gradle/locals.scm create mode 100644 proxy-common/Cargo.toml create mode 100644 proxy-common/src/lib.rs rename {proxy => proxy-common}/src/lsp.rs (100%) rename {proxy => proxy-common}/src/platform/mod.rs (100%) rename {proxy => proxy-common}/src/platform/unix.rs (100%) rename {proxy => proxy-common}/src/platform/windows.rs (100%) create mode 100644 src/gradle_bridge.rs create mode 100644 src/gradle_ls.rs create mode 100644 src/gradle_ls_server.rs diff --git a/.github/workflows/release-proxy.yml b/.github/workflows/release-proxy.yml index 7902cca..7f15f03 100644 --- a/.github/workflows/release-proxy.yml +++ b/.github/workflows/release-proxy.yml @@ -17,28 +17,34 @@ jobs: include: - target: aarch64-apple-darwin runner: macos-15 - asset_name: java-lsp-proxy-darwin-aarch64.tar.gz helper_asset_name: java-task-helper-darwin-aarch64.tar.gz + proxy_asset: java-lsp-proxy-darwin-aarch64.tar.gz + bridge_asset: gradle-lsp-bridge-darwin-aarch64.tar.gz - target: x86_64-apple-darwin runner: macos-15-intel - asset_name: java-lsp-proxy-darwin-x86_64.tar.gz helper_asset_name: java-task-helper-darwin-x86_64.tar.gz + proxy_asset: java-lsp-proxy-darwin-x86_64.tar.gz + bridge_asset: gradle-lsp-bridge-darwin-x86_64.tar.gz - target: x86_64-unknown-linux-gnu runner: ubuntu-22.04 - asset_name: java-lsp-proxy-linux-x86_64.tar.gz helper_asset_name: java-task-helper-linux-x86_64.tar.gz + proxy_asset: java-lsp-proxy-linux-x86_64.tar.gz + bridge_asset: gradle-lsp-bridge-linux-x86_64.tar.gz - target: aarch64-unknown-linux-gnu runner: ubuntu-22.04-arm - asset_name: java-lsp-proxy-linux-aarch64.tar.gz helper_asset_name: java-task-helper-linux-aarch64.tar.gz + proxy_asset: java-lsp-proxy-linux-aarch64.tar.gz + bridge_asset: gradle-lsp-bridge-linux-aarch64.tar.gz - target: x86_64-pc-windows-msvc runner: windows-latest - asset_name: java-lsp-proxy-windows-x86_64.zip helper_asset_name: java-task-helper-windows-x86_64.zip + proxy_asset: java-lsp-proxy-windows-x86_64.zip + bridge_asset: gradle-lsp-bridge-windows-x86_64.zip - target: aarch64-pc-windows-msvc runner: windows-11-arm - asset_name: java-lsp-proxy-windows-aarch64.zip helper_asset_name: java-task-helper-windows-aarch64.zip + proxy_asset: java-lsp-proxy-windows-aarch64.zip + bridge_asset: gradle-lsp-bridge-windows-aarch64.zip steps: - name: Checkout @@ -49,9 +55,12 @@ jobs: with: targets: ${{ matrix.target }} - - name: Build proxy binary - working-directory: proxy - run: cargo build --release --target ${{ matrix.target }} + # Build both native binaries from the workspace root. Selecting the two + # packages explicitly keeps the WASM extension crate (`zed_java`, a + # cdylib) out of the native target build. The gradle-lsp-bridge gRPC + # bindings are committed, so no protoc toolchain is needed here. + - name: Build binaries + run: cargo build --release --target ${{ matrix.target }} -p java-lsp-proxy -p gradle-lsp-bridge shell: bash - name: Build task helper binary @@ -62,27 +71,32 @@ jobs: - name: Package binaries (Unix) if: runner.os != 'Windows' run: | - tar -czf ${{ matrix.asset_name }} \ + tar -czf ${{ matrix.proxy_asset }} \ -C target/${{ matrix.target }}/release \ java-lsp-proxy tar -czf ${{ matrix.helper_asset_name }} \ -C target/${{ matrix.target }}/release \ java-task-helper + tar -czf ${{ matrix.bridge_asset }} \ + -C target/${{ matrix.target }}/release \ + gradle-lsp-bridge - name: Package binaries (Windows) if: runner.os == 'Windows' shell: pwsh - run: | - Compress-Archive -Path target/${{ matrix.target }}/release/java-lsp-proxy.exe -DestinationPath ${{ matrix.asset_name }} + run: | Compress-Archive -Path target/${{ matrix.target }}/release/java-task-helper.exe -DestinationPath ${{ matrix.helper_asset_name }} + Compress-Archive -Path target/${{ matrix.target }}/release/java-lsp-proxy.exe -DestinationPath ${{ matrix.proxy_asset }} + Compress-Archive -Path target/${{ matrix.target }}/release/gradle-lsp-bridge.exe -DestinationPath ${{ matrix.bridge_asset }} - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: binaries-${{ matrix.target }} path: | - ${{ matrix.asset_name }} ${{ matrix.helper_asset_name }} + ${{ matrix.proxy_asset }} + ${{ matrix.bridge_asset }} retention-days: 1 release: diff --git a/Cargo.lock b/Cargo.lock index 061b786..32fa43b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,23 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auditable-serde" version = "0.8.0" @@ -35,6 +52,12 @@ dependencies = [ "topological-sort", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -50,6 +73,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + [[package]] name = "cc" version = "1.2.62" @@ -126,6 +155,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -170,6 +205,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -296,6 +337,38 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gradle-lsp-bridge" +version = "6.8.20" +dependencies = [ + "prost", + "proxy-common", + "serde", + "serde_json", + "tokio", + "tonic", + "tonic-prost", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -323,6 +396,106 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -457,6 +630,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -465,12 +647,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "java-lsp-proxy" -version = "6.8.12" +version = "6.8.20" dependencies = [ - "libc", + "proxy-common", "serde", "serde_json", - "windows-sys 0.59.0", ] [[package]] @@ -528,6 +709,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -540,6 +732,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -574,6 +786,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proxy-common" +version = "6.8.20" +dependencies = [ + "libc", + "serde", + "serde_json", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -707,6 +952,14 @@ name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] [[package]] name = "similar" @@ -726,6 +979,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spdx" version = "0.10.9" @@ -758,6 +1021,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -792,12 +1061,164 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "topological-sort" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-sitter" version = "0.26.9" @@ -828,6 +1249,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -870,6 +1297,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 2c62927..4663480 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,16 @@ members = [ ".", "proxy", - "task_helper" + "task_helper", + "proxy-common", + "gradle-bridge" ] +exclude = [ + "grammars/groovy" +] + +[workspace.dependencies] +proxy-common = { path = "proxy-common" } [package] name = "zed_java" diff --git a/README.md b/README.md index 781d742..2aefc4f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This extension adds support for Java and `.properties` files to [Zed](https://zed.dev). It uses the [Eclipse JDT Language Server](https://projects.eclipse.org/projects/eclipse.jdt.ls) (JDTLS for short) to provide completions, code-actions and diagnostics. +It also provides intelligence for Gradle build files (`.gradle` / `.gradle.kts`) via Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle) — plugin-aware completions, closures, and build-script diagnostics. See [Gradle Build Files](#gradle-build-files) below. + ## Quick Start Install the extension via Zeds extension manager. It should work out of the box for most people. However, there are some things to know: @@ -13,7 +15,7 @@ Install the extension via Zeds extension manager. It should work out of the box - You can provide a **custom JDTLS binary** through one of these mechanisms (in priority order): 1. The `jdtls_launcher` setting — specify an absolute path to a JDTLS launch script 2. An executable named `jdtls` (or `jdtls.bat` on Windows) on your `$PATH` - + When either is found, the extension will skip downloading and launching a managed JDTLS instance and use the provided one instead. - To support [Lombok](https://projectlombok.org/), the lombok-jar must be downloaded and registered as a Java-Agent when launching JDTLS. By default the extension automatically takes care of that, but in case you don't want that you can set the `lombok_support` configuration-option to `false`. @@ -57,6 +59,38 @@ Here is a common `settings.json` including the above mentioned configurations: } ``` +## Gradle Build Files + +For `.gradle` and `.gradle.kts` files the extension runs Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle), giving you completions for Gradle DSL closures, plugin-contributed blocks (e.g. `java {}`, `application {}`), Maven Central dependency coordinates, and diagnostics for build-script errors. + +To resolve the *plugin-aware* parts of the model (which plugins are applied, the closures/methods they contribute, and the script classpath), the language server needs the resolved build model. The extension obtains this exactly the way the VS Code Gradle extension does: it drives the bundled `gradle-server` over gRPC via a small native binary, `gradle-lsp-bridge`. The bridge keeps a single `gradle-server` process (and its Gradle daemon) warm for the lifetime of the session, so re-syncs after a build-file save are fast. Both the language server and the bridge are downloaded and managed automatically — no configuration is required. + +Configuration, when you need it, goes under the `gradle-language-server` language server in your `settings.json` (note: this is a **different** block from `jdtls`): + +```jsonc +"lsp": { + "gradle-language-server": { + "settings": { + // All optional — sensible defaults are used when omitted. + + // Gradle distribution (mirrors the Gradle Language Server's own schema). + // By default the project's Gradle wrapper is used. + "gradleWrapperEnabled": true, + "gradleVersion": null, // pin a version when the wrapper is disabled + "gradleHome": null, // a local Gradle installation directory + "gradleUserHome": null, // overrides GRADLE_USER_HOME + "gradle_jvm_arguments": null, // e.g. "-Xmx2G" for the Gradle build + + // Path to a locally built gradle-lsp-bridge binary, overriding the + // managed download. Primarily for development (see Developing Locally). + "gradle_bridge_path": "/path/to/your/gradle-lsp-bridge" + } + } +} +``` + +> **Note:** The bridge launches `gradle-server` with the same JDK the extension resolves for Java (`$JAVA_HOME` / the `java_home` setting / an auto-downloaded JDK). Modern Gradle requires JVM 17+, so ensure that JDK satisfies your Gradle distribution. + ## Project Symbol Search The extension supports project-wide symbol search with syntax-highlighted results. This feature is powered by JDTLS and can be accessed via Zed's symbol search. @@ -70,6 +104,7 @@ Debug support is enabled via our [Fork of Java Debug](https://github.com/zed-ind ### Launch Mode To get started with Java, click the `edit debug.json` button in the Debug menu, and replace the contents of the file with the following: + ```jsonc [ { @@ -141,6 +176,7 @@ For **attach** configurations: ### Single-File Debugging If you're working a lot with single file debugging, you can use the following `debug.json` config instead: + ```jsonc [ { @@ -160,9 +196,10 @@ If you're working a lot with single file debugging, you can use the following `d } ] ``` -This will compile and launch the debugger using the currently selected file as the entry point. + +This will compile and launch the debugger using the currently selected file as the entry point. Ideally, we would implement a run/debug option directly in the runnables (similar to how the Rust extension does it), which would allow you to easily start a debugging session without explicitly updating the entry point. -Note that integrating the debugger with runnables is currently limited to core languages in Zed, so this is the best workaround for now. +Note that integrating the debugger with runnables is currently limited to core languages in Zed, so this is the best workaround for now. ## Launch Scripts (aka Tasks) in Windows @@ -367,6 +404,7 @@ If a native executable is not available for your platform (or you prefer not to JDTLS provides many configuration options that can be passed via the `initialize` LSP-request. The extension will pass the JSON-object from `lsp.jdtls.initialization_options` in your settings on to JDTLS. Please refer to the [JDTLS Configuration Wiki Page](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request) for the available options and values. The extension automatically injects the following defaults into `initialization_options` (unless you override them): + - `workspaceFolders` — set to the worktree root as a `file://` URI - `extendedClientCapabilities.classFileContentsSupport` — `true` (enables decompiled source navigation) - `extendedClientCapabilities.resolveAdditionalTextEditsSupport` — `true` @@ -542,12 +580,12 @@ Below is an opinionated example configuration for JDTLS with most options enable If you're working without a Gradle or Maven project, and the following error `The declared package "Example" does not match the expected package ""` pops up, consider adding these settings under -``` +```text MyProject/ ├── .zed/ │ └── settings.json ``` - + ```jsonc "lsp": { "jdtls": { @@ -568,7 +606,10 @@ If changes are not picked up, clean JDTLS' cache (from a java file run the task ## Architecture Note -The extension uses a native binary (`java-lsp-proxy`) that wraps the JDTLS process. This proxy enables the extension to communicate with JDTLS for features like debug class resolution and classpath queries. It is automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requires no user configuration. +The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration: + +- **`java-lsp-proxy`** wraps the JDTLS process, enabling the extension to communicate with JDTLS for features like debug class resolution and classpath queries. +- **`gradle-lsp-bridge`** bridges Zed to the Gradle Language Server and drives the bundled `gradle-server` over gRPC to supply the resolved build model (see [Gradle Build Files](#gradle-build-files)). It pulls in an async/gRPC stack, so it is kept as a separate binary from the deliberately lean JDTLS proxy. ## Developing Locally @@ -583,12 +624,14 @@ If you want to contribute to this extension or test local changes, you can insta ### Installing as a Dev Extension 1. Clone the repository: + ```sh git clone https://github.com/zed-extensions/java.git cd java ``` 2. Make sure you are on the branch that contains the feature or fix you want to test: + ```sh git branch --show-current # Switch if needed: @@ -612,24 +655,37 @@ The project includes a `justfile` with common development tasks: | `just task-release` | Build the task helper binary in release mode | | `just task-install` | Build release task helper and copy it to the extension workdir | | `just task-test` | Run task helper tests | +| `just bridge-build` | Build the gradle-lsp-bridge binary in debug mode | +| `just bridge-release` | Build the gradle-lsp-bridge binary in release mode | | `just ext-build` | Build the WASM extension in release mode | | `just fmt` | Format all code (Rust + tree-sitter queries) | | `just clippy` | Run clippy on all crates | | `just lint` | Format and lint all code | -| `just all` | Lint, build extension, and install proxy & task helper | +| `just all` | Lint, build extension, and install binaries | -### Updating the `java-lsp-proxy` Binary +### Testing Local Binary Changes -The proxy is a separate native Rust binary (in the `proxy/` directory) that runs alongside the WASM extension. Because it's a native binary, it is **not** rebuilt when you use "Rebuild Dev Extension" — you need to build and install it manually. +The two native binaries (`java-lsp-proxy` in `proxy/`, `gradle-lsp-bridge` in `gradle-bridge/`) are **not** rebuilt when you use "Rebuild Dev Extension" — and by default the extension downloads release binaries from GitHub. To test a local build, point the extension directly at the binary in your `target/` directory with the corresponding path setting: -> **Important:** When testing a manually built proxy, set `"check_updates": "never"` in your `lsp.jdtls.settings` to prevent the extension from downloading a release binary and overwriting your local build. - -```sh -# Build the proxy in release mode and copy it to the extension workdir -just proxy-install +```jsonc +"lsp": { + "jdtls": { + "settings": { + // Absolute path to your locally built proxy. Replace with your + // host triple, e.g. aarch64-apple-darwin (run `rustc -vV | grep host`). + "lsp_proxy_path": "/path/to/java/target//release/java-lsp-proxy" + } + }, + "gradle-language-server": { + "settings": { + "gradle_bridge_path": "/path/to/java/target//release/gradle-lsp-bridge" + } + } +} ``` This compiles the proxy for your native target and copies it to the appropriate Zed extension working directory: + - **macOS**: `~/Library/Application Support/Zed/extensions/work/java/bin/` - **Linux**: `~/.local/share/zed/extensions/work/java/bin/` - **Windows**: `%LOCALAPPDATA%/Zed/extensions/work/java/bin/` @@ -637,34 +693,40 @@ This compiles the proxy for your native target and copies it to the appropriate After installing the proxy, restart the language server in Zed for the changes to take effect. If you prefer not to use `just`, you can build and copy manually: +When a path setting is provided, the extension uses that binary as-is and skips the managed download entirely — so there's no need to set `check_updates`. +Just rebuild and restart the language server to pick up changes: ```sh -cd proxy -cargo build --release --target $(rustc -vV | grep host | awk '{print $2}') -# Then copy the binary from target//release/java-lsp-proxy -# to the appropriate extension workdir shown above +just proxy-release # or: just bridge-release ``` +After rebuilding, restart the language server in Zed (`jdtls` or `gradle-language-server`) for the new binary to take effect. + +> **Note:** The gRPC bindings the bridge uses are committed under `gradle-bridge/src/gen/`, so building it needs no `protoc`. They are regenerated only when the bundled Gradle Language Server's `gradle.proto` contract changes — see the header of `gradle-bridge/proto/gradle.proto`. + ### Remote Development (SSH) -When using [Zed's remote development](https://zed.dev/docs/remote-development) over SSH, extensions installed locally are automatically propagated to the remote server. The language server and the proxy binary run on the **remote host**, not your local machine. +When using [Zed's remote development](https://zed.dev/docs/remote-development) over SSH, the language server and all native binaries run on the **remote host**, not your local machine. For standard use they are auto-downloaded from GitHub releases for the remote server's platform — no action is needed. For standard use, the proxy binary is auto-downloaded from GitHub releases for the remote server's platform — no action is needed. However, if you're **testing local proxy changes** against a remote host, you need to get the binary onto the remote server yourself. The key thing to be aware of is that on remote hosts, extensions are stored under a **different path** than on your local machine — typically: -``` +```text ~/.local/share/zed/remote_extensions/work/java/bin/ ``` > **Tip:** If you're unsure of the exact path, SSH into the remote and look for it: +> > ```sh > find ~/.local/share/zed -type d -name "bin" 2>/dev/null > ``` +> +To test **local binary changes** against a remote host, get the binary onto the remote (anywhere you like) and point the path setting at that remote location. The path settings (`lsp_proxy_path`, `gradle_bridge_path`) are resolved on the remote host, so this works the same as locally. #### Option A: Build on the remote directly -If you have Rust installed on the remote server, you can clone the repo there and build natively: +If you have Rust installed on the remote server, clone the repo there and build natively: ```sh # On the remote host @@ -675,23 +737,37 @@ cargo build --release # Copy to the remote extensions workdir mkdir -p ~/.local/share/zed/remote_extensions/work/java/bin cp target/release/java-lsp-proxy ~/.local/share/zed/remote_extensions/work/java/bin/ +cd java +cargo build --release -p java-lsp-proxy -p gradle-lsp-bridge -p +# Binaries are at: /target/release/{java-lsp-proxy,gradle-lsp-bridge} ``` #### Option B: Cross-compile locally and copy -If you prefer to build on your local machine: +If you prefer to build on your local machine, cross-compile for the remote target (typically Linux x86_64 or aarch64) and `scp` the binaries anywhere on the remote: -1. Cross-compile the proxy for the remote target (typically Linux x86_64 or aarch64): - ```sh - cd proxy - cargo build --release --target x86_64-unknown-linux-gnu - ``` - > You may need to install the target first: `rustup target add x86_64-unknown-linux-gnu` and configure a linker in `.cargo/config.toml`. +```sh +cargo build --release --target x86_64-unknown-linux-gnu -p java-lsp-proxy -p gradle-lsp-bridge -p java-task-helper +# You may need: rustup target add x86_64-unknown-linux-gnu (and a linker in .cargo/config.toml) 2. Copy the binary to the remote server: ```sh - scp target/x86_64-unknown-linux-gnu/release/java-lsp-proxy \ - user@remote:~/.local/share/zed/remote_extensions/work/java/bin/java-lsp-proxy - ``` -After either option, restart the language server in Zed for the changes to take effect. +scp target/x86_64-unknown-linux-gnu/release/java-lsp-proxy \ + target/x86_64-unknown-linux-gnu/release/java-task-helper \ + target/x86_64-unknown-linux-gnu/release/gradle-lsp-bridge \ + user@remote:~/java-bins/ +```text + +Then set the path settings to the remote paths and restart the language server: + +```jsonc +"lsp": { + "jdtls": { + "settings": { "lsp_proxy_path": "/home/user/java-bins/java-lsp-proxy" } + }, + "gradle-language-server": { + "settings": { "gradle_bridge_path": "/home/user/java-bins/gradle-lsp-bridge" } + } +} +``` diff --git a/extension.toml b/extension.toml index 0dff5e3..262e12d 100644 --- a/extension.toml +++ b/extension.toml @@ -16,10 +16,18 @@ commit = "94703d5a6bed02b98e438d7cad1136c01a60ba2c" repository = "https://github.com/tree-sitter-grammars/tree-sitter-properties" commit = "579b62f5ad8d96c2bb331f07d1408c92767531d9" +[grammars.groovy] +repository = "https://github.com/murtaza64/tree-sitter-groovy" +commit = "deb0dcf8c4544f07564060f6e9b9f6e4b0bfc27d" + [language_servers.jdtls] name = "Eclipse JDT Language Server" language = "Java" +[language_servers.gradle-language-server] +name = "Gradle Language Server" +language = "Gradle" + [debug_adapters.Java] [[capabilities]] diff --git a/gradle-bridge/Cargo.toml b/gradle-bridge/Cargo.toml new file mode 100644 index 0000000..0f3bb96 --- /dev/null +++ b/gradle-bridge/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "gradle-lsp-bridge" +version = "6.8.20" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "Bridges Zed to the Gradle Language Server, driving the real gradle-server.jar over gRPC for plugin-aware completions" + +[[bin]] +name = "gradle-lsp-bridge" +path = "src/main.rs" + +[dependencies] +proxy-common.workspace = true +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +prost = "0.14" +tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "transport"] } +tonic-prost = "0.14" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "net", "process", "sync", "time", "fs"] } diff --git a/gradle-bridge/proto/gradle.proto b/gradle-bridge/proto/gradle.proto new file mode 100644 index 0000000..c67a8c9 --- /dev/null +++ b/gradle-bridge/proto/gradle.proto @@ -0,0 +1,199 @@ +// Source of truth: this file is a verbatim copy of `gradle.proto` embedded +// inside the shipped `gradle-server.jar` (vscode-gradle / package +// `com.github.badsyntax.gradle`), extracted with: +// +// unzip -p gradle-server.jar gradle.proto +// +// The Rust bindings in `src/gen/gradle.rs` are generated from this file and +// COMMITTED, so neither CI nor contributors need `protoc`. When Microsoft ships +// a new gradle-server contract, re-extract this proto and regenerate: +// +// # from gradle-bridge/, with protoc on PATH: +// cargo run --quiet --bin gen-proto # (throwaway tonic-build helper), or +// protoc + tonic-build as documented in the build notes +// +// then commit both this file and the regenerated `src/gen/gradle.rs`. +// +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "com.github.badsyntax.gradle"; +option java_outer_classname = "GradleProto"; + +package gradle; + +service Gradle { + rpc GetBuild(GetBuildRequest) returns (stream GetBuildReply) {} + rpc RunBuild(RunBuildRequest) returns (stream RunBuildReply) {} + rpc CancelBuild(CancelBuildRequest) returns (CancelBuildReply) {} + rpc CancelBuilds(CancelBuildsRequest) returns (CancelBuildsReply) {} + rpc executeCommand(ExecuteCommandRequest) returns (ExecuteCommandReply) {} +} + +message GetBuildRequest { + string project_dir = 1; + string cancellation_key = 2; + GradleConfig gradle_config = 3; + bool show_output_colors = 4; +} + +message GetBuildReply { + oneof kind { + GetBuildResult get_build_result = 1; + Progress progress = 2; + Output output = 3; + Cancelled cancelled = 4; + Environment environment = 5; + string compatibility_check_error = 6; + } +} + +message GetBuildResult { + string message = 1; + GradleBuild build = 2; +} + +enum GradleDependencyType +{ + PROJECT = 0; + CONFIGURATION = 1; + DEPENDENCY = 2; +} + +message DependencyItem { + string name = 1; + GradleDependencyType type = 2; + repeated DependencyItem children = 3; +} + +message GrpcGradleClosure { + string name = 1; + repeated GrpcGradleMethod methods = 2; + repeated GrpcGradleField fields = 3; +} + +message GrpcGradleMethod { + string name = 1; + repeated string parameterTypes = 2; + bool deprecated = 3; +} + +message GrpcGradleField { + string name = 1; + bool deprecated = 2; +} + +message RunBuildRequest { + string project_dir = 1; + string cancellation_key = 2; + repeated string args = 3; + int32 java_debug_port = 4; + GradleConfig gradle_config = 5; + string input = 6; + bool show_output_colors = 7; + bool java_debug_clean_output_cache = 8; + string additional_tool_options = 9; +} + +message RunBuildResult { + string message = 1; +} + +message RunBuildReply { + oneof kind { + RunBuildResult run_build_result = 1; + Progress progress = 2; + Output output = 3; + Cancelled cancelled = 4; + } +} + +message CancelBuildRequest { + string cancellation_key = 1; +} + +message CancelBuildsRequest {} + +message CancelBuildReply { + string message = 1; + bool build_running = 2; +} + +message CancelBuildsReply { + string message = 1; +} + +message GradleConfig { + string gradle_home = 1; + string user_home = 2; + string jvm_arguments = 3; + bool wrapper_enabled = 4; + string version = 5; + string java_extension_version = 6; + string java_home = 7; +} + +message GradleBuild { GradleProject project = 1; } + +message GradleProject { + bool is_root = 1; + repeated GradleTask tasks = 2; + repeated GradleProject projects = 3; + string projectPath = 4; + DependencyItem dependencyItem = 5; + repeated string plugins = 6; + repeated GrpcGradleClosure pluginClosures = 7; + repeated string scriptClasspaths = 8; +} + +message GradleTask { + string name = 1; + string group = 2; + string path = 3; + string project = 4; + string buildFile = 5; + string rootProject = 6; + string description = 7; + bool debuggable = 8; +} + +message Cancelled { + string message = 1; + string project_dir = 2; +} + +message Progress { string message = 1; } + +message Environment { + JavaEnvironment java_environment = 1; + GradleEnvironment gradle_environment = 2; +} + +message JavaEnvironment { + string java_home = 1; + repeated string jvm_args = 2; +} + +message GradleEnvironment { + string gradle_user_home = 1; + string gradle_version = 2; +} + +message Output { + enum OutputType { + STDERR = 0; + STDOUT = 1; + } + + OutputType output_type = 1; + bytes output_bytes = 2; +} + +message ExecuteCommandRequest { + string command = 1; + repeated string arguments = 2; +} + +message ExecuteCommandReply { + string result = 1; +} diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs new file mode 100644 index 0000000..112183a --- /dev/null +++ b/gradle-bridge/src/channel.rs @@ -0,0 +1,412 @@ +//! LSP message helpers shared across the bridge: build-file/save detectors, +//! build-evaluation diagnostics parsing, and the [`EditorChannel`] that owns the +//! single byte stream to the editor and merges diagnostics from two sources. +//! +//! Most of this is byte-level and synchronous (ported verbatim from the original +//! single-binary proxy); only [`EditorChannel`] is async because it writes to +//! the editor over tokio's stdout. + +use std::collections::HashMap; + +use serde_json::Value; +use tokio::io::{AsyncWriteExt, Stdout}; +use tokio::sync::Mutex; + +use proxy_common::{encode_lsp, parse_lsp_content}; + +/// Prefix for the JSON-RPC `id` of requests the bridge injects into the language +/// server (the build-model sync commands). Responses carry the same id, letting +/// us recognize and drop them so they are never forwarded to the editor, which +/// never issued them. +pub const INJECTED_ID_PREFIX: &str = "gradle-sync-"; + +/// Quick check for the `"initialized"` method in the LSP message body. +pub fn is_initialized_notification(raw: &[u8]) -> bool { + let Some(body) = lsp_body(raw) else { + return false; + }; + body.windows(13).any(|w| w == b"\"initialized\"") +} + +/// Detect a `textDocument/didSave` notification for a Gradle build file +/// (`*.gradle` / `*.gradle.kts`). Saving a build file can change the resolved +/// build model, so we re-run the sync to refresh the language server's +/// plugins/closures/classpaths. +pub fn is_gradle_build_file_save(raw: &[u8]) -> bool { + let Some(body) = lsp_body(raw) else { + return false; + }; + // `.gradle` also matches `.gradle.kts`. + contains_subslice(body, b"\"textDocument/didSave\"") && contains_subslice(body, b".gradle") +} + +/// Detect a `textDocument/didSave` for `gradle-wrapper.properties`, which +/// declares the Gradle distribution version. +pub fn is_wrapper_properties_save(raw: &[u8]) -> bool { + let Some(body) = lsp_body(raw) else { + return false; + }; + contains_subslice(body, b"\"textDocument/didSave\"") + && contains_subslice(body, b"gradle-wrapper.properties") +} + +/// Whether a raw LSP message is a response to one of the bridge's injected +/// requests, identified by an `"id":"gradle-sync-…"` field. Such responses must +/// not reach the editor, which never sent the corresponding request. +pub fn is_injected_response(raw: &[u8]) -> bool { + let Some(body) = lsp_body(raw) else { + return false; + }; + let mut needle = Vec::with_capacity(INJECTED_ID_PREFIX.len() + 6); + needle.extend_from_slice(b"\"id\":\""); + needle.extend_from_slice(INJECTED_ID_PREFIX.as_bytes()); + contains_subslice(body, &needle) +} + +/// The JSON body of a raw LSP message (everything after the `\r\n\r\n` header +/// separator), or `None` if the framing is absent. +fn lsp_body(raw: &[u8]) -> Option<&[u8]> { + let sep_pos = raw.windows(4).position(|w| w == b"\r\n\r\n")?; + Some(&raw[sep_pos + 4..]) +} + +pub fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || haystack.len() < needle.len() { + return false; + } + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// If `raw` is a `textDocument/publishDiagnostics` notification, return its +/// `(uri, diagnostics)`. Returns `None` for any other message. +pub fn parse_publish_diagnostics(raw: &[u8]) -> Option<(String, Vec)> { + let msg = parse_lsp_content(raw)?; + if msg.get("method")?.as_str()? != "textDocument/publishDiagnostics" { + return None; + } + let params = msg.get("params")?; + let uri = params.get("uri")?.as_str()?.to_string(); + let diagnostics = params.get("diagnostics")?.as_array()?.clone(); + Some((uri, diagnostics)) +} + +/// Build the `uri -> [diagnostic]` map for a build-evaluation failure. +/// +/// `error` is the top-level message (typically the gRPC `Status` message or a +/// `compatibility_check_error`); `causes` are appended line by line. The target +/// file and line/column are parsed from the Gradle message when present +/// (`build file '…': N:` and `@ line N, column C`), otherwise the diagnostic is +/// attached at the top of `build_file` when provided. +pub fn build_eval_diagnostics( + error: &str, + causes: &[String], + build_file: Option<&str>, +) -> HashMap> { + let mut message = error.to_string(); + for cause in causes { + message.push('\n'); + message.push_str(cause); + } + + let parsed_path = parse_build_file_path(&message); + let path = parsed_path.as_deref().or(build_file); + let Some(path) = path else { + return HashMap::new(); + }; + + let (line, character) = parse_line_column(&message).unwrap_or((0, 0)); + let diagnostic = serde_json::json!({ + "range": { + "start": { "line": line, "character": character }, + "end": { "line": line, "character": character.saturating_add(1) } + }, + "severity": 1, + "source": "Gradle", + "message": message + }); + + let uri = path_to_file_uri(path); + let mut map = HashMap::new(); + map.insert(uri, vec![diagnostic]); + map +} + +/// Parse the build-file path from a Gradle error message. Gradle prints either +/// `build file '/abs/path/build.gradle'` or `Build file '/abs/path/build.gradle'`. +pub fn parse_build_file_path(message: &str) -> Option { + for marker in ["build file '", "Build file '"] { + if let Some(start) = message.find(marker) { + let rest = &message[start + marker.len()..]; + if let Some(end) = rest.find('\'') { + return Some(rest[..end].to_string()); + } + } + } + None +} + +/// Parse a zero-based `(line, column)` from a Gradle error message. Gradle +/// reports 1-based positions as `@ line N, column C` or `line: N`; we convert to +/// the 0-based positions LSP expects. Returns `None` if no line is found. +pub fn parse_line_column(message: &str) -> Option<(u64, u64)> { + if let Some(idx) = message.find("@ line ") { + let rest = &message[idx + "@ line ".len()..]; + let line = take_u64(rest)?; + let column = rest + .find("column ") + .and_then(|c| take_u64(&rest[c + "column ".len()..])) + .unwrap_or(1); + return Some((line.saturating_sub(1), column.saturating_sub(1))); + } + if let Some(idx) = message.find("line: ") { + let line = take_u64(&message[idx + "line: ".len()..])?; + return Some((line.saturating_sub(1), 0)); + } + None +} + +/// Read the leading run of ASCII digits as a `u64`. +fn take_u64(s: &str) -> Option { + let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +/// Convert a filesystem path to a `file://` URI, matching how the language +/// server's `publishDiagnostics` and the editor key documents. +pub fn path_to_file_uri(path: &str) -> String { + if cfg!(windows) { + format!("file:///{}", path.replace('\\', "/")) + } else { + format!("file://{path}") + } +} + +/// Owns the single byte stream to the editor (the bridge's stdout) and the +/// diagnostics merge state. +/// +/// `textDocument/publishDiagnostics` *replaces* a URI's diagnostics for the +/// publishing server, and to the editor the bridge is one server. So both the +/// language server (Groovy syntax errors) and the bridge's own build-model sync +/// (Gradle evaluation errors) must publish through here; the channel keeps each +/// source's diagnostics per URI and always emits their union, so neither erases +/// the other. +pub struct EditorChannel { + inner: Mutex, +} + +struct EditorChannelInner { + stdout: Stdout, + /// Diagnostics last published by the language server, per URI. + server: HashMap>, + /// Diagnostics derived from the build-model sync, per URI. + sync: HashMap>, +} + +impl EditorChannel { + pub fn new() -> Self { + Self { + inner: Mutex::new(EditorChannelInner { + stdout: tokio::io::stdout(), + server: HashMap::new(), + sync: HashMap::new(), + }), + } + } + + /// Forward a raw LSP message to the editor verbatim. Returns false if the + /// write failed (editor side closed). + pub async fn forward_raw(&self, raw: &[u8]) -> bool { + let mut inner = self.inner.lock().await; + inner.stdout.write_all(raw).await.is_ok() && inner.stdout.flush().await.is_ok() + } + + /// Record the language server's diagnostics for `uri` and re-emit the merged + /// set. Called instead of forwarding the server's raw publishDiagnostics. + pub async fn set_server_diagnostics(&self, uri: String, diagnostics: Vec) { + let mut inner = self.inner.lock().await; + inner.server.insert(uri.clone(), diagnostics); + inner.publish_merged(&uri).await; + } + + /// Replace all build-model-sync diagnostics with `next` (URI -> diagnostics). + /// Any URI that previously had sync diagnostics but is absent from `next` is + /// cleared. Re-emits the merged set for every affected URI. + pub async fn set_sync_diagnostics(&self, next: HashMap>) { + let mut inner = self.inner.lock().await; + let mut affected: Vec = next.keys().cloned().collect(); + for uri in inner.sync.keys() { + if !next.contains_key(uri) { + affected.push(uri.clone()); + } + } + inner.sync = next; + for uri in affected { + inner.publish_merged(&uri).await; + } + } +} + +impl EditorChannelInner { + /// Emit `textDocument/publishDiagnostics` for `uri` carrying the union of the + /// server's and the sync's diagnostics. + async fn publish_merged(&mut self, uri: &str) { + let mut merged: Vec = Vec::new(); + if let Some(d) = self.server.get(uri) { + merged.extend(d.iter().cloned()); + } + if let Some(d) = self.sync.get(uri) { + merged.extend(d.iter().cloned()); + } + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": uri, "diagnostics": merged } + }); + let encoded = encode_lsp(&msg); + let _ = self.stdout.write_all(encoded.as_bytes()).await; + let _ = self.stdout.flush().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Wrap a JSON body in LSP framing the way the language server transmits it. + fn frame(body: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{body}", body.len()).into_bytes() + } + + #[test] + fn detects_injected_response_with_string_id() { + let raw = frame(r#"{"jsonrpc":"2.0","id":"gradle-sync-0","result":null}"#); + assert!(is_injected_response(&raw)); + } + + #[test] + fn does_not_drop_genuine_editor_responses() { + let raw = frame(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#); + assert!(!is_injected_response(&raw)); + + let raw = frame(r#"{"jsonrpc":"2.0","id":"client-42","result":"gradle-sync-x"}"#); + assert!(!is_injected_response(&raw)); + } + + #[test] + fn detects_build_file_saves() { + let save = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///p/build.gradle"}}}"#, + ); + assert!(is_gradle_build_file_save(&save)); + + let kts = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///p/build.gradle.kts"}}}"#, + ); + assert!(is_gradle_build_file_save(&kts)); + } + + #[test] + fn ignores_non_gradle_and_non_save() { + let java_save = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///p/Main.java"}}}"#, + ); + assert!(!is_gradle_build_file_save(&java_save)); + + let open = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///p/build.gradle"}}}"#, + ); + assert!(!is_gradle_build_file_save(&open)); + } + + #[test] + fn detects_wrapper_properties_save() { + let save = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///p/gradle/wrapper/gradle-wrapper.properties"}}}"#, + ); + assert!(is_wrapper_properties_save(&save)); + assert!(!is_gradle_build_file_save(&save)); + } + + #[test] + fn detects_initialized_notification() { + let init = frame(r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#); + assert!(is_initialized_notification(&init)); + } + + #[test] + fn parses_publish_diagnostics() { + let raw = frame( + r#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///p/build.gradle","diagnostics":[{"message":"x"}]}}"#, + ); + let (uri, diags) = parse_publish_diagnostics(&raw).expect("should parse"); + assert_eq!(uri, "file:///p/build.gradle"); + assert_eq!(diags.len(), 1); + + let other = frame(r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{}}"#); + assert!(parse_publish_diagnostics(&other).is_none()); + } + + #[test] + fn parses_build_file_path_and_line_column() { + let msg = "Could not compile build file '/Users/me/proj/build.gradle'.\nstartup failed:\nbuild file '/Users/me/proj/build.gradle': 9: Unexpected input: '{' @ line 9, column 6."; + assert_eq!( + parse_build_file_path(msg).as_deref(), + Some("/Users/me/proj/build.gradle") + ); + assert_eq!(parse_line_column(msg), Some((8, 5))); + } + + #[test] + fn parses_line_only_form() { + let msg = "build file '/p/build.gradle' line: 12"; + assert_eq!(parse_line_column(msg), Some((11, 0))); + } + + #[test] + fn no_location_for_methodless_errors() { + let msg = + "Could not find method implementatoin() for arguments [com.google.gwt:gwt:2.10.0]"; + assert_eq!(parse_build_file_path(msg), None); + assert_eq!(parse_line_column(msg), None); + } + + #[test] + fn build_eval_diagnostics_uses_parsed_location() { + let causes = vec![ + "startup failed:\nbuild file '/p/build.gradle': 9: Unexpected input: '{' @ line 9, column 6.".to_string(), + ]; + let map = build_eval_diagnostics( + "Could not compile build file '/p/build.gradle'.", + &causes, + Some("/p/build.gradle"), + ); + let diags = map + .get("file:///p/build.gradle") + .expect("diag for build file"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0]["range"]["start"]["line"], 8); + assert_eq!(diags[0]["severity"], 1); + assert_eq!(diags[0]["source"], "Gradle"); + } + + #[test] + fn build_eval_diagnostics_falls_back_to_build_file_top() { + let causes = vec![ + "Could not find method implementatoin() for arguments [com.google.gwt:gwt:2.10.0]" + .to_string(), + ]; + let map = build_eval_diagnostics( + "A problem occurred evaluating root project 'proj'.", + &causes, + Some("/p/build.gradle"), + ); + let diags = map + .get("file:///p/build.gradle") + .expect("diag for build file"); + assert_eq!(diags[0]["range"]["start"]["line"], 0); + assert!(diags[0]["message"] + .as_str() + .unwrap() + .contains("implementatoin()")); + } +} diff --git a/gradle-bridge/src/gen/gradle.rs b/gradle-bridge/src/gen/gradle.rs new file mode 100644 index 0000000..e58631f --- /dev/null +++ b/gradle-bridge/src/gen/gradle.rs @@ -0,0 +1,526 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetBuildRequest { + #[prost(string, tag = "1")] + pub project_dir: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub cancellation_key: ::prost::alloc::string::String, + #[prost(message, optional, tag = "3")] + pub gradle_config: ::core::option::Option, + #[prost(bool, tag = "4")] + pub show_output_colors: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBuildReply { + #[prost(oneof = "get_build_reply::Kind", tags = "1, 2, 3, 4, 5, 6")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `GetBuildReply`. +pub mod get_build_reply { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(message, tag = "1")] + GetBuildResult(super::GetBuildResult), + #[prost(message, tag = "2")] + Progress(super::Progress), + #[prost(message, tag = "3")] + Output(super::Output), + #[prost(message, tag = "4")] + Cancelled(super::Cancelled), + #[prost(message, tag = "5")] + Environment(super::Environment), + #[prost(string, tag = "6")] + CompatibilityCheckError(::prost::alloc::string::String), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBuildResult { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub build: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DependencyItem { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(enumeration = "GradleDependencyType", tag = "2")] + pub r#type: i32, + #[prost(message, repeated, tag = "3")] + pub children: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GrpcGradleClosure { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "2")] + pub methods: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "3")] + pub fields: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GrpcGradleMethod { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "2")] + pub parameter_types: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(bool, tag = "3")] + pub deprecated: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GrpcGradleField { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(bool, tag = "2")] + pub deprecated: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RunBuildRequest { + #[prost(string, tag = "1")] + pub project_dir: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub cancellation_key: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "3")] + pub args: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(int32, tag = "4")] + pub java_debug_port: i32, + #[prost(message, optional, tag = "5")] + pub gradle_config: ::core::option::Option, + #[prost(string, tag = "6")] + pub input: ::prost::alloc::string::String, + #[prost(bool, tag = "7")] + pub show_output_colors: bool, + #[prost(bool, tag = "8")] + pub java_debug_clean_output_cache: bool, + #[prost(string, tag = "9")] + pub additional_tool_options: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RunBuildResult { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RunBuildReply { + #[prost(oneof = "run_build_reply::Kind", tags = "1, 2, 3, 4")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `RunBuildReply`. +pub mod run_build_reply { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Kind { + #[prost(message, tag = "1")] + RunBuildResult(super::RunBuildResult), + #[prost(message, tag = "2")] + Progress(super::Progress), + #[prost(message, tag = "3")] + Output(super::Output), + #[prost(message, tag = "4")] + Cancelled(super::Cancelled), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CancelBuildRequest { + #[prost(string, tag = "1")] + pub cancellation_key: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CancelBuildsRequest {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CancelBuildReply { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, + #[prost(bool, tag = "2")] + pub build_running: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CancelBuildsReply { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GradleConfig { + #[prost(string, tag = "1")] + pub gradle_home: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub user_home: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub jvm_arguments: ::prost::alloc::string::String, + #[prost(bool, tag = "4")] + pub wrapper_enabled: bool, + #[prost(string, tag = "5")] + pub version: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub java_extension_version: ::prost::alloc::string::String, + #[prost(string, tag = "7")] + pub java_home: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GradleBuild { + #[prost(message, optional, tag = "1")] + pub project: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GradleProject { + #[prost(bool, tag = "1")] + pub is_root: bool, + #[prost(message, repeated, tag = "2")] + pub tasks: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag = "3")] + pub projects: ::prost::alloc::vec::Vec, + #[prost(string, tag = "4")] + pub project_path: ::prost::alloc::string::String, + #[prost(message, optional, tag = "5")] + pub dependency_item: ::core::option::Option, + #[prost(string, repeated, tag = "6")] + pub plugins: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(message, repeated, tag = "7")] + pub plugin_closures: ::prost::alloc::vec::Vec, + #[prost(string, repeated, tag = "8")] + pub script_classpaths: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GradleTask { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub group: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub path: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub project: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub build_file: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub root_project: ::prost::alloc::string::String, + #[prost(string, tag = "7")] + pub description: ::prost::alloc::string::String, + #[prost(bool, tag = "8")] + pub debuggable: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Cancelled { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub project_dir: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Progress { + #[prost(string, tag = "1")] + pub message: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Environment { + #[prost(message, optional, tag = "1")] + pub java_environment: ::core::option::Option, + #[prost(message, optional, tag = "2")] + pub gradle_environment: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct JavaEnvironment { + #[prost(string, tag = "1")] + pub java_home: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "2")] + pub jvm_args: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GradleEnvironment { + #[prost(string, tag = "1")] + pub gradle_user_home: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub gradle_version: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Output { + #[prost(enumeration = "output::OutputType", tag = "1")] + pub output_type: i32, + #[prost(bytes = "vec", tag = "2")] + pub output_bytes: ::prost::alloc::vec::Vec, +} +/// Nested message and enum types in `Output`. +pub mod output { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum OutputType { + Stderr = 0, + Stdout = 1, + } + impl OutputType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Stderr => "STDERR", + Self::Stdout => "STDOUT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "STDERR" => Some(Self::Stderr), + "STDOUT" => Some(Self::Stdout), + _ => None, + } + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExecuteCommandRequest { + #[prost(string, tag = "1")] + pub command: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "2")] + pub arguments: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExecuteCommandReply { + #[prost(string, tag = "1")] + pub result: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum GradleDependencyType { + Project = 0, + Configuration = 1, + Dependency = 2, +} +impl GradleDependencyType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Project => "PROJECT", + Self::Configuration => "CONFIGURATION", + Self::Dependency => "DEPENDENCY", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PROJECT" => Some(Self::Project), + "CONFIGURATION" => Some(Self::Configuration), + "DEPENDENCY" => Some(Self::Dependency), + _ => None, + } + } +} +/// Generated client implementations. +pub mod gradle_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct GradleClient { + inner: tonic::client::Grpc, + } + impl GradleClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl GradleClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> GradleClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + GradleClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn get_build( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/gradle.Gradle/GetBuild"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "GetBuild")); + self.inner.server_streaming(req, path, codec).await + } + pub async fn run_build( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/gradle.Gradle/RunBuild"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "RunBuild")); + self.inner.server_streaming(req, path, codec).await + } + pub async fn cancel_build( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/gradle.Gradle/CancelBuild", + ); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "CancelBuild")); + self.inner.unary(req, path, codec).await + } + pub async fn cancel_builds( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/gradle.Gradle/CancelBuilds", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("gradle.Gradle", "CancelBuilds")); + self.inner.unary(req, path, codec).await + } + pub async fn execute_command( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/gradle.Gradle/executeCommand", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("gradle.Gradle", "executeCommand")); + self.inner.unary(req, path, codec).await + } + } +} diff --git a/gradle-bridge/src/grpc.rs b/gradle-bridge/src/grpc.rs new file mode 100644 index 0000000..3d41cec --- /dev/null +++ b/gradle-bridge/src/grpc.rs @@ -0,0 +1,495 @@ +//! Drives the real shipped `gradle-server.jar` over gRPC, exactly as the VS Code +//! `vscode-gradle` extension does. +//! +//! A single long-lived `gradle-server` JVM is spawned once and kept alive for +//! the bridge's lifetime: it holds the Gradle Tooling-API connection open and +//! keeps the Gradle daemon warm, so re-syncs after the first are near-instant. +//! This is the whole point of driving the real server rather than forking a cold +//! JVM per save. +//! +//! The bridge calls the server-streaming `GetBuild` RPC, then maps the resulting +//! model into the `gradle.setPlugins` / `gradle.setClosures` / +//! `gradle.setScriptClasspaths` `executeCommand` arguments the Gradle Language +//! Server understands — the same forwarding the VS Code TypeScript client does. + +use std::env; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; +use tokio::time::sleep; +use tonic::transport::{Channel, Endpoint}; + +use crate::proto::gradle::{ + gradle_client::GradleClient, get_build_reply::Kind, output::OutputType, GetBuildRequest, + GradleConfig, GradleProject, +}; + +/// The Gradle distribution configuration forwarded by the extension via +/// environment variables and threaded into the gRPC `GradleConfig`. Mirrors the +/// knobs the VS Code `gradle-server` honors. +#[derive(Clone, Default)] +pub struct DistributionConfig { + pub gradle_user_home: String, + pub gradle_home: String, + pub version: String, + pub jvm_arguments: String, + pub java_home: String, + pub wrapper_enabled: bool, +} + +impl DistributionConfig { + /// Read the configuration the extension exported as `GRADLE_SYNC_*` vars. + /// Absence of `GRADLE_SYNC_WRAPPER_ENABLED=false` means wrapper-enabled (the + /// default), matching the helper's previous behavior and the LS settings. + pub fn from_env() -> Self { + let wrapper_enabled = env::var("GRADLE_SYNC_WRAPPER_ENABLED") + .map(|v| !v.eq_ignore_ascii_case("false")) + .unwrap_or(true); + Self { + gradle_user_home: env_or_empty("GRADLE_SYNC_USER_HOME"), + gradle_home: env_or_empty("GRADLE_SYNC_GRADLE_HOME"), + version: env_or_empty("GRADLE_SYNC_VERSION"), + jvm_arguments: env_or_empty("GRADLE_SYNC_JVM_ARGS"), + java_home: env_or_empty("GRADLE_SYNC_JAVA_HOME"), + wrapper_enabled, + } + } + + fn to_gradle_config(&self) -> GradleConfig { + GradleConfig { + gradle_home: self.gradle_home.clone(), + user_home: self.gradle_user_home.clone(), + jvm_arguments: self.jvm_arguments.clone(), + wrapper_enabled: self.wrapper_enabled, + version: self.version.clone(), + // The Gradle Language Server checks the extension version against a + // minimum; the value is otherwise opaque, so report a recent one. + java_extension_version: String::new(), + java_home: self.java_home.clone(), + } + } +} + +fn env_or_empty(key: &str) -> String { + env::var(key).unwrap_or_default() +} + +/// The outcome of a `GetBuild` call: either the resolved root project model, or +/// a build-evaluation failure to surface as a diagnostic on the build file. +pub enum BuildOutcome { + Model(GradleProject), + /// `(error, causes)` — already flattened from the gRPC status / reply. + Error { error: String, causes: Vec }, +} + +/// Manages the long-lived `gradle-server` process and gRPC channel. Cloneable +/// (cheap `Arc` clone) so it can be shared with the sync worker. +#[derive(Clone)] +pub struct GradleServer { + inner: Arc>, + java: String, + classpath: String, + java_home: Option, + config: DistributionConfig, +} + +struct ServerState { + /// The running server process + connected channel, if started. + running: Option, +} + +struct RunningServer { + child: Child, + channel: Channel, +} + +impl GradleServer { + pub fn new( + java: String, + classpath: String, + java_home: Option, + config: DistributionConfig, + ) -> Self { + Self { + inner: Arc::new(Mutex::new(ServerState { running: None })), + java, + classpath, + java_home, + config, + } + } + + /// Run `GetBuild` for `project_dir`, starting the server on first use and + /// reusing it thereafter. `cancellation_key` is echoed in the request so a + /// superseding sync can cancel this build via [`Self::cancel`]. + pub async fn get_build(&self, project_dir: &str, cancellation_key: &str) -> BuildOutcome { + let channel = match self.ensure_channel().await { + Ok(c) => c, + Err(e) => { + return BuildOutcome::Error { + error: format!("Failed to start gradle-server: {e}"), + causes: Vec::new(), + }; + } + }; + + let mut client = GradleClient::new(channel) + // Multi-project models can exceed the 4 MB default; the VS Code + // client sets this to unlimited. + .max_decoding_message_size(usize::MAX); + + let request = GetBuildRequest { + project_dir: project_dir.to_string(), + cancellation_key: cancellation_key.to_string(), + gradle_config: Some(self.config.to_gradle_config()), + show_output_colors: false, + }; + + let mut stream = match client.get_build(request).await { + Ok(resp) => resp.into_inner(), + Err(status) => { + return BuildOutcome::Error { + error: status.message().to_string(), + causes: Vec::new(), + }; + } + }; + + // The gRPC error status carries only the outermost exception message + // (`ErrorMessageBuilder` sets `Status.INTERNAL.withDescription(e.getMessage())`) + // — e.g. "The supplied build action failed with an exception." The + // actionable detail (the offending build file, line/column, and the root + // cause) is what Gradle writes to standard error, which the server + // streams back as `Output` messages with `output_type = STDERR`. We + // accumulate that here and attach it to the failure so it reaches the + // editor diagnostic, instead of discarding it. + let mut model: Option = None; + let mut stderr = String::new(); + loop { + match stream.message().await { + Ok(Some(reply)) => match reply.kind { + Some(Kind::GetBuildResult(result)) => { + model = result.build.and_then(|b| b.project); + } + Some(Kind::CompatibilityCheckError(msg)) => { + return BuildOutcome::Error { + error: msg, + causes: stderr_causes(&stderr), + }; + } + Some(Kind::Output(output)) => { + if output.output_type == OutputType::Stderr as i32 { + stderr.push_str(&String::from_utf8_lossy(&output.output_bytes)); + } + } + // Progress/Environment/Cancelled are informational. + _ => {} + }, + Ok(None) => break, + Err(status) => { + return BuildOutcome::Error { + error: status.message().to_string(), + causes: stderr_causes(&stderr), + }; + } + } + } + + match model { + Some(project) => BuildOutcome::Model(project), + None => BuildOutcome::Error { + error: "gradle-server returned no build model".to_string(), + causes: stderr_causes(&stderr), + }, + } + } + + /// Cancel an in-flight build identified by `cancellation_key`. Best-effort: + /// errors (including the server not running) are ignored. + pub async fn cancel(&self, cancellation_key: &str) { + let channel = { + let state = self.inner.lock().await; + state.running.as_ref().map(|r| r.channel.clone()) + }; + let Some(channel) = channel else { + return; + }; + let mut client = GradleClient::new(channel); + let _ = client + .cancel_build(crate::proto::gradle::CancelBuildRequest { + cancellation_key: cancellation_key.to_string(), + }) + .await; + } + + /// Kill the server process if running. Called on bridge shutdown. + pub async fn shutdown(&self) { + let mut state = self.inner.lock().await; + if let Some(mut running) = state.running.take() { + let _ = running.child.start_kill(); + } + } + + /// Ensure a connected channel exists, (re)starting the server if needed. + async fn ensure_channel(&self) -> Result { + let mut state = self.inner.lock().await; + + // Reuse a healthy running server. + if let Some(running) = state.running.as_mut() { + // If the JVM died, drop it and restart below. + match running.child.try_wait() { + Ok(None) => return Ok(running.channel.clone()), + _ => { + state.running = None; + } + } + } + + let port = free_port().await?; + let child = self.spawn_server(port)?; + let channel = connect_with_retry(port).await?; + state.running = Some(RunningServer { child, channel: channel.clone() }); + Ok(channel) + } + + /// Spawn `java -cp com.github.badsyntax.gradle.GradleServer `. + fn spawn_server(&self, port: u16) -> Result { + let mut cmd = Command::new(&self.java); + // GradleServer.main parses only `--key=value` args (Utils.parseArgs); a + // bare positional port is ignored. `port` is required; `startBuildServer` + // is also validated as required — we set it false because we only need + // the gRPC build-model server, not the BSP build server (which would in + // turn require `pipeName`/`bundleDir`). The LS pipe path is omitted: the + // bridge launches and talks to the language server itself. + cmd.args([ + "-Dfile.encoding=UTF-8", + "-cp", + &self.classpath, + "com.github.badsyntax.gradle.GradleServer", + &format!("--port={port}"), + "--startBuildServer=false", + ]) + .stdin(Stdio::null()) + // The server logs readiness to stderr; inherit so it lands in the + // bridge's own stderr (Zed's language server log) for debugging. + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + + if let Some(home) = &self.java_home { + // The Gradle start script honors VSCODE_JAVA_HOME; set JAVA_HOME too + // so the directly-launched server uses the same JDK. + cmd.env("JAVA_HOME", home); + cmd.env("VSCODE_JAVA_HOME", home); + } + + cmd.spawn() + .map_err(|e| format!("failed to spawn gradle-server: {e}")) + } +} + +/// Turn the captured Gradle standard-error text into a list of cause lines. +/// +/// The diagnostics builder joins these onto the top-level error message and +/// scans the combined text for Gradle's `build file '…': N:` and +/// `@ line N, column C` markers, so preserving the raw lines keeps both the +/// human-readable detail and the location parsing intact. Returns empty when no +/// stderr was captured (a successful build, or a failure that wrote nothing). +fn stderr_causes(stderr: &str) -> Vec { + let trimmed = stderr.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + trimmed + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +/// Pick a free TCP port on the loopback interface. We bind, read the assigned +/// port, then drop the listener so the JVM can bind it. (A brief race window +/// exists, but the loopback ephemeral range makes a collision very unlikely; the +/// connect-retry below also absorbs a transient failure.) +async fn free_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("failed to reserve a port: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("failed to read reserved port: {e}"))? + .port(); + Ok(port) +} + +/// Connect a plaintext h2c channel to the server, retrying while the JVM starts +/// up and binds its port. Mirrors the VS Code client's `waitForReady` deadline. +async fn connect_with_retry(port: u16) -> Result { + let uri = format!("http://127.0.0.1:{port}"); + let endpoint = Endpoint::from_shared(uri) + .map_err(|e| format!("invalid gradle-server endpoint: {e}"))? + .connect_timeout(Duration::from_secs(2)); + + // Up to ~30s total, matching the VS Code client's readiness deadline. + let mut last_err = String::new(); + for _ in 0..150 { + match endpoint.connect().await { + Ok(channel) => return Ok(channel), + Err(e) => { + last_err = e.to_string(); + sleep(Duration::from_millis(200)).await; + } + } + } + Err(format!("gradle-server did not become ready: {last_err}")) +} + +/// Build the ordered list of `workspace/executeCommand` argument tuples to send +/// to the language server for `root` and every subproject, recursively. +/// +/// Each entry is `(command, arguments)` where `arguments` is the JSON array the +/// LS expects. The `projectPath` argument is normalized to match the key the LS +/// derives from a document URI (`Paths.get(uri).getParent().toString()`), i.e. +/// the absolute project directory — which is exactly what the model reports. +pub fn model_to_commands(root: &GradleProject) -> Vec<(&'static str, Value)> { + let mut commands = Vec::new(); + collect_commands(root, &mut commands); + commands +} + +fn collect_commands(project: &GradleProject, out: &mut Vec<(&'static str, Value)>) { + let project_path = normalize_project_path(&project.project_path); + + // gradle.setPlugins [projectPath, plugins[]] + out.push(( + "gradle.setPlugins", + json!([project_path, project.plugins]), + )); + + // gradle.setClosures [projectPath, closures[]] + let closures: Vec = project + .plugin_closures + .iter() + .map(|closure| { + let methods: Vec = closure + .methods + .iter() + .map(|m| { + json!({ + "name": m.name, + "parameterTypes": m.parameter_types, + "deprecated": m.deprecated, + }) + }) + .collect(); + let fields: Vec = closure + .fields + .iter() + .map(|f| json!({ "name": f.name, "deprecated": f.deprecated })) + .collect(); + json!({ "name": closure.name, "methods": methods, "fields": fields }) + }) + .collect(); + out.push(("gradle.setClosures", json!([project_path, closures]))); + + // gradle.setScriptClasspaths [projectPath, scriptClasspaths[]] + out.push(( + "gradle.setScriptClasspaths", + json!([project_path, project.script_classpaths]), + )); + + for sub in &project.projects { + collect_commands(sub, out); + } +} + +/// Normalize an absolute project path so it matches the key the language server +/// derives via `Paths.get(uri).getParent().toString()` — collapsing redundant +/// separators and `.`/`..` segments without resolving symlinks. +fn normalize_project_path(path: &str) -> String { + use std::path::{Component, PathBuf}; + + let mut normalized = PathBuf::new(); + for component in std::path::Path::new(path).components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + normalized.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::gradle::{GrpcGradleClosure, GrpcGradleField, GrpcGradleMethod}; + + fn sample_project() -> GradleProject { + GradleProject { + is_root: true, + tasks: vec![], + projects: vec![GradleProject { + is_root: false, + project_path: "/p/sub".to_string(), + plugins: vec!["java".to_string()], + ..Default::default() + }], + project_path: "/p".to_string(), + dependency_item: None, + plugins: vec!["java".to_string(), "application".to_string()], + plugin_closures: vec![GrpcGradleClosure { + name: "java".to_string(), + methods: vec![GrpcGradleMethod { + name: "sourceCompatibility".to_string(), + parameter_types: vec!["String".to_string()], + deprecated: false, + }], + fields: vec![GrpcGradleField { + name: "sourceSets".to_string(), + deprecated: false, + }], + }], + script_classpaths: vec!["/p/.gradle/x.jar".to_string()], + } + } + + #[test] + fn emits_three_commands_per_project_recursively() { + let cmds = model_to_commands(&sample_project()); + // root + 1 subproject, 3 commands each. + assert_eq!(cmds.len(), 6); + assert_eq!(cmds[0].0, "gradle.setPlugins"); + assert_eq!(cmds[1].0, "gradle.setClosures"); + assert_eq!(cmds[2].0, "gradle.setScriptClasspaths"); + // Root projectPath is arg 0 of setPlugins. + assert_eq!(cmds[0].1[0], "/p"); + assert_eq!(cmds[0].1[1][0], "java"); + // Subproject follows. + assert_eq!(cmds[3].1[0], "/p/sub"); + } + + #[test] + fn closure_shape_matches_ls_contract() { + let cmds = model_to_commands(&sample_project()); + let closures = &cmds[1].1[1]; + assert_eq!(closures[0]["name"], "java"); + assert_eq!(closures[0]["methods"][0]["name"], "sourceCompatibility"); + assert_eq!(closures[0]["methods"][0]["parameterTypes"][0], "String"); + assert_eq!(closures[0]["methods"][0]["deprecated"], false); + assert_eq!(closures[0]["fields"][0]["name"], "sourceSets"); + } + + #[test] + fn normalizes_dot_segments() { + assert_eq!(normalize_project_path("/p/./sub/../sub"), "/p/sub"); + } +} diff --git a/gradle-bridge/src/main.rs b/gradle-bridge/src/main.rs new file mode 100644 index 0000000..807d455 --- /dev/null +++ b/gradle-bridge/src/main.rs @@ -0,0 +1,231 @@ +//! `gradle-lsp-bridge` — bridges Zed (LSP over stdio) to the Microsoft Gradle +//! Language Server (LSP over a Unix socket / Windows named pipe) and drives the +//! real shipped `gradle-server.jar` over gRPC to feed the LS a plugin-aware +//! build model. +//! +//! Invocation (set up by the Zed Java extension): +//! +//! ```text +//! gradle-lsp-bridge -cp com.microsoft.gradle.GradleLanguageServer +//! ``` +//! +//! The classpath already contains every jar the gradle-server needs +//! (`gradle-server.jar`, grpc-netty, netty, the Tooling API), so the bridge +//! launches the server from the same classpath — no extra jars shipped. + +mod channel; +mod grpc; +mod proto; +mod sync; +mod transport; + +use std::process; +use std::sync::Arc; + +use channel::EditorChannel; +use grpc::{DistributionConfig, GradleServer}; +use sync::SyncScheduler; +use transport::{pump_editor_to_ls, pump_ls_to_editor, LsWriter}; + +/// Parsed launch arguments: the java binary, the LS classpath, and the LS main +/// class. Mirrors the ` -cp ` shape. +struct Args { + java: String, + classpath: String, + main_class: String, +} + +fn parse_args() -> Args { + let args: Vec = std::env::args().skip(1).collect(); + // Expect: -cp + let cp_idx = args.iter().position(|a| a == "-cp"); + let (Some(java), Some(cp_idx)) = (args.first().cloned(), cp_idx) else { + eprintln!( + "Usage: gradle-lsp-bridge -cp com.microsoft.gradle.GradleLanguageServer" + ); + process::exit(1); + }; + let Some(classpath) = args.get(cp_idx + 1).cloned() else { + eprintln!("gradle-lsp-bridge: missing classpath after -cp"); + process::exit(1); + }; + let Some(main_class) = args.get(cp_idx + 2).cloned() else { + eprintln!("gradle-lsp-bridge: missing language server main class"); + process::exit(1); + }; + Args { + java, + classpath, + main_class, + } +} + +/// The project root the editor opened. Zed launches the bridge with the project +/// root as the working directory (`PWD`), matching how the previous helper +/// resolved it. +fn project_dir() -> Option { + std::env::var("PWD").ok().or_else(|| { + std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + }) +} + +fn main() { + let args = parse_args(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap_or_else(|e| { + eprintln!("gradle-lsp-bridge: failed to start tokio runtime: {e}"); + process::exit(1); + }); + runtime.block_on(run(args)); +} + +/// Construct the long-lived gradle-server manager from the launch args + env. +fn build_server(args: &Args) -> GradleServer { + let java_home = std::env::var("JAVA_HOME").ok().filter(|s| !s.is_empty()); + GradleServer::new( + args.java.clone(), + args.classpath.clone(), + java_home, + DistributionConfig::from_env(), + ) +} + +/// Wire up the channel/writer/scheduler and run both pumps to completion. +async fn drive(ls_read: R, ls_write: W, server: GradleServer) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let channel = Arc::new(EditorChannel::new()); + let ls_writer = LsWriter::new(ls_write); + let dir = project_dir().unwrap_or_else(|| ".".to_string()); + let scheduler = SyncScheduler::new(server, Arc::clone(&channel), ls_writer.clone(), dir); + + // LS -> editor: frame, drop injected responses, merge diagnostics. + let ls_to_editor = tokio::spawn(pump_ls_to_editor(ls_read, Arc::clone(&channel))); + + // editor -> LS: forward + drive the build-model sync. + let editor = tokio::io::stdin(); + let editor_to_ls = tokio::spawn(pump_editor_to_ls(editor, ls_writer, scheduler)); + + // Either side closing ends the bridge. + tokio::select! { + _ = ls_to_editor => {} + _ = editor_to_ls => {} + } +} + +#[cfg(unix)] +async fn run(args: Args) { + use tokio::net::UnixListener; + + let socket_dir = std::env::temp_dir().join(format!("gradle-ls-{}", process::id())); + if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { + eprintln!("gradle-lsp-bridge: failed to create socket dir: {e}"); + process::exit(1); + } + let socket_path = socket_dir.join("ls.sock"); + + let listener = match UnixListener::bind(&socket_path) { + Ok(l) => l, + Err(e) => { + eprintln!("gradle-lsp-bridge: failed to bind socket: {e}"); + process::exit(1); + } + }; + + // Spawn the language server pointed at our socket. + let mut ls_child = match std::process::Command::new(&args.java) + .args([ + "-cp", + &args.classpath, + &args.main_class, + &socket_path.to_string_lossy(), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("gradle-lsp-bridge: failed to spawn language server: {e}"); + process::exit(1); + } + }; + + // Terminate the LS if the editor that launched us goes away. + let alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); + proxy_common::spawn_parent_monitor(Arc::clone(&alive), ls_child.id()); + + let stream = match listener.accept().await { + Ok((stream, _)) => stream, + Err(e) => { + eprintln!("gradle-lsp-bridge: failed to accept LS connection: {e}"); + let _ = ls_child.kill(); + process::exit(1); + } + }; + let (ls_read, ls_write) = stream.into_split(); + + let server = build_server(&args); + drive(ls_read, ls_write, server.clone()).await; + + server.shutdown().await; + let _ = ls_child.kill(); + let _ = tokio::fs::remove_file(&socket_path).await; + let _ = tokio::fs::remove_dir(&socket_dir).await; +} + +#[cfg(windows)] +async fn run(args: Args) { + use tokio::net::windows::named_pipe::ServerOptions; + + let pipe_name = format!("\\\\.\\pipe\\gradle-ls-{}", process::id()); + let server_pipe = match ServerOptions::new() + .first_pipe_instance(true) + .create(&pipe_name) + { + Ok(p) => p, + Err(e) => { + eprintln!("gradle-lsp-bridge: failed to create named pipe: {e}"); + process::exit(1); + } + }; + + // Spawn the language server pointed at our pipe. + let mut ls_child = match std::process::Command::new(&args.java) + .args(["-cp", &args.classpath, &args.main_class, &pipe_name]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("gradle-lsp-bridge: failed to spawn language server: {e}"); + process::exit(1); + } + }; + + let alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); + proxy_common::spawn_parent_monitor(Arc::clone(&alive), ls_child.id()); + + if let Err(e) = server_pipe.connect().await { + eprintln!("gradle-lsp-bridge: failed to accept pipe connection: {e}"); + let _ = ls_child.kill(); + process::exit(1); + } + + let (ls_read, ls_write) = transport::split_duplex(server_pipe); + + let server = build_server(&args); + drive(ls_read, ls_write, server.clone()).await; + + server.shutdown().await; + let _ = ls_child.kill(); +} diff --git a/gradle-bridge/src/proto.rs b/gradle-bridge/src/proto.rs new file mode 100644 index 0000000..daf3ac4 --- /dev/null +++ b/gradle-bridge/src/proto.rs @@ -0,0 +1,10 @@ +//! Generated gRPC bindings for the shipped `gradle-server` contract. +//! +//! The contents of [`gradle`] are produced by `prost`/`tonic` from +//! `proto/gradle.proto` (a verbatim copy of the proto embedded in +//! `gradle-server.jar`) and committed under `src/gen/`, so the build needs no +//! `protoc`. See `proto/gradle.proto` for regeneration instructions. +#[allow(clippy::all, clippy::pedantic, missing_docs)] +pub mod gradle { + include!("gen/gradle.rs"); +} diff --git a/gradle-bridge/src/sync.rs b/gradle-bridge/src/sync.rs new file mode 100644 index 0000000..46cf8ac --- /dev/null +++ b/gradle-bridge/src/sync.rs @@ -0,0 +1,153 @@ +//! Serializes Gradle build-model syncs: at most one runs at a time, and requests +//! arriving while a sync is in flight collapse into a single pending rerun. This +//! mirrors the VS Code extension, which serializes refreshes and coalesces bursts +//! (e.g. "save all") rather than launching a Gradle build per event. +//! +//! Unlike the previous fork-per-save helper, a sync here is a `GetBuild` RPC to +//! the already-warm `gradle-server`, so a coalesced burst resolves quickly. + +use std::sync::Arc; + +use serde_json::json; +use tokio::sync::Mutex; + +use proxy_common::encode_lsp; + +use crate::channel::{build_eval_diagnostics, EditorChannel, INJECTED_ID_PREFIX}; +use crate::grpc::{model_to_commands, BuildOutcome, GradleServer}; +use crate::transport::LsWriter; + +/// Coordinates single-flight syncs against the shared [`GradleServer`]. +#[derive(Clone)] +pub struct SyncScheduler { + state: Arc>, + server: GradleServer, + channel: Arc, + ls_writer: LsWriter, + project_dir: String, +} + +#[derive(Default)] +struct SyncState { + running: bool, + /// A rerun was requested while a sync was in flight; run exactly once more. + pending: bool, + /// A monotonically increasing key so a superseding sync can cancel the + /// in-flight build. + seq: u64, +} + +impl SyncScheduler { + pub fn new( + server: GradleServer, + channel: Arc, + ls_writer: LsWriter, + project_dir: String, + ) -> Self { + Self { + state: Arc::new(Mutex::new(SyncState::default())), + server, + channel, + ls_writer, + project_dir, + } + } + + /// Request a sync. Starts a worker if none is running; otherwise marks a + /// single rerun pending and cancels the in-flight build so the newest inputs + /// win quickly. + pub async fn request(&self) { + let mut state = self.state.lock().await; + if state.running { + state.pending = true; + let key = cancellation_key(state.seq); + drop(state); + // Cancel the in-flight build; the pending rerun picks up the change. + self.server.cancel(&key).await; + return; + } + state.running = true; + drop(state); + self.spawn_worker(); + } + + fn spawn_worker(&self) { + let this = self.clone(); + tokio::spawn(async move { + loop { + let seq = { + let mut s = this.state.lock().await; + s.pending = false; + s.seq += 1; + s.seq + }; + + this.run_once(seq).await; + + let mut s = this.state.lock().await; + if !s.pending { + s.running = false; + break; + } + } + }); + } + + /// Perform one `GetBuild` + forward cycle. + async fn run_once(&self, seq: u64) { + let key = cancellation_key(seq); + match self.server.get_build(&self.project_dir, &key).await { + BuildOutcome::Model(root) => { + // Successful evaluation: clear any prior build-eval diagnostics. + self.channel + .set_sync_diagnostics(std::collections::HashMap::new()) + .await; + + let commands = model_to_commands(&root); + for (idx, (command, arguments)) in commands.into_iter().enumerate() { + let msg = json!({ + "jsonrpc": "2.0", + "id": format!("{INJECTED_ID_PREFIX}{seq}-{idx}"), + "method": "workspace/executeCommand", + "params": { "command": command, "arguments": arguments } + }); + self.ls_writer.send(encode_lsp(&msg).into_bytes()).await; + } + } + BuildOutcome::Error { error, causes } => { + // Log the full detail (the top-level message plus the Gradle + // stderr captured as causes), not just the generic outer message + // — the causes are where the offending build file and line live. + eprintln!("[gradle-bridge] build model sync failed: {error}"); + for cause in &causes { + eprintln!("[gradle-bridge] {cause}"); + } + let build_file = default_build_file(&self.project_dir); + self.channel + .set_sync_diagnostics(build_eval_diagnostics( + &error, + &causes, + build_file.as_deref(), + )) + .await; + } + } + } +} + +fn cancellation_key(seq: u64) -> String { + format!("gradle-bridge-sync-{seq}") +} + +/// Best-effort default build file for attaching a diagnostic when the error +/// message carries no path: `build.gradle`, else `build.gradle.kts`. +fn default_build_file(project_dir: &str) -> Option { + let dir = std::path::Path::new(project_dir); + for name in ["build.gradle", "build.gradle.kts"] { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate.to_str().map(str::to_string); + } + } + None +} diff --git a/gradle-bridge/src/transport.rs b/gradle-bridge/src/transport.rs new file mode 100644 index 0000000..7cf66b7 --- /dev/null +++ b/gradle-bridge/src/transport.rs @@ -0,0 +1,164 @@ +//! Transport between the editor (stdin/stdout) and the Gradle Language Server +//! (a Unix domain socket on macOS/Linux, a named pipe on Windows — the LS does +//! not support stdio), plus the two async pumps that move LSP messages in each +//! direction. +//! +//! The pumps are generic over the LS-side read/write halves so the Unix-socket +//! and Windows-pipe paths share identical framing, sync-driving, injected- +//! response filtering, and diagnostics merging. + +use std::sync::Arc; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::Mutex; + +use crate::channel::{ + is_gradle_build_file_save, is_initialized_notification, is_injected_response, + is_wrapper_properties_save, parse_publish_diagnostics, EditorChannel, +}; +use crate::sync::SyncScheduler; + +/// A cloneable handle for writing framed LSP messages to the language server. +/// Wraps the LS-side writer behind a mutex so both the editor→LS pump and the +/// sync worker's injected commands can share it. +#[derive(Clone)] +pub struct LsWriter { + inner: Arc>>, +} + +impl LsWriter { + pub fn new(writer: W) -> Self { + Self { + inner: Arc::new(Mutex::new(Box::new(writer))), + } + } + + /// Write raw bytes to the LS, flushing afterward. Errors are swallowed; a + /// dead LS surfaces via the pumps' read loops ending. + pub async fn send(&self, bytes: Vec) { + let mut w = self.inner.lock().await; + let _ = w.write_all(&bytes).await; + let _ = w.flush().await; + } +} + +/// Async LSP message reader: reads `Content-Length`-framed messages from an +/// `AsyncRead`, returning each complete message (headers + body) as raw bytes. +pub struct AsyncLspReader { + reader: R, +} + +impl AsyncLspReader { + pub fn new(reader: R) -> Self { + Self { reader } + } + + /// Read the next message, or `None` at EOF. + pub async fn read_message(&mut self) -> std::io::Result>> { + let mut header_buf: Vec = Vec::new(); + loop { + let mut byte = [0u8; 1]; + match self.reader.read(&mut byte).await { + Ok(0) => return Ok(None), + Ok(_) => header_buf.push(byte[0]), + Err(e) => return Err(e), + } + if header_buf.ends_with(b"\r\n\r\n") { + break; + } + } + + let header_str = String::from_utf8_lossy(&header_buf); + let content_length = header_str + .lines() + .find_map(|line| { + let (name, value) = line.split_once(": ")?; + if name.eq_ignore_ascii_case("Content-Length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + + let mut content = vec![0u8; content_length]; + self.reader.read_exact(&mut content).await?; + + let mut message = header_buf; + message.extend_from_slice(&content); + Ok(Some(message)) + } +} + +/// Pump messages from the language server to the editor via `channel`, parsing +/// LSP framing so that: +/// - responses to the bridge's own injected requests are dropped (the editor +/// never issued them); +/// - the server's `publishDiagnostics` are merged with the build-model sync's +/// diagnostics for the same URI rather than overwriting them. +/// +/// All other messages are forwarded verbatim. Runs until the server closes the +/// connection. +pub async fn pump_ls_to_editor( + ls_reader: R, + channel: Arc, +) { + let mut reader = AsyncLspReader::new(ls_reader); + while let Ok(Some(raw)) = reader.read_message().await { + if is_injected_response(&raw) { + continue; + } + if let Some((uri, diagnostics)) = parse_publish_diagnostics(&raw) { + channel.set_server_diagnostics(uri, diagnostics).await; + continue; + } + if !channel.forward_raw(&raw).await { + break; + } + } +} + +/// Pump messages from the editor's stdin to the language server, forwarding each +/// verbatim and driving the build-model sync: an initial sync once the server is +/// initialized, then a re-sync on every save of a Gradle build file or +/// `gradle-wrapper.properties`. +/// +/// Runs until stdin closes. +pub async fn pump_editor_to_ls( + editor_reader: R, + ls_writer: LsWriter, + scheduler: SyncScheduler, +) { + let mut reader = AsyncLspReader::new(editor_reader); + let mut initialized_sent = false; + + while let Ok(Some(raw)) = reader.read_message().await { + ls_writer.send(raw.clone()).await; + + // Initial sync once the server is ready, then re-sync on every save of a + // Gradle build file or the wrapper properties — the build model + // (plugins, closures, classpaths) can change and the language server + // otherwise keeps stale completions. + let should_sync = if !initialized_sent && is_initialized_notification(&raw) { + initialized_sent = true; + true + } else { + initialized_sent + && (is_gradle_build_file_save(&raw) || is_wrapper_properties_save(&raw)) + }; + + if should_sync { + scheduler.request().await; + } + } +} + +/// Split an owned duplex stream (used on Windows, where read and write share one +/// pipe handle) into the read and write halves the pumps expect. +#[cfg(windows)] +pub fn split_duplex(stream: S) -> (tokio::io::ReadHalf, tokio::io::WriteHalf) +where + S: AsyncRead + AsyncWrite, +{ + tokio::io::split(stream) +} diff --git a/justfile b/justfile index 4fec092..36903a1 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ tasks_bin := ext_dir / "bin" / "java-task-helper" # Build proxy in debug mode proxy-build: - cd proxy && cargo build --target {{ native_target }} + cargo build --target {{ native_target }} -p java-lsp-proxy # Build proxy in release mode proxy-release: @@ -42,6 +42,13 @@ task-clean: cd task_helper && cargo clean # --- Core recipes --- +# Build gradle-lsp-bridge in debug mode +bridge-build: + cargo build --target {{ native_target }} -p gradle-lsp-bridge + +# Build gradle-lsp-bridge in release mode +bridge-release: + cargo build --release --target {{ native_target }} -p gradle-lsp-bridge # Build WASM extension in release mode ext-build: @@ -52,10 +59,10 @@ fmt: cargo fmt --all ts_query_ls format languages -# Run clippy on both crates +# Run clippy on the WASM extension and the native crates (proxy, bridge, common) clippy: cargo clippy --all-targets --fix --allow-dirty - cd proxy && cargo clippy --all-targets --fix --allow-dirty --target {{ native_target }} + cargo clippy --fix --allow-dirty --target {{ native_target }} -p java-lsp-proxy -p gradle-lsp-bridge -p proxy-common # Format and lint all code lint: fmt clippy diff --git a/languages/gradle/config.toml b/languages/gradle/config.toml new file mode 100644 index 0000000..bbe0bf5 --- /dev/null +++ b/languages/gradle/config.toml @@ -0,0 +1,13 @@ +name = "Gradle" +grammar = "groovy" +path_suffixes = ["gradle"] +line_comments = ["// "] +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = false }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "'", end = "'", close = true, newline = false, not_in = ["string"] }, + { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] }, +] +block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 0 } diff --git a/languages/gradle/highlights.scm b/languages/gradle/highlights.scm new file mode 100644 index 0000000..1fd272f --- /dev/null +++ b/languages/gradle/highlights.scm @@ -0,0 +1,260 @@ +[ + "!in" + "!instanceof" + "as" + "assert" + "case" + "catch" + "class" + "def" + "default" + "else" + "extends" + "finally" + "for" + "if" + "import" + "in" + "instanceof" + "package" + "pipeline" + "return" + "switch" + "try" + "while" + (break) + (continue) +] @keyword + +[ + "true" + "false" +] @boolean + +(null) @constant + +"this" @variable.builtin + +[ + "int" + "char" + "short" + "long" + "boolean" + "float" + "double" + "void" +] @type.builtin + +[ + "final" + "private" + "protected" + "public" + "static" + "synchronized" +] @type.qualifier + +(comment) @comment + +(shebang) @comment + +(string) @string + +(string + (escape_sequence) @operator) + +(string + (interpolation + ("$") @operator)) + +"(" @punctuation.bracket + +")" @punctuation.bracket + +"[" @punctuation.bracket + +"]" @punctuation.bracket + +"{" @punctuation.bracket + +"}" @punctuation.bracket + +":" @punctuation.delimiter + +"," @punctuation.delimiter + +"." @punctuation.delimiter + +(number_literal) @number + +(identifier) @variable + +((identifier) @variable.parameter + (#is? @variable.parameter "local.parameter")) + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]+")) + +[ + "%" + "*" + "/" + "+" + "-" + "<<" + ">>" + ">>>" + ".." + "..<" + "<..<" + "<.." + "<" + "<=" + ">" + ">=" + "==" + "!=" + "<=>" + "===" + "!==" + "=~" + "==~" + "&" + "^" + "|" + "&&" + "||" + "?:" + "+" + "*" + ".&" + ".@" + "?." + "*." + "*" + "*:" + "++" + "--" + "!" +] @operator + +(string + "/" @string) + +(ternary_op + ([ + "?" + ":" + ]) @operator) + +(map + (map_item + key: (identifier) @variable.parameter)) + +(parameter + type: (identifier) @type + name: (identifier) @variable.parameter) + +(generic_param + name: (identifier) @variable.parameter) + +(declaration + type: (identifier) @type) + +(function_definition + type: (identifier) @type) + +(function_declaration + type: (identifier) @type) + +(class_definition + name: (identifier) @type) + +(class_definition + superclass: (identifier) @type) + +(generic_param + superclass: (identifier) @type) + +(type_with_generics + (identifier) @type) + +(type_with_generics + (generics + (identifier) @type)) + +(generics + [ + "<" + ">" + ] @punctuation.bracket) + +(generic_parameters + [ + "<" + ">" + ] @punctuation.bracket) + +; TODO: Class literals with PascalCase +(declaration + "=" @operator) + +(assignment + "=" @operator) + +(function_call + function: (identifier) @function) + +(function_call + function: (dotted_identifier + (identifier) @function .)) + +(function_call + (argument_list + (map_item + key: (identifier) @variable.parameter))) + +(juxt_function_call + function: (identifier) @function) + +(juxt_function_call + function: (dotted_identifier + (identifier) @function .)) + +(juxt_function_call + (argument_list + (map_item + key: (identifier) @variable.parameter))) + +(function_definition + function: (identifier) @function) + +(function_declaration + function: (identifier) @function) + +(annotation) @function.macro + +(annotation + (identifier) @function.macro) + +"@interface" @function.macro + +"pipeline" @keyword + +(groovy_doc) @comment.documentation + +(groovy_doc + [ + (groovy_doc_param) + (groovy_doc_throws) + (groovy_doc_tag) + ] @string.special) + +(groovy_doc + (groovy_doc_param + (identifier) @variable.parameter)) + +(groovy_doc + (groovy_doc_throws + (identifier) @type)) diff --git a/languages/gradle/indents.scm b/languages/gradle/indents.scm new file mode 100644 index 0000000..888d501 --- /dev/null +++ b/languages/gradle/indents.scm @@ -0,0 +1,35 @@ +[ + (closure) + (map) + (list) + (argument_list) + (parameter_list) + (for_parameters) +] @indent.begin + +; (function_definition "(" @indent.begin) +(closure + "}" @indent.end) + +(argument_list + ")" @indent.end) + +(for_parameters + ")" @indent.end) + +((for_loop + body: (_) @_body) @indent.begin + (#not-has-type? @_body closure)) + +; TODO: while, try +(list + "]" @indent.end) + +(map + "]" @indent.end) + +[ + "}" + ")" + "]" +] @indent.branch diff --git a/languages/gradle/locals.scm b/languages/gradle/locals.scm new file mode 100644 index 0000000..23cb5f0 --- /dev/null +++ b/languages/gradle/locals.scm @@ -0,0 +1,6 @@ +(function_definition) @local.scope + +(parameter + name: (identifier) @local.definition.parameter) + +(identifier) @local.reference diff --git a/proxy-common/Cargo.toml b/proxy-common/Cargo.toml new file mode 100644 index 0000000..fe48327 --- /dev/null +++ b/proxy-common/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "proxy-common" +version = "6.8.20" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "Shared primitives (LSP framing, parent-process monitor) for the Zed Java extension's native binaries" + +[lib] +name = "proxy_common" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] } diff --git a/proxy-common/src/lib.rs b/proxy-common/src/lib.rs new file mode 100644 index 0000000..d281d15 --- /dev/null +++ b/proxy-common/src/lib.rs @@ -0,0 +1,16 @@ +//! Primitives shared by the Zed Java extension's native binaries +//! (`java-lsp-proxy` and `gradle-lsp-bridge`): +//! +//! - [`lsp`]: LSP message framing — a streaming reader, content parsing, and +//! encoding helpers. +//! - [`platform`]: a parent-process monitor that terminates the spawned child +//! when the editor that launched us goes away. + +pub mod lsp; +pub mod platform; + +pub use lsp::{ + encode_lsp, parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader, + CONTENT_LENGTH, HEADER_SEP, +}; +pub use platform::spawn_parent_monitor; diff --git a/proxy/src/lsp.rs b/proxy-common/src/lsp.rs similarity index 100% rename from proxy/src/lsp.rs rename to proxy-common/src/lsp.rs diff --git a/proxy/src/platform/mod.rs b/proxy-common/src/platform/mod.rs similarity index 100% rename from proxy/src/platform/mod.rs rename to proxy-common/src/platform/mod.rs diff --git a/proxy/src/platform/unix.rs b/proxy-common/src/platform/unix.rs similarity index 100% rename from proxy/src/platform/unix.rs rename to proxy-common/src/platform/unix.rs diff --git a/proxy/src/platform/windows.rs b/proxy-common/src/platform/windows.rs similarity index 100% rename from proxy/src/platform/windows.rs rename to proxy-common/src/platform/windows.rs diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index 595281a..8c16f5a 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "java-lsp-proxy" -version = "6.8.12" +version = "6.8.20" edition = "2021" publish = false license = "Apache-2.0" @@ -11,11 +11,6 @@ name = "java-lsp-proxy" path = "src/main.rs" [dependencies] +proxy-common.workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] } diff --git a/proxy/src/decompile.rs b/proxy/src/decompile.rs index c65a355..b89e435 100644 --- a/proxy/src/decompile.rs +++ b/proxy/src/decompile.rs @@ -8,7 +8,8 @@ use std::{ sync::{mpsc, Arc, Mutex}, }; -use crate::{lsp::encode_lsp, lsp_error, lsp_warn}; +use crate::{lsp_error, lsp_warn}; +use proxy_common::encode_lsp; const DECOMPILED_DIR: &str = "jdtls-decompiled"; diff --git a/proxy/src/http.rs b/proxy/src/http.rs index 7aa5494..33b4d3d 100644 --- a/proxy/src/http.rs +++ b/proxy/src/http.rs @@ -10,7 +10,7 @@ use std::{ time::Duration, }; -use crate::lsp::encode_lsp; +use proxy_common::encode_lsp; pub const TIMEOUT: Duration = Duration::from_secs(5); diff --git a/proxy/src/log.rs b/proxy/src/log.rs index c91f3b4..a93ae58 100644 --- a/proxy/src/log.rs +++ b/proxy/src/log.rs @@ -1,7 +1,7 @@ use serde::Serialize; use std::io::{self, Write}; -use crate::lsp::encode_lsp; +use proxy_common::encode_lsp; /// LSP `MessageType` constants as defined in the specification. /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageType diff --git a/proxy/src/main.rs b/proxy/src/main.rs index b07ce68..55fd5d2 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -2,14 +2,11 @@ mod completions; mod decompile; mod http; mod log; -mod lsp; -mod platform; use completions::{is_completion_response, process_completions, sanitize_resolved_completion}; use decompile::{rewrite_jdt_in_strings, rewrite_jdt_locations}; use http::handle_http; -use lsp::{parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader}; -use platform::spawn_parent_monitor; +use proxy_common::{parse_lsp_content, raw_has_id, spawn_parent_monitor, write_raw, write_to_stdout, LspReader}; use serde_json::Value; use std::{ collections::HashMap, @@ -33,6 +30,7 @@ enum TrackedKind { fn main() { let args: Vec = env::args().skip(1).collect(); + if args.len() < 2 { eprintln!("Usage: java-lsp-proxy [args...]"); lsp_error!("Usage: java-lsp-proxy [args...]"); diff --git a/src/config.rs b/src/config.rs index 7d0553d..cd4f730 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,3 +162,26 @@ pub fn get_lsp_proxy_path(configuration: &Option, worktree: &Worktree) -> None } + +/// Path to a local `gradle-lsp-bridge` binary, overriding the downloaded one. +/// Parallel to [`get_lsp_proxy_path`] for the JDTLS proxy; primarily used to +/// test a locally-built bridge before a release ships the asset. +pub fn get_gradle_bridge_path( + configuration: &Option, + worktree: &Worktree, +) -> Option { + if let Some(configuration) = configuration + && let Some(bridge_path) = configuration + .pointer("/gradle_bridge_path") + .and_then(|x| x.as_str()) + { + match expand_home_path(worktree, bridge_path.to_string()) { + Ok(path) => return Some(path), + Err(err) => { + println!("{err}"); + } + } + } + + None +} diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs new file mode 100644 index 0000000..044198d --- /dev/null +++ b/src/gradle_bridge.rs @@ -0,0 +1,219 @@ +use std::{fs::metadata, path::PathBuf}; + +use zed_extension_api::{ + self as zed, DownloadedFileType, GithubReleaseOptions, LanguageServerId, + LanguageServerInstallationStatus, Worktree, serde_json::Value, + set_language_server_installation_status, +}; + +use crate::{ + config::{get_gradle_bridge_path, get_lsp_proxy_path}, + downloadable::Downloadable, + util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}, +}; + +const BRIDGE_BINARY: &str = "gradle-lsp-bridge"; +const BRIDGE_INSTALL_PATH: &str = "gradle-bridge-bin"; +/// The bridge ships as a separate per-platform asset on the same GitHub release +/// as the JDTLS proxy (the extension version's release tag). +const GITHUB_REPO: &str = "zed-extensions/java"; + +/// Downloads and locates the `gradle-lsp-bridge` binary — the native process +/// that bridges Zed to the Gradle Language Server and drives the real +/// `gradle-server.jar` over gRPC. Mirrors [`crate::proxy::Proxy`], but resolves +/// its own asset name so the two binaries can be downloaded independently from +/// the shared release. +pub struct GradleBridge { + cached_path: Option, +} + +impl GradleBridge { + pub fn new() -> Self { + Self { cached_path: None } + } + + pub fn binary_path( + &mut self, + configuration: &Option, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result { + let path = self.get_or_download(language_server_id, configuration, worktree)?; + Ok(path.to_string_lossy().to_string()) + } +} + +impl Downloadable for GradleBridge { + const INSTALL_PATH: &'static str = BRIDGE_INSTALL_PATH; + + fn find_local(&self) -> Option { + let local_binary = PathBuf::from(BRIDGE_INSTALL_PATH).join(bridge_exec()); + if metadata(&local_binary).is_ok_and(|m| m.is_file()) { + return Some(local_binary); + } + + std::fs::read_dir(BRIDGE_INSTALL_PATH) + .ok()? + .filter_map(Result::ok) + .map(|e| e.path().join(bridge_exec())) + .filter(|p| metadata(p).is_ok_and(|m| m.is_file())) + .last() + } + + fn loaded(&self) -> bool { + self.cached_path.is_some() + } + + fn fetch_latest_version(&self) -> zed::Result { + Ok(zed::latest_github_release( + GITHUB_REPO, + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + ) + .map_err(|err| format!("Failed to fetch latest bridge release from {GITHUB_REPO}: {err}"))? + .version) + } + + fn download( + &mut self, + version: &str, + language_server_id: &LanguageServerId, + ) -> zed::Result { + let (name, file_type) = asset_name()?; + let bin_path = format!("{BRIDGE_INSTALL_PATH}/{version}/{}", bridge_exec()); + + if metadata(&bin_path).is_ok() { + self.cached_path = Some(bin_path.clone()); + return Ok(PathBuf::from(bin_path)); + } + + let release = zed::latest_github_release( + GITHUB_REPO, + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + ) + .map_err(|err| format!("Failed to fetch bridge release: {err}"))?; + + let asset = release + .assets + .iter() + .find(|a| a.name == name) + .ok_or_else(|| format!("No asset found matching {name:?}"))?; + + let version_dir = format!("{BRIDGE_INSTALL_PATH}/{version}"); + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file(&asset.download_url, &version_dir, file_type) + .map_err(|err| format!("Failed to download bridge: {err}"))?; + + let _ = zed::make_file_executable(&bin_path); + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + let _ = remove_all_files_except(BRIDGE_INSTALL_PATH, version); + let _ = mark_checked_once(BRIDGE_INSTALL_PATH, version); + + self.cached_path = Some(bin_path.clone()); + Ok(PathBuf::from(bin_path)) + } + + fn get_or_download( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + ) -> zed::Result { + if let Some(path) = self.user_configured_path(configuration, worktree) { + self.cached_path = Some(path.clone()); + return Ok(PathBuf::from(path)); + } + + if let Some(path) = + should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) + .unwrap_or(None) + { + let s = path.to_string_lossy().to_string(); + self.cached_path = Some(s); + return Ok(path); + } + + if let Ok(version) = self.fetch_latest_version() + && let Ok(path) = self.download(&version, language_server_id) + { + return Ok(path); + } + + if let Some(path) = worktree.which(bridge_exec().as_str()) { + return Ok(PathBuf::from(path)); + } + + Err(format!("'{}' not found", bridge_exec())) + } + + fn user_configured_path( + &self, + configuration: &Option, + worktree: &Worktree, + ) -> Option { + // A dedicated override always wins. + if let Some(path) = get_gradle_bridge_path(configuration, worktree) { + return Some(path); + } + + // Otherwise, if the user points `lsp_proxy_path` at a local build, look + // for the bridge as a sibling — but only adopt it when it actually + // exists, so users running a custom proxy without a co-located bridge + // still fall through to the normal download path. + let proxy_path = get_lsp_proxy_path(configuration, worktree)?; + let p = PathBuf::from(&proxy_path); + let dir = if p.is_dir() { + p + } else { + p.parent().map(PathBuf::from).unwrap_or(p) + }; + let sibling = dir.join(bridge_exec()); + metadata(&sibling) + .is_ok_and(|m| m.is_file()) + .then(|| sibling.to_string_lossy().to_string()) + } +} + +fn asset_name() -> zed::Result<(String, DownloadedFileType)> { + let (os, arch) = zed::current_platform(); + let (os_str, file_type) = match os { + zed::Os::Mac => ("darwin", DownloadedFileType::GzipTar), + zed::Os::Linux => ("linux", DownloadedFileType::GzipTar), + zed::Os::Windows => ("windows", DownloadedFileType::Zip), + }; + let arch_str = match arch { + zed::Architecture::Aarch64 => "aarch64", + zed::Architecture::X8664 => "x86_64", + _ => return Err("Unsupported architecture".into()), + }; + let ext = if matches!(file_type, DownloadedFileType::Zip) { + "zip" + } else { + "tar.gz" + }; + Ok(( + format!("{BRIDGE_BINARY}-{os_str}-{arch_str}.{ext}"), + file_type, + )) +} + +fn bridge_exec() -> String { + let (os, _arch) = zed::current_platform(); + match os { + zed::Os::Linux | zed::Os::Mac => BRIDGE_BINARY.to_string(), + zed::Os::Windows => format!("{BRIDGE_BINARY}.exe"), + } +} diff --git a/src/gradle_ls.rs b/src/gradle_ls.rs new file mode 100644 index 0000000..b6d13f8 --- /dev/null +++ b/src/gradle_ls.rs @@ -0,0 +1,123 @@ +use std::{ + fs::{metadata, read_dir}, + path::PathBuf, +}; + +use zed_extension_api::{ + self as zed, DownloadedFileType, GithubReleaseOptions, LanguageServerId, + LanguageServerInstallationStatus, set_language_server_installation_status, +}; + +use crate::{ + downloadable::Downloadable, + util::{create_path_if_not_exists, mark_checked_once, remove_all_files_except}, +}; + +const INSTALL_PATH: &str = "gradle-ls"; +const GITHUB_REPO: &str = "microsoft/vscode-gradle"; +const VSIX_PUBLISHER: &str = "vscjava"; +const VSIX_EXTENSION: &str = "vscode-gradle"; + +pub struct GradleLs { + cached_path: Option, +} + +impl GradleLs { + pub fn new() -> Self { + Self { cached_path: None } + } +} + +impl Downloadable for GradleLs { + const INSTALL_PATH: &'static str = INSTALL_PATH; + + fn find_local(&self) -> Option { + let prefix = PathBuf::from(INSTALL_PATH); + read_dir(&prefix) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .find(|path| path.join("lib").is_dir()) + } + + fn loaded(&self) -> bool { + self.cached_path.is_some() + } + + fn fetch_latest_version(&self) -> zed::Result { + let release = zed::latest_github_release( + GITHUB_REPO, + GithubReleaseOptions { + require_assets: false, + pre_release: false, + }, + ) + .map_err(|err| { + format!("Failed to fetch latest Gradle LS release from {GITHUB_REPO}: {err}") + })?; + Ok(release.version) + } + + fn download( + &mut self, + version: &str, + language_server_id: &LanguageServerId, + ) -> zed::Result { + let version_dir = PathBuf::from(INSTALL_PATH).join(version); + let lib_dir = version_dir.join("lib"); + + if metadata(&lib_dir).is_ok_and(|m| m.is_dir()) { + self.cached_path = Some(version_dir.clone()); + return Ok(version_dir); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + create_path_if_not_exists(&version_dir) + .map_err(|err| format!("Failed to create Gradle LS directory: {err}"))?; + + let download_url = format!( + "https://{VSIX_PUBLISHER}.gallery.vsassets.io/_apis/public/gallery/publisher/{VSIX_PUBLISHER}/extension/{VSIX_EXTENSION}/{version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage" + ); + + // The VSIX is a zip file. We download and extract it into a temp location, + // then move the lib/ directory to our version directory. + let vsix_dir = PathBuf::from(INSTALL_PATH).join("_vsix_temp"); + let vsix_dir_str = vsix_dir.to_string_lossy().to_string(); + + zed::download_file(&download_url, &vsix_dir_str, DownloadedFileType::Zip) + .map_err(|err| format!("Failed to download Gradle LS VSIX: {err}"))?; + + // The VSIX extracts with extension/lib/ containing the JARs + let extracted_lib = vsix_dir.join("extension").join("lib"); + if !metadata(&extracted_lib).is_ok_and(|m| m.is_dir()) { + let _ = std::fs::remove_dir_all(&vsix_dir); + return Err( + "Downloaded VSIX does not contain expected extension/lib/ directory".to_string(), + ); + } + + // Move extension/lib/ to our version directory + std::fs::rename(&extracted_lib, &lib_dir) + .map_err(|err| format!("Failed to move lib directory: {err}"))?; + + // Cleanup VSIX temp + let _ = std::fs::remove_dir_all(&vsix_dir); + + // Remove old versions + let _ = remove_all_files_except(INSTALL_PATH, version); + let _ = mark_checked_once(INSTALL_PATH, version); + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + + self.cached_path = Some(version_dir.clone()); + Ok(version_dir) + } +} diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs new file mode 100644 index 0000000..5a0c6bb --- /dev/null +++ b/src/gradle_ls_server.rs @@ -0,0 +1,203 @@ +use std::{env, fs}; + +use zed_extension_api::{ + self as zed, LanguageServerId, Os, Worktree, current_platform, + serde_json::{Value, json}, + settings::LspSettings, +}; + +use crate::{ + config::get_java_home, + downloadable::Downloadable, + gradle_bridge::GradleBridge, + gradle_ls::GradleLs, + language_server::LanguageServer, + util::{get_java_executable, path_to_string}, +}; + +pub struct GradleLsServer { + pub gradle_ls: GradleLs, + pub bridge: GradleBridge, +} + +impl GradleLsServer { + pub fn new() -> Self { + Self { + gradle_ls: GradleLs::new(), + bridge: GradleBridge::new(), + } + } +} + +impl LanguageServer for GradleLsServer { + const SERVER_ID: &'static str = "gradle-language-server"; + + fn command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result { + let configuration = LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.settings); + + let current_dir = + env::current_dir().map_err(|err| format!("Failed to get current directory: {err}"))?; + + let gradle_ls_path = self + .gradle_ls + .get_or_download(language_server_id, &configuration, worktree) + .map_err(|err| format!("Failed to get Gradle Language Server: {err}"))?; + + let lib_path = current_dir.join(&gradle_ls_path).join("lib"); + let classpath = build_classpath(&lib_path)?; + + let bridge_path = self + .bridge + .binary_path(&configuration, language_server_id, worktree) + .map_err(|err| format!("Failed to get gradle-lsp-bridge binary path: {err}"))?; + + let java_executable = get_java_executable(&configuration, worktree, language_server_id) + .map_err(|err| format!("Failed to locate Java executable: {err}"))?; + + let java_home = get_java_home(&configuration, worktree); + + let mut env = Vec::new(); + if let Some(java_home) = &java_home { + env.push(("JAVA_HOME".to_string(), java_home.clone())); + } + + // Forward Gradle distribution settings to the bridge (read from the + // process environment, threaded into the gRPC GradleConfig the bridge + // sends to gradle-server). Mirrors the knobs the VS Code gradle-server + // applies to its Tooling API connection. Sourced from the LSP `settings` + // block (the single config source); init options are left empty. + env.extend(gradle_config_env(&configuration, java_home.as_deref())); + + let java_path = path_to_string(&java_executable) + .map_err(|err| format!("Failed to convert Java path: {err}"))?; + + Ok(zed::Command { + command: bridge_path, + args: vec![ + java_path, + "-cp".to_string(), + classpath, + "com.microsoft.gradle.GradleLanguageServer".to_string(), + ], + env, + }) + } + + fn initialization_options( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result> { + // Mirror the JDTLS server: forward the user's `initialization_options` + // verbatim, defaulting to an empty object when none are configured. The + // Gradle distribution config the bridge needs is read separately from + // the `settings` block (see `command`/`gradle_config_env`), so for now + // init options are typically empty. An empty object — not null — also + // avoids the language server's NPE on a null `initializationOptions`. + let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) + .map_err(|err| format!("Failed to get LSP settings: {err}"))? + .unwrap_or_else(|| json!({})); + + Ok(Some(options)) + } + + fn workspace_configuration( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result> { + // Return the `settings` block as-is. Unlike the JDTLS server, there is no + // fallback to the init options' `settings` — that fallback exists only + // for JDTLS for historical reasons; the Gradle server has no such legacy + // and its init options are kept empty. + Ok(LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.settings)) + } +} + +/// Build the environment that conveys Gradle distribution settings to the +/// `gradle-lsp-bridge`. The keys mirror the language server's own settings +/// schema (`gradleUserHome`, `gradleVersion`, `gradleWrapperEnabled`, +/// `gradleHome`), read from the LSP `settings` block, and are mapped to the +/// `GRADLE_SYNC_*` variables the bridge reads into the gRPC `GradleConfig` it +/// sends to `gradle-server`. `gradle_jvm_arguments` (a string) and the resolved +/// JDK home are also forwarded if present. +fn gradle_config_env( + configuration: &Option, + java_home: Option<&str>, +) -> Vec<(String, String)> { + let mut env = Vec::new(); + + // The JDK the bridge should ask gradle-server to build with. Threaded into + // the gRPC GradleConfig's java_home; mirrors VS Code passing VSCODE_JAVA_HOME. + if let Some(java_home) = java_home + && !java_home.is_empty() + { + env.push(("GRADLE_SYNC_JAVA_HOME".to_string(), java_home.to_string())); + } + + let Some(settings) = configuration else { + return env; + }; + + let mut push_str = |key: &str, var: &str| { + if let Some(value) = settings.get(key).and_then(|v| v.as_str()) + && !value.is_empty() + { + env.push((var.to_string(), value.to_string())); + } + }; + + push_str("gradleUserHome", "GRADLE_SYNC_USER_HOME"); + push_str("gradleVersion", "GRADLE_SYNC_VERSION"); + push_str("gradleHome", "GRADLE_SYNC_GRADLE_HOME"); + push_str("gradle_jvm_arguments", "GRADLE_SYNC_JVM_ARGS"); + + // Only forward the wrapper flag when explicitly disabled; the bridge treats + // its absence as "wrapper enabled" (the default). + if settings + .get("gradleWrapperEnabled") + .and_then(|v| v.as_bool()) + == Some(false) + { + env.push(( + "GRADLE_SYNC_WRAPPER_ENABLED".to_string(), + "false".to_string(), + )); + } + + env +} + +fn build_classpath(lib_path: &std::path::Path) -> zed::Result { + let separator = match current_platform().0 { + Os::Windows => ";", + _ => ":", + }; + + let entries: Vec = fs::read_dir(lib_path) + .map_err(|err| format!("Failed to read lib directory {}: {err}", lib_path.display()))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "jar") + }) + .map(|path| path.to_string_lossy().to_string()) + .collect(); + + if entries.is_empty() { + return Err(format!("No JAR files found in {}", lib_path.display())); + } + + Ok(entries.join(separator)) +} diff --git a/src/java.rs b/src/java.rs index fa28272..3b1b259 100644 --- a/src/java.rs +++ b/src/java.rs @@ -1,6 +1,9 @@ mod config; mod debugger; mod downloadable; +mod gradle_bridge; +mod gradle_ls; +mod gradle_ls_server; mod jdk; mod jdtls; mod jdtls_server; @@ -21,13 +24,15 @@ use zed_extension_api::{ }; use crate::{ - downloadable::Downloadable, jdtls_server::JdtlsServer, language_server::LanguageServer, + downloadable::Downloadable, gradle_ls_server::GradleLsServer, jdtls_server::JdtlsServer, + language_server::LanguageServer, }; const DEBUG_ADAPTER_NAME: &str = "Java"; struct Java { jdtls_server: JdtlsServer, + gradle_ls_server: GradleLsServer, } impl Extension for Java { @@ -37,6 +42,7 @@ impl Extension for Java { { Self { jdtls_server: JdtlsServer::new(), + gradle_ls_server: GradleLsServer::new(), } } @@ -47,6 +53,9 @@ impl Extension for Java { ) -> zed::Result { match language_server_id.as_ref() { JdtlsServer::SERVER_ID => self.jdtls_server.command(language_server_id, worktree), + GradleLsServer::SERVER_ID => { + self.gradle_ls_server.command(language_server_id, worktree) + } id => Err(format!("Unknown language server: {id}")), } } @@ -60,6 +69,9 @@ impl Extension for Java { JdtlsServer::SERVER_ID => self .jdtls_server .initialization_options(language_server_id, worktree), + GradleLsServer::SERVER_ID => self + .gradle_ls_server + .initialization_options(language_server_id, worktree), _ => Ok(None), } } @@ -73,6 +85,9 @@ impl Extension for Java { JdtlsServer::SERVER_ID => self .jdtls_server .workspace_configuration(language_server_id, worktree), + GradleLsServer::SERVER_ID => self + .gradle_ls_server + .workspace_configuration(language_server_id, worktree), _ => Ok(None), } } From 43c303e442bb3ca9c3284b1ebc97eda2e7c15797 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 17:49:02 +0200 Subject: [PATCH 02/14] Add Gradle KTS support --- Cargo.toml | 3 - README.md | 13 +- extension.toml | 8 +- gradle-bridge/src/channel.rs | 13 ++ gradle-bridge/src/transport.rs | 11 + languages/gradle-kts/brackets.scm | 13 ++ languages/gradle-kts/config.toml | 13 ++ languages/gradle-kts/highlights.scm | 312 ++++++++++++++++++++++++++++ languages/gradle-kts/indents.scm | 7 + languages/gradle-kts/injections.scm | 5 + languages/gradle-kts/outline.scm | 50 +++++ languages/gradle-kts/overrides.scm | 6 + 12 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 languages/gradle-kts/brackets.scm create mode 100644 languages/gradle-kts/config.toml create mode 100644 languages/gradle-kts/highlights.scm create mode 100644 languages/gradle-kts/indents.scm create mode 100644 languages/gradle-kts/injections.scm create mode 100644 languages/gradle-kts/outline.scm create mode 100644 languages/gradle-kts/overrides.scm diff --git a/Cargo.toml b/Cargo.toml index 4663480..40f1302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,6 @@ members = [ "proxy-common", "gradle-bridge" ] -exclude = [ - "grammars/groovy" -] [workspace.dependencies] proxy-common = { path = "proxy-common" } diff --git a/README.md b/README.md index 2aefc4f..df3ada2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This extension adds support for Java and `.properties` files to [Zed](https://zed.dev). It uses the [Eclipse JDT Language Server](https://projects.eclipse.org/projects/eclipse.jdt.ls) (JDTLS for short) to provide completions, code-actions and diagnostics. -It also provides intelligence for Gradle build files (`.gradle` / `.gradle.kts`) via Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle) — plugin-aware completions, closures, and build-script diagnostics. See [Gradle Build Files](#gradle-build-files) below. +It also provides intelligence for Gradle build files via Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle): plugin-aware completions, closures, and diagnostics for Groovy `.gradle` scripts, plus highlighting and build-evaluation diagnostics for Kotlin `.gradle.kts` scripts. See [Gradle Build Files](#gradle-build-files) below. ## Quick Start @@ -61,10 +61,19 @@ Here is a common `settings.json` including the above mentioned configurations: ## Gradle Build Files -For `.gradle` and `.gradle.kts` files the extension runs Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle), giving you completions for Gradle DSL closures, plugin-contributed blocks (e.g. `java {}`, `application {}`), Maven Central dependency coordinates, and diagnostics for build-script errors. +For **Groovy** build scripts (`.gradle`) the extension runs Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle), giving you completions for Gradle DSL closures, plugin-contributed blocks (e.g. `java {}`, `application {}`), Maven Central dependency coordinates, and syntax diagnostics. To resolve the *plugin-aware* parts of the model (which plugins are applied, the closures/methods they contribute, and the script classpath), the language server needs the resolved build model. The extension obtains this exactly the way the VS Code Gradle extension does: it drives the bundled `gradle-server` over gRPC via a small native binary, `gradle-lsp-bridge`. The bridge keeps a single `gradle-server` process (and its Gradle daemon) warm for the lifetime of the session, so re-syncs after a build-file save are fast. Both the language server and the bridge are downloaded and managed automatically — no configuration is required. +### Kotlin DSL (`.gradle.kts`) + +The Gradle Language Server itself is Groovy-only, so it does **not** provide completions or semantic tokens for Kotlin-DSL build scripts. For `.gradle.kts` files the extension instead provides: + +- **Syntax highlighting** via the bundled Kotlin grammar. +- **Build-evaluation diagnostics** — when `gradle-server` configures the project, Gradle's own Kotlin-DSL compiler reports errors (unresolved references, invalid dependency notations, etc.). The bridge surfaces these as squiggles on the build file with the correct line. + +(The Groovy language server's own — invalid — diagnostics for these files are suppressed, since it cannot parse Kotlin.) + Configuration, when you need it, goes under the `gradle-language-server` language server in your `settings.json` (note: this is a **different** block from `jdtls`): ```jsonc diff --git a/extension.toml b/extension.toml index 262e12d..0ce88f1 100644 --- a/extension.toml +++ b/extension.toml @@ -20,13 +20,19 @@ commit = "579b62f5ad8d96c2bb331f07d1408c92767531d9" repository = "https://github.com/murtaza64/tree-sitter-groovy" commit = "deb0dcf8c4544f07564060f6e9b9f6e4b0bfc27d" +# Kotlin grammar for `*.gradle.kts` build scripts (the same grammar the Kotlin +# extension uses). Bundled so KTS highlighting works without that extension. +[grammars.kotlin] +repository = "https://github.com/fwcd/tree-sitter-kotlin" +commit = "4e909d6cc9ac96b4eaecb3fb538eaca48e9e9ee9" + [language_servers.jdtls] name = "Eclipse JDT Language Server" language = "Java" [language_servers.gradle-language-server] name = "Gradle Language Server" -language = "Gradle" +languages = ["Gradle", "Gradle KTS"] [debug_adapters.Java] diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs index 112183a..70446de 100644 --- a/gradle-bridge/src/channel.rs +++ b/gradle-bridge/src/channel.rs @@ -362,6 +362,19 @@ mod tests { assert_eq!(parse_line_column(msg), Some((11, 0))); } + #[test] + fn parses_kotlin_dsl_build_failure() { + // The shape gradle-server emits for a Kotlin-DSL script error, captured + // from its stderr: capital "Build file '…'" with a `line: N` marker. + let msg = "FAILURE: Build failed with an exception.\n* Where:\nBuild file '/Users/me/proj/build.gradle.kts' line: 4\n* What went wrong:\nScript compilation error:\n Line 4: adewdw\n ^ Unresolved reference 'adewdw'."; + assert_eq!( + parse_build_file_path(msg).as_deref(), + Some("/Users/me/proj/build.gradle.kts") + ); + // 1-based "line: 4" -> 0-based line 4 -> 3. + assert_eq!(parse_line_column(msg), Some((3, 0))); + } + #[test] fn no_location_for_methodless_errors() { let msg = diff --git a/gradle-bridge/src/transport.rs b/gradle-bridge/src/transport.rs index 7cf66b7..1a7f3f0 100644 --- a/gradle-bridge/src/transport.rs +++ b/gradle-bridge/src/transport.rs @@ -109,6 +109,17 @@ pub async fn pump_ls_to_editor( continue; } if let Some((uri, diagnostics)) = parse_publish_diagnostics(&raw) { + // The language server parses every file as Groovy, but Kotlin-DSL + // build scripts (`*.gradle.kts`) are not Groovy — so its diagnostics + // for those are spurious syntax errors. Drop them (publish an empty + // set) while still letting the build-eval diagnostics from the + // gradle-server sync show through the merge. Groovy `.gradle` files + // keep their real syntax diagnostics. + let diagnostics = if uri.ends_with(".gradle.kts") { + Vec::new() + } else { + diagnostics + }; channel.set_server_diagnostics(uri, diagnostics).await; continue; } diff --git a/languages/gradle-kts/brackets.scm b/languages/gradle-kts/brackets.scm new file mode 100644 index 0000000..63c331d --- /dev/null +++ b/languages/gradle-kts/brackets.scm @@ -0,0 +1,13 @@ +("(" @open + ")" @close) + +("[" @open + "]" @close) + +("{" @open + "}" @close) + +("<" @open + ">" @close) + +; ("\"" @open "\"" @close) FIXME: `Invalid node type`. This line exists in the `brackets.scm` for other languages, but errors here. diff --git a/languages/gradle-kts/config.toml b/languages/gradle-kts/config.toml new file mode 100644 index 0000000..f4b2ff0 --- /dev/null +++ b/languages/gradle-kts/config.toml @@ -0,0 +1,13 @@ +name = "Gradle KTS" +grammar = "kotlin" +path_suffixes = ["gradle.kts"] +line_comments = ["// "] +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = false }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "'", end = "'", close = true, newline = false, not_in = ["string"] }, + { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] }, +] +block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 0 } diff --git a/languages/gradle-kts/highlights.scm b/languages/gradle-kts/highlights.scm new file mode 100644 index 0000000..aaa3feb --- /dev/null +++ b/languages/gradle-kts/highlights.scm @@ -0,0 +1,312 @@ +; Based on the nvim-treesitter highlighting, which is under the Apache license. +; See https://github.com/nvim-treesitter/nvim-treesitter/blob/f8ab59861eed4a1c168505e3433462ed800f2bae/queries/kotlin/highlights.scm +; +; The only difference in this file is that queries using #lua-match? +; have been removed. +; Identifiers +(simple_identifier) @variable + +; `it` keyword inside lambdas +; FIXME: This will highlight the keyword outside of lambdas since tree-sitter +; does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin + (#eq? @variable.builtin "it")) + +; `field` keyword inside property getter/setter +; FIXME: This will highlight the keyword outside of getters and setters +; since tree-sitter does not allow us to check for arbitrary nestation +((simple_identifier) @variable.builtin + (#eq? @variable.builtin "field")) + +; `this` this keyword inside classes +(this_expression) @variable.builtin + +; `super` keyword inside classes +(super_expression) @variable.builtin + +(class_parameter + (simple_identifier) @property) + +(class_body + (property_declaration + (variable_declaration + (simple_identifier) @property))) + +; id_1.id_2.id_3: `id_2` and `id_3` are assumed as object properties +(_ + (navigation_suffix + (simple_identifier) @property)) + +(enum_entry + (simple_identifier) @constant) + +(type_identifier) @type + +((type_identifier) @type.builtin + (#any-of? @type.builtin + "Byte" "Short" "Int" "Long" "UByte" "UShort" "UInt" "ULong" "Float" "Double" "Boolean" "Char" + "String" "Array" "ByteArray" "ShortArray" "IntArray" "LongArray" "UByteArray" "UShortArray" + "UIntArray" "ULongArray" "FloatArray" "DoubleArray" "BooleanArray" "CharArray" "Map" "Set" + "List" "EmptyMap" "EmptySet" "EmptyList" "MutableMap" "MutableSet" "MutableList")) + +(package_header + . + (identifier)) @namespace + +(import_header + "import" @include) + +; TODO: Seperate labeled returns/breaks/continue/super/this +; Must be implemented in the parser first +(label) @label + +; Function definitions +(function_declaration + (simple_identifier) @function) + +(getter + "get" @function.builtin) + +(setter + "set" @function.builtin) + +(primary_constructor) @constructor + +(secondary_constructor + "constructor" @constructor) + +(constructor_invocation + (user_type + (type_identifier) @constructor)) + +(anonymous_initializer + "init" @constructor) + +(parameter + (simple_identifier) @parameter) + +(parameter_with_optional_type + (simple_identifier) @parameter) + +; lambda parameters +(lambda_literal + (lambda_parameters + (variable_declaration + (simple_identifier) @parameter))) + +; Function calls +; function() +(call_expression + (simple_identifier) @function) + +; object.function() or object.property.function() +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @function))) + +(call_expression + (simple_identifier) @function.builtin + (#any-of? @function.builtin + "arrayOf" "arrayOfNulls" "byteArrayOf" "shortArrayOf" "intArrayOf" "longArrayOf" "ubyteArrayOf" + "ushortArrayOf" "uintArrayOf" "ulongArrayOf" "floatArrayOf" "doubleArrayOf" "booleanArrayOf" + "charArrayOf" "emptyArray" "mapOf" "setOf" "listOf" "emptyMap" "emptySet" "emptyList" + "mutableMapOf" "mutableSetOf" "mutableListOf" "print" "println" "error" "TODO" "run" + "runCatching" "repeat" "lazy" "lazyOf" "enumValues" "enumValueOf" "assert" "check" + "checkNotNull" "require" "requireNotNull" "with" "suspend" "synchronized")) + +; Literals +[ + (line_comment) + (multiline_comment) + (shebang_line) +] @comment + +(real_literal) @float + +[ + (integer_literal) + (long_literal) + (hex_literal) + (bin_literal) + (unsigned_literal) +] @number + +[ + "null" ; should be highlighted the same as booleans + (boolean_literal) +] @boolean + +(character_literal) @character + +(string_literal) @string + +(character_escape_seq) @string.escape + +; There are 3 ways to define a regex +; - "[abc]?".toRegex() +(call_expression + (navigation_expression + (string_literal) @string.regex + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "toRegex"))))) + +; - Regex("[abc]?") +(call_expression + ((simple_identifier) @_function + (#eq? @_function "Regex")) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +; - Regex.fromLiteral("[abc]?") +(call_expression + (navigation_expression + ((simple_identifier) @_class + (#eq? @_class "Regex")) + (navigation_suffix + ((simple_identifier) @_function + (#eq? @_function "fromLiteral")))) + (call_suffix + (value_arguments + (value_argument + (string_literal) @string.regex)))) + +; Keywords +(type_alias + "typealias" @keyword) + +[ + (class_modifier) + (member_modifier) + (function_modifier) + (property_modifier) + (platform_modifier) + (variance_modifier) + (parameter_modifier) + (visibility_modifier) + (reification_modifier) + (inheritance_modifier) +] @keyword + +[ + "if" + "else" + "when" + "for" + "while" + "do" + "try" + "catch" + "throw" + "finally" + "val" + "var" + "enum" + "class" + "object" + "interface" + "companion" + "package" + "import" + ; "typeof" ; NOTE: It is reserved for future use +] @keyword + +"fun" @keyword.function + +(jump_expression) @keyword.return + +(annotation + "@" @attribute + (use_site_target)? @attribute) + +(annotation + (user_type + (type_identifier) @attribute)) + +(annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +(file_annotation + "@" @attribute + "file" @attribute + ":" @attribute) + +(file_annotation + (user_type + (type_identifier) @attribute)) + +(file_annotation + (constructor_invocation + (user_type + (type_identifier) @attribute))) + +; Operators & Punctuation +[ + "!" + "!=" + "!==" + "=" + "==" + "===" + ">" + ">=" + "<" + "<=" + "||" + "&&" + "+" + "++" + "+=" + "-" + "--" + "-=" + "*" + "*=" + "/" + "/=" + "%" + "%=" + "?." + "?:" + "!!" + "is" + "!is" + "in" + "!in" + "as" + "as?" + ".." + "->" +] @operator + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "." + "," + ";" + ":" + "::" +] @punctuation.delimiter + +; NOTE: `interpolated_identifier`s can be highlighted in any way +(string_literal + "$" @punctuation.special + (interpolated_identifier) @none) + +(string_literal + "${" @punctuation.special + (interpolated_expression) @none + "}" @punctuation.special) diff --git a/languages/gradle-kts/indents.scm b/languages/gradle-kts/indents.scm new file mode 100644 index 0000000..853b2b1 --- /dev/null +++ b/languages/gradle-kts/indents.scm @@ -0,0 +1,7 @@ +(_ + "{" + "}" @end) @indent + +(_ + "(" + ")" @end) @indent diff --git a/languages/gradle-kts/injections.scm b/languages/gradle-kts/injections.scm new file mode 100644 index 0000000..b1635f5 --- /dev/null +++ b/languages/gradle-kts/injections.scm @@ -0,0 +1,5 @@ +([ + (line_comment) + (multiline_comment) +] @content + (#set! "language" "comment")) diff --git a/languages/gradle-kts/outline.scm b/languages/gradle-kts/outline.scm new file mode 100644 index 0000000..62ec6ef --- /dev/null +++ b/languages/gradle-kts/outline.scm @@ -0,0 +1,50 @@ +(package_header + "package" @context + (identifier) @name) @item + +(class_declaration + (modifiers)? @context + (type_identifier) @name) @item + +(object_declaration + "object" @context + (type_identifier) @name) @item + +(type_alias + "typealias" @context + (type_identifier) @name) @item + +(enum_entry + (simple_identifier) @name) @item + +(function_declaration + "fun" @context + (simple_identifier) @name) @item + +(property_declaration + [ + "val" + "var" + ] @context + (variable_declaration + (simple_identifier) @name)) @item + +(property_declaration + [ + "val" + "var" + ] @context + (multi_variable_declaration + (variable_declaration + (simple_identifier) @name) @item)) + +(companion_object + "companion" @context + "object" @context + (type_identifier)? @name) @item + +(secondary_constructor + "constructor" @name) @item + +(anonymous_initializer + "init" @name) @item diff --git a/languages/gradle-kts/overrides.scm b/languages/gradle-kts/overrides.scm new file mode 100644 index 0000000..ece5bb4 --- /dev/null +++ b/languages/gradle-kts/overrides.scm @@ -0,0 +1,6 @@ +[ + (line_comment) + (multiline_comment) +] @comment.inclusive + +(string_literal) @string From 0af16f63154bb6233374f20edf84c94af6663be9 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 18:33:34 +0200 Subject: [PATCH 03/14] Lint and deduplicate code --- gradle-bridge/src/channel.rs | 56 +++++++++++++++------------------ gradle-bridge/src/grpc.rs | 36 +++++++++++++-------- gradle-bridge/src/transport.rs | 17 +++++----- languages/gradle/highlights.scm | 2 +- proxy-common/src/lib.rs | 3 ++ proxy-common/src/uri.rs | 21 +++++++++++++ proxy/src/decompile.rs | 22 ++----------- proxy/src/main.rs | 4 ++- src/gradle_bridge.rs | 50 +++++++++++++---------------- src/gradle_ls_server.rs | 8 +++-- src/proxy.rs | 32 +++++++++++-------- 11 files changed, 131 insertions(+), 120 deletions(-) create mode 100644 proxy-common/src/uri.rs diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs index 70446de..f36dcce 100644 --- a/gradle-bridge/src/channel.rs +++ b/gradle-bridge/src/channel.rs @@ -7,12 +7,13 @@ //! the editor over tokio's stdout. use std::collections::HashMap; +use std::path::Path; use serde_json::Value; use tokio::io::{AsyncWriteExt, Stdout}; use tokio::sync::Mutex; -use proxy_common::{encode_lsp, parse_lsp_content}; +use proxy_common::{encode_lsp, parse_lsp_content, path_to_file_uri}; /// Prefix for the JSON-RPC `id` of requests the bridge injects into the language /// server (the build-model sync commands). Responses carry the same id, letting @@ -40,27 +41,26 @@ pub fn is_gradle_build_file_save(raw: &[u8]) -> bool { contains_subslice(body, b"\"textDocument/didSave\"") && contains_subslice(body, b".gradle") } -/// Detect a `textDocument/didSave` for `gradle-wrapper.properties`, which -/// declares the Gradle distribution version. -pub fn is_wrapper_properties_save(raw: &[u8]) -> bool { - let Some(body) = lsp_body(raw) else { - return false; - }; - contains_subslice(body, b"\"textDocument/didSave\"") - && contains_subslice(body, b"gradle-wrapper.properties") -} - /// Whether a raw LSP message is a response to one of the bridge's injected -/// requests, identified by an `"id":"gradle-sync-…"` field. Such responses must -/// not reach the editor, which never sent the corresponding request. +/// requests, identified by a string `id` beginning with [`INJECTED_ID_PREFIX`]. +/// Such responses must not reach the editor, which never sent the corresponding +/// request. +/// +/// A cheap byte pre-check (does the body even mention the prefix?) gates a +/// proper JSON parse of the `id` field, so the common case — the vast majority +/// of messages, which don't contain the prefix at all — stays allocation-free, +/// while matches are confirmed without depending on the server's exact +/// whitespace around `"id":` (compact today, but not guaranteed). pub fn is_injected_response(raw: &[u8]) -> bool { let Some(body) = lsp_body(raw) else { return false; }; - let mut needle = Vec::with_capacity(INJECTED_ID_PREFIX.len() + 6); - needle.extend_from_slice(b"\"id\":\""); - needle.extend_from_slice(INJECTED_ID_PREFIX.as_bytes()); - contains_subslice(body, &needle) + if !contains_subslice(body, INJECTED_ID_PREFIX.as_bytes()) { + return false; + } + parse_lsp_content(raw) + .and_then(|msg| msg.get("id")?.as_str().map(str::to_string)) + .is_some_and(|id| id.starts_with(INJECTED_ID_PREFIX)) } /// The JSON body of a raw LSP message (everything after the `\r\n\r\n` header @@ -125,7 +125,7 @@ pub fn build_eval_diagnostics( "message": message }); - let uri = path_to_file_uri(path); + let uri = path_to_file_uri(Path::new(path)); let mut map = HashMap::new(); map.insert(uri, vec![diagnostic]); map @@ -171,16 +171,6 @@ fn take_u64(s: &str) -> Option { digits.parse().ok() } -/// Convert a filesystem path to a `file://` URI, matching how the language -/// server's `publishDiagnostics` and the editor key documents. -pub fn path_to_file_uri(path: &str) -> String { - if cfg!(windows) { - format!("file:///{}", path.replace('\\', "/")) - } else { - format!("file://{path}") - } -} - /// Owns the single byte stream to the editor (the bridge's stdout) and the /// diagnostics merge state. /// @@ -281,6 +271,11 @@ mod tests { fn detects_injected_response_with_string_id() { let raw = frame(r#"{"jsonrpc":"2.0","id":"gradle-sync-0","result":null}"#); assert!(is_injected_response(&raw)); + + // Tolerant of whitespace around the colon (compact today, but the parse + // path must not depend on it). + let spaced = frame(r#"{"jsonrpc": "2.0", "id": "gradle-sync-7", "result": null}"#); + assert!(is_injected_response(&spaced)); } #[test] @@ -319,11 +314,12 @@ mod tests { } #[test] - fn detects_wrapper_properties_save() { + fn wrapper_properties_save_is_not_a_build_file_save() { + // `gradle-wrapper.properties` is a `.properties` file, not a build + // script, so it must not trigger a build-model re-sync. let save = frame( r#"{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///p/gradle/wrapper/gradle-wrapper.properties"}}}"#, ); - assert!(is_wrapper_properties_save(&save)); assert!(!is_gradle_build_file_save(&save)); } diff --git a/gradle-bridge/src/grpc.rs b/gradle-bridge/src/grpc.rs index 3d41cec..ddd2467 100644 --- a/gradle-bridge/src/grpc.rs +++ b/gradle-bridge/src/grpc.rs @@ -25,10 +25,15 @@ use tokio::time::sleep; use tonic::transport::{Channel, Endpoint}; use crate::proto::gradle::{ - gradle_client::GradleClient, get_build_reply::Kind, output::OutputType, GetBuildRequest, + get_build_reply::Kind, gradle_client::GradleClient, output::OutputType, GetBuildRequest, GradleConfig, GradleProject, }; +/// The `java_extension_version` reported to `gradle-server` in `GradleConfig`. +/// It only has to clear the server's `isAtLeast("1.3.0")` compatibility gate; +/// we report the vscode-gradle line we drive the protocol against. +const JAVA_EXTENSION_VERSION: &str = "3.17.3"; + /// The Gradle distribution configuration forwarded by the extension via /// environment variables and threaded into the gRPC `GradleConfig`. Mirrors the /// knobs the VS Code `gradle-server` honors. @@ -67,9 +72,12 @@ impl DistributionConfig { jvm_arguments: self.jvm_arguments.clone(), wrapper_enabled: self.wrapper_enabled, version: self.version.clone(), - // The Gradle Language Server checks the extension version against a - // minimum; the value is otherwise opaque, so report a recent one. - java_extension_version: String::new(), + // gradle-server runs a compatibility gate as `new Version(value) + // .isAtLeast("1.3.0")` (verified in GetBuildHandler), and an empty + // string would construct an invalid Version. Report a value at/above + // that floor, mirroring the real vscode-gradle extension which sends + // its own version here. + java_extension_version: JAVA_EXTENSION_VERSION.to_string(), java_home: self.java_home.clone(), } } @@ -84,7 +92,10 @@ fn env_or_empty(key: &str) -> String { pub enum BuildOutcome { Model(GradleProject), /// `(error, causes)` — already flattened from the gRPC status / reply. - Error { error: String, causes: Vec }, + Error { + error: String, + causes: Vec, + }, } /// Manages the long-lived `gradle-server` process and gRPC channel. Cloneable @@ -182,11 +193,10 @@ impl GradleServer { causes: stderr_causes(&stderr), }; } - Some(Kind::Output(output)) => { - if output.output_type == OutputType::Stderr as i32 { + Some(Kind::Output(output)) + if output.output_type == OutputType::Stderr as i32 => { stderr.push_str(&String::from_utf8_lossy(&output.output_bytes)); } - } // Progress/Environment/Cancelled are informational. _ => {} }, @@ -253,7 +263,10 @@ impl GradleServer { let port = free_port().await?; let child = self.spawn_server(port)?; let channel = connect_with_retry(port).await?; - state.running = Some(RunningServer { child, channel: channel.clone() }); + state.running = Some(RunningServer { + child, + channel: channel.clone(), + }); Ok(channel) } @@ -367,10 +380,7 @@ fn collect_commands(project: &GradleProject, out: &mut Vec<(&'static str, Value) let project_path = normalize_project_path(&project.project_path); // gradle.setPlugins [projectPath, plugins[]] - out.push(( - "gradle.setPlugins", - json!([project_path, project.plugins]), - )); + out.push(("gradle.setPlugins", json!([project_path, project.plugins]))); // gradle.setClosures [projectPath, closures[]] let closures: Vec = project diff --git a/gradle-bridge/src/transport.rs b/gradle-bridge/src/transport.rs index 1a7f3f0..4357c8a 100644 --- a/gradle-bridge/src/transport.rs +++ b/gradle-bridge/src/transport.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex; use crate::channel::{ is_gradle_build_file_save, is_initialized_notification, is_injected_response, - is_wrapper_properties_save, parse_publish_diagnostics, EditorChannel, + parse_publish_diagnostics, EditorChannel, }; use crate::sync::SyncScheduler; @@ -99,10 +99,7 @@ impl AsyncLspReader { /// /// All other messages are forwarded verbatim. Runs until the server closes the /// connection. -pub async fn pump_ls_to_editor( - ls_reader: R, - channel: Arc, -) { +pub async fn pump_ls_to_editor(ls_reader: R, channel: Arc) { let mut reader = AsyncLspReader::new(ls_reader); while let Ok(Some(raw)) = reader.read_message().await { if is_injected_response(&raw) { @@ -147,15 +144,15 @@ pub async fn pump_editor_to_ls( ls_writer.send(raw.clone()).await; // Initial sync once the server is ready, then re-sync on every save of a - // Gradle build file or the wrapper properties — the build model - // (plugins, closures, classpaths) can change and the language server - // otherwise keeps stale completions. + // Gradle build file — the build model (plugins, closures, classpaths) + // can change and the language server otherwise keeps stale completions. + // (We don't watch `gradle-wrapper.properties`: it belongs to the + // Properties language, so the editor never routes its saves to us.) let should_sync = if !initialized_sent && is_initialized_notification(&raw) { initialized_sent = true; true } else { - initialized_sent - && (is_gradle_build_file_save(&raw) || is_wrapper_properties_save(&raw)) + initialized_sent && is_gradle_build_file_save(&raw) }; if should_sync { diff --git a/languages/gradle/highlights.scm b/languages/gradle/highlights.scm index 1fd272f..d3d2747 100644 --- a/languages/gradle/highlights.scm +++ b/languages/gradle/highlights.scm @@ -66,7 +66,7 @@ (string (interpolation - ("$") @operator)) + "$" @operator)) "(" @punctuation.bracket diff --git a/proxy-common/src/lib.rs b/proxy-common/src/lib.rs index d281d15..264eb1f 100644 --- a/proxy-common/src/lib.rs +++ b/proxy-common/src/lib.rs @@ -5,12 +5,15 @@ //! encoding helpers. //! - [`platform`]: a parent-process monitor that terminates the spawned child //! when the editor that launched us goes away. +//! - [`uri`]: filesystem-path-to-`file://`-URI conversion. pub mod lsp; pub mod platform; +pub mod uri; pub use lsp::{ encode_lsp, parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader, CONTENT_LENGTH, HEADER_SEP, }; pub use platform::spawn_parent_monitor; +pub use uri::path_to_file_uri; diff --git a/proxy-common/src/uri.rs b/proxy-common/src/uri.rs new file mode 100644 index 0000000..ffa0864 --- /dev/null +++ b/proxy-common/src/uri.rs @@ -0,0 +1,21 @@ +use std::path::Path; + +/// Convert a filesystem path to a `file://` URI, matching how language servers' +/// `publishDiagnostics` and the editor key documents. +/// +/// On Unix the path already starts with `/`, so `file://` + path gives the +/// correct `file:///…` form with no extra work. +/// +/// On Windows the backslashes are replaced with `/` and an extra `/` is +/// prepended before the drive letter, so we get `file:///C:/…` rather than +/// `file://C:\…`. +#[cfg(unix)] +pub fn path_to_file_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +#[cfg(windows)] +pub fn path_to_file_uri(path: &Path) -> String { + let s = path.display().to_string().replace('\\', "/"); + format!("file:///{s}") +} diff --git a/proxy/src/decompile.rs b/proxy/src/decompile.rs index b89e435..f8e079c 100644 --- a/proxy/src/decompile.rs +++ b/proxy/src/decompile.rs @@ -4,33 +4,15 @@ use std::{ env, fs, hash::{Hash, Hasher}, io::Write, - path::{Path, PathBuf}, + path::PathBuf, sync::{mpsc, Arc, Mutex}, }; use crate::{lsp_error, lsp_warn}; -use proxy_common::encode_lsp; +use proxy_common::{encode_lsp, path_to_file_uri}; const DECOMPILED_DIR: &str = "jdtls-decompiled"; -/// Convert a `PathBuf` to a proper `file://` URI. -/// -/// On Unix the path already starts with `/`, so `file://` + path gives us -/// the correct `file:///…` form with no extra work. -/// -/// On Windows we must replace `\` with `/` and prepend `file:///` before the -/// drive letter so that we get `file:///C:/…` instead of `file://C:\…`. -#[cfg(unix)] -fn path_to_file_uri(path: &Path) -> String { - format!("file://{}", path.display()) -} - -#[cfg(windows)] -fn path_to_file_uri(path: &Path) -> String { - let s = path.display().to_string().replace('\\', "/"); - format!("file:///{s}") -} - fn cache_dir() -> PathBuf { env::temp_dir().join(DECOMPILED_DIR) } diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 55fd5d2..1022cfe 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -6,7 +6,9 @@ mod log; use completions::{is_completion_response, process_completions, sanitize_resolved_completion}; use decompile::{rewrite_jdt_in_strings, rewrite_jdt_locations}; use http::handle_http; -use proxy_common::{parse_lsp_content, raw_has_id, spawn_parent_monitor, write_raw, write_to_stdout, LspReader}; +use proxy_common::{ + parse_lsp_content, raw_has_id, spawn_parent_monitor, write_raw, write_to_stdout, LspReader, +}; use serde_json::Value; use std::{ collections::HashMap, diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs index 044198d..cad03fa 100644 --- a/src/gradle_bridge.rs +++ b/src/gradle_bridge.rs @@ -7,7 +7,7 @@ use zed_extension_api::{ }; use crate::{ - config::{get_gradle_bridge_path, get_lsp_proxy_path}, + config::get_gradle_bridge_path, downloadable::Downloadable, util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}, }; @@ -137,19 +137,25 @@ impl Downloadable for GradleBridge { return Ok(PathBuf::from(path)); } - if let Some(path) = - should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) - .unwrap_or(None) - { - let s = path.to_string_lossy().to_string(); - self.cached_path = Some(s); - return Ok(path); - } - - if let Ok(version) = self.fetch_latest_version() - && let Ok(path) = self.download(&version, language_server_id) - { - return Ok(path); + // Respect the `check_updates` policy: + // Ok(Some) — use the local install, + // Ok(None) — policy allows a download (fall through), + // Err — Never / Once-exhausted with no local install: do NOT + // download; fall through to the PATH lookup as a last resort. + match should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) { + Ok(Some(path)) => { + let s = path.to_string_lossy().to_string(); + self.cached_path = Some(s); + return Ok(path); + } + Ok(None) => { + if let Ok(version) = self.fetch_latest_version() + && let Ok(path) = self.download(&version, language_server_id) + { + return Ok(path); + } + } + Err(_) => { /* policy forbids download; skip to PATH fallback */ } } if let Some(path) = worktree.which(bridge_exec().as_str()) { @@ -169,21 +175,7 @@ impl Downloadable for GradleBridge { return Some(path); } - // Otherwise, if the user points `lsp_proxy_path` at a local build, look - // for the bridge as a sibling — but only adopt it when it actually - // exists, so users running a custom proxy without a co-located bridge - // still fall through to the normal download path. - let proxy_path = get_lsp_proxy_path(configuration, worktree)?; - let p = PathBuf::from(&proxy_path); - let dir = if p.is_dir() { - p - } else { - p.parent().map(PathBuf::from).unwrap_or(p) - }; - let sibling = dir.join(bridge_exec()); - metadata(&sibling) - .is_ok_and(|m| m.is_file()) - .then(|| sibling.to_string_lossy().to_string()) + None } } diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs index 5a0c6bb..cd57331 100644 --- a/src/gradle_ls_server.rs +++ b/src/gradle_ls_server.rs @@ -117,9 +117,11 @@ impl LanguageServer for GradleLsServer { // fallback to the init options' `settings` — that fallback exists only // for JDTLS for historical reasons; the Gradle server has no such legacy // and its init options are kept empty. - Ok(LspSettings::for_worktree(language_server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings)) + Ok( + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.settings), + ) } } diff --git a/src/proxy.rs b/src/proxy.rs index be39108..01805e6 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -130,19 +130,25 @@ impl Downloadable for Proxy { return Ok(PathBuf::from(path)); } - if let Some(path) = - should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) - .unwrap_or(None) - { - let s = path.to_string_lossy().to_string(); - self.cached_path = Some(s); - return Ok(path); - } - - if let Ok(version) = self.fetch_latest_version() - && let Ok(path) = self.download(&version, language_server_id) - { - return Ok(path); + // Respect the `check_updates` policy: + // Ok(Some) — use the local install, + // Ok(None) — policy allows a download (fall through), + // Err — Never / Once-exhausted with no local install: do NOT + // download; fall through to the PATH lookup as a last resort. + match should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) { + Ok(Some(path)) => { + let s = path.to_string_lossy().to_string(); + self.cached_path = Some(s); + return Ok(path); + } + Ok(None) => { + if let Ok(version) = self.fetch_latest_version() + && let Ok(path) = self.download(&version, language_server_id) + { + return Ok(path); + } + } + Err(_) => { /* policy forbids download; skip to PATH fallback */ } } if let Some(path) = worktree.which(proxy_exec().as_str()) { From a555430e24c3f5d66c18216744ca4ac3f5982d01 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 19:36:40 +0200 Subject: [PATCH 04/14] Extract LSP utilities to proxy-common for reuse Move `parse_content_length`, `lsp_body`, and `contains_subslice` from gradle-bridge into proxy-common's lsp module so both the proxy and gradle-bridge can share the same LSP framing logic. Add comprehensive unit tests covering edge cases and frame parsing. --- gradle-bridge/src/channel.rs | 18 +----- gradle-bridge/src/transport.rs | 14 +---- proxy-common/src/lib.rs | 4 +- proxy-common/src/lsp.rs | 102 ++++++++++++++++++++++++++------- 4 files changed, 88 insertions(+), 50 deletions(-) diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs index f36dcce..3f38f2c 100644 --- a/gradle-bridge/src/channel.rs +++ b/gradle-bridge/src/channel.rs @@ -13,7 +13,9 @@ use serde_json::Value; use tokio::io::{AsyncWriteExt, Stdout}; use tokio::sync::Mutex; -use proxy_common::{encode_lsp, parse_lsp_content, path_to_file_uri}; +use proxy_common::{ + contains_subslice, encode_lsp, lsp_body, parse_lsp_content, path_to_file_uri, +}; /// Prefix for the JSON-RPC `id` of requests the bridge injects into the language /// server (the build-model sync commands). Responses carry the same id, letting @@ -63,20 +65,6 @@ pub fn is_injected_response(raw: &[u8]) -> bool { .is_some_and(|id| id.starts_with(INJECTED_ID_PREFIX)) } -/// The JSON body of a raw LSP message (everything after the `\r\n\r\n` header -/// separator), or `None` if the framing is absent. -fn lsp_body(raw: &[u8]) -> Option<&[u8]> { - let sep_pos = raw.windows(4).position(|w| w == b"\r\n\r\n")?; - Some(&raw[sep_pos + 4..]) -} - -pub fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { - if needle.is_empty() || haystack.len() < needle.len() { - return false; - } - haystack.windows(needle.len()).any(|w| w == needle) -} - /// If `raw` is a `textDocument/publishDiagnostics` notification, return its /// `(uri, diagnostics)`. Returns `None` for any other message. pub fn parse_publish_diagnostics(raw: &[u8]) -> Option<(String, Vec)> { diff --git a/gradle-bridge/src/transport.rs b/gradle-bridge/src/transport.rs index 4357c8a..80a87d0 100644 --- a/gradle-bridge/src/transport.rs +++ b/gradle-bridge/src/transport.rs @@ -68,19 +68,7 @@ impl AsyncLspReader { } } - let header_str = String::from_utf8_lossy(&header_buf); - let content_length = header_str - .lines() - .find_map(|line| { - let (name, value) = line.split_once(": ")?; - if name.eq_ignore_ascii_case("Content-Length") { - value.trim().parse::().ok() - } else { - None - } - }) - .unwrap_or(0); - + let content_length = proxy_common::parse_content_length(&header_buf); let mut content = vec![0u8; content_length]; self.reader.read_exact(&mut content).await?; diff --git a/proxy-common/src/lib.rs b/proxy-common/src/lib.rs index 264eb1f..87c75ea 100644 --- a/proxy-common/src/lib.rs +++ b/proxy-common/src/lib.rs @@ -12,8 +12,8 @@ pub mod platform; pub mod uri; pub use lsp::{ - encode_lsp, parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader, - CONTENT_LENGTH, HEADER_SEP, + contains_subslice, encode_lsp, lsp_body, parse_content_length, parse_lsp_content, raw_has_id, + write_raw, write_to_stdout, LspReader, CONTENT_LENGTH, HEADER_SEP, }; pub use platform::spawn_parent_monitor; pub use uri::path_to_file_uri; diff --git a/proxy-common/src/lsp.rs b/proxy-common/src/lsp.rs index 7493861..0c37f63 100644 --- a/proxy-common/src/lsp.rs +++ b/proxy-common/src/lsp.rs @@ -27,19 +27,7 @@ impl LspReader { } } - let header_str = String::from_utf8_lossy(&header_buf); - let content_length = header_str - .lines() - .find_map(|line| { - let (name, value) = line.split_once(": ")?; - if name.eq_ignore_ascii_case(CONTENT_LENGTH) { - value.trim().parse::().ok() - } else { - None - } - }) - .unwrap_or(0); - + let content_length = parse_content_length(&header_buf); let mut content = vec![0u8; content_length]; self.reader.read_exact(&mut content)?; @@ -49,20 +37,49 @@ impl LspReader { } } -pub fn parse_lsp_content(raw: &[u8]) -> Option { +/// Parse the `Content-Length` value from a complete LSP header block (the bytes +/// up to and including the `\r\n\r\n` separator). Returns `0` when absent. +/// +/// Shared by the synchronous [`LspReader`] and the bridge's async reader, which +/// differ only in how they read bytes off the wire, not in how they frame them. +pub fn parse_content_length(header: &[u8]) -> usize { + String::from_utf8_lossy(header) + .lines() + .find_map(|line| { + let (name, value) = line.split_once(": ")?; + if name.eq_ignore_ascii_case(CONTENT_LENGTH) { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0) +} + +/// The JSON body of a raw LSP message — everything after the `\r\n\r\n` header +/// separator — or `None` if the framing is absent. +pub fn lsp_body(raw: &[u8]) -> Option<&[u8]> { let sep_pos = raw.windows(4).position(|w| w == HEADER_SEP)?; - serde_json::from_slice(&raw[sep_pos + 4..]).ok() + Some(&raw[sep_pos + 4..]) +} + +/// Whether `needle` occurs anywhere in `haystack`. +pub fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || haystack.len() < needle.len() { + return false; + } + haystack.windows(needle.len()).any(|w| w == needle) +} + +pub fn parse_lsp_content(raw: &[u8]) -> Option { + serde_json::from_slice(lsp_body(raw)?).ok() } /// Cheap check for the presence of an `"id"` key in the JSON body of a raw LSP /// message. Used to skip full JSON parsing for notifications, which carry no /// `id` and therefore cannot be responses or completion results. pub fn raw_has_id(raw: &[u8]) -> bool { - let Some(sep_pos) = raw.windows(4).position(|w| w == HEADER_SEP) else { - return false; - }; - let body = &raw[sep_pos + 4..]; - body.windows(5).any(|w| w == b"\"id\":") + lsp_body(raw).is_some_and(|body| contains_subslice(body, b"\"id\":")) } pub fn encode_lsp(value: &impl Serialize) -> String { @@ -83,3 +100,48 @@ pub fn write_to_stdout(value: &impl Serialize) { let _ = w.write_all(out.as_bytes()); let _ = w.flush(); } + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(body: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{body}", body.len()).into_bytes() + } + + #[test] + fn reads_a_framed_message() { + let body = r#"{"jsonrpc":"2.0","id":1}"#; + let mut reader = LspReader::new(std::io::Cursor::new(frame(body))); + let msg = reader.read_message().unwrap().expect("a message"); + assert_eq!(lsp_body(&msg), Some(body.as_bytes())); + assert!(reader.read_message().unwrap().is_none()); // EOF + } + + #[test] + fn parses_content_length_case_insensitively() { + assert_eq!(parse_content_length(b"Content-Length: 42\r\n\r\n"), 42); + assert_eq!(parse_content_length(b"content-length: 7\r\n\r\n"), 7); + // Missing / malformed header -> 0. + assert_eq!(parse_content_length(b"X-Other: 1\r\n\r\n"), 0); + } + + #[test] + fn lsp_body_requires_framing() { + assert_eq!(lsp_body(b"no separator here"), None); + assert_eq!(lsp_body(b"H: 1\r\n\r\nbody"), Some(&b"body"[..])); + } + + #[test] + fn contains_subslice_basics() { + assert!(contains_subslice(b"hello world", b"o w")); + assert!(!contains_subslice(b"abc", b"abcd")); // needle longer than haystack + assert!(!contains_subslice(b"abc", b"")); // empty needle + } + + #[test] + fn raw_has_id_detects_id_field() { + assert!(raw_has_id(&frame(r#"{"id":1}"#))); + assert!(!raw_has_id(&frame(r#"{"method":"x"}"#))); + } +} From 79a5903e163da97f988099a71ff72c9d6363dfee Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 19:50:19 +0200 Subject: [PATCH 05/14] Extract platform-specific asset naming to util module Consolidate duplicate platform detection logic in `proxy.rs` and `gradle_bridge.rs` into two shared helpers: `platform_exec_name` and `platform_asset_name`. Both functions live in `util.rs` and handle OS/arch mapping, file extensions, and `.exe` suffixes consistently. Also remove explanatory comments from `gradle_ls_server.rs` that merely restate the code. --- gradle-bridge/src/channel.rs | 4 +--- gradle-bridge/src/grpc.rs | 7 +++--- src/gradle_bridge.rs | 33 ++++++----------------------- src/gradle_ls_server.rs | 10 --------- src/proxy.rs | 32 ++++++---------------------- src/util.rs | 41 +++++++++++++++++++++++++++++++++++- 6 files changed, 57 insertions(+), 70 deletions(-) diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs index 3f38f2c..05ca632 100644 --- a/gradle-bridge/src/channel.rs +++ b/gradle-bridge/src/channel.rs @@ -13,9 +13,7 @@ use serde_json::Value; use tokio::io::{AsyncWriteExt, Stdout}; use tokio::sync::Mutex; -use proxy_common::{ - contains_subslice, encode_lsp, lsp_body, parse_lsp_content, path_to_file_uri, -}; +use proxy_common::{contains_subslice, encode_lsp, lsp_body, parse_lsp_content, path_to_file_uri}; /// Prefix for the JSON-RPC `id` of requests the bridge injects into the language /// server (the build-model sync commands). Responses carry the same id, letting diff --git a/gradle-bridge/src/grpc.rs b/gradle-bridge/src/grpc.rs index ddd2467..d852cc6 100644 --- a/gradle-bridge/src/grpc.rs +++ b/gradle-bridge/src/grpc.rs @@ -194,9 +194,10 @@ impl GradleServer { }; } Some(Kind::Output(output)) - if output.output_type == OutputType::Stderr as i32 => { - stderr.push_str(&String::from_utf8_lossy(&output.output_bytes)); - } + if output.output_type == OutputType::Stderr as i32 => + { + stderr.push_str(&String::from_utf8_lossy(&output.output_bytes)); + } // Progress/Environment/Cancelled are informational. _ => {} }, diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs index cad03fa..b0940b6 100644 --- a/src/gradle_bridge.rs +++ b/src/gradle_bridge.rs @@ -9,7 +9,10 @@ use zed_extension_api::{ use crate::{ config::get_gradle_bridge_path, downloadable::Downloadable, - util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}, + util::{ + mark_checked_once, platform_asset_name, platform_exec_name, remove_all_files_except, + should_use_local_or_download, + }, }; const BRIDGE_BINARY: &str = "gradle-lsp-bridge"; @@ -170,7 +173,6 @@ impl Downloadable for GradleBridge { configuration: &Option, worktree: &Worktree, ) -> Option { - // A dedicated override always wins. if let Some(path) = get_gradle_bridge_path(configuration, worktree) { return Some(path); } @@ -180,32 +182,9 @@ impl Downloadable for GradleBridge { } fn asset_name() -> zed::Result<(String, DownloadedFileType)> { - let (os, arch) = zed::current_platform(); - let (os_str, file_type) = match os { - zed::Os::Mac => ("darwin", DownloadedFileType::GzipTar), - zed::Os::Linux => ("linux", DownloadedFileType::GzipTar), - zed::Os::Windows => ("windows", DownloadedFileType::Zip), - }; - let arch_str = match arch { - zed::Architecture::Aarch64 => "aarch64", - zed::Architecture::X8664 => "x86_64", - _ => return Err("Unsupported architecture".into()), - }; - let ext = if matches!(file_type, DownloadedFileType::Zip) { - "zip" - } else { - "tar.gz" - }; - Ok(( - format!("{BRIDGE_BINARY}-{os_str}-{arch_str}.{ext}"), - file_type, - )) + platform_asset_name(BRIDGE_BINARY) } fn bridge_exec() -> String { - let (os, _arch) = zed::current_platform(); - match os { - zed::Os::Linux | zed::Os::Mac => BRIDGE_BINARY.to_string(), - zed::Os::Windows => format!("{BRIDGE_BINARY}.exe"), - } + platform_exec_name(BRIDGE_BINARY) } diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs index cd57331..8ce7699 100644 --- a/src/gradle_ls_server.rs +++ b/src/gradle_ls_server.rs @@ -94,12 +94,6 @@ impl LanguageServer for GradleLsServer { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result> { - // Mirror the JDTLS server: forward the user's `initialization_options` - // verbatim, defaulting to an empty object when none are configured. The - // Gradle distribution config the bridge needs is read separately from - // the `settings` block (see `command`/`gradle_config_env`), so for now - // init options are typically empty. An empty object — not null — also - // avoids the language server's NPE on a null `initializationOptions`. let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) .map(|lsp_settings| lsp_settings.initialization_options) .map_err(|err| format!("Failed to get LSP settings: {err}"))? @@ -113,10 +107,6 @@ impl LanguageServer for GradleLsServer { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result> { - // Return the `settings` block as-is. Unlike the JDTLS server, there is no - // fallback to the init options' `settings` — that fallback exists only - // for JDTLS for historical reasons; the Gradle server has no such legacy - // and its init options are kept empty. Ok( LspSettings::for_worktree(language_server_id.as_ref(), worktree) .ok() diff --git a/src/proxy.rs b/src/proxy.rs index 01805e6..f5ecafb 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -9,7 +9,10 @@ use zed_extension_api::{ use crate::{ config::get_lsp_proxy_path, downloadable::Downloadable, - util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}, + util::{ + mark_checked_once, platform_asset_name, platform_exec_name, remove_all_files_except, + should_use_local_or_download, + }, }; const PROXY_BINARY: &str = "java-lsp-proxy"; @@ -168,32 +171,9 @@ impl Downloadable for Proxy { } fn asset_name() -> zed::Result<(String, DownloadedFileType)> { - let (os, arch) = zed::current_platform(); - let (os_str, file_type) = match os { - zed::Os::Mac => ("darwin", DownloadedFileType::GzipTar), - zed::Os::Linux => ("linux", DownloadedFileType::GzipTar), - zed::Os::Windows => ("windows", DownloadedFileType::Zip), - }; - let arch_str = match arch { - zed::Architecture::Aarch64 => "aarch64", - zed::Architecture::X8664 => "x86_64", - _ => return Err("Unsupported architecture".into()), - }; - let ext = if matches!(file_type, DownloadedFileType::Zip) { - "zip" - } else { - "tar.gz" - }; - Ok(( - format!("java-lsp-proxy-{os_str}-{arch_str}.{ext}"), - file_type, - )) + platform_asset_name(PROXY_BINARY) } fn proxy_exec() -> String { - let (os, _arch) = zed::current_platform(); - match os { - zed::Os::Linux | zed::Os::Mac => PROXY_BINARY.to_string(), - zed::Os::Windows => format!("{PROXY_BINARY}.exe"), - } + platform_exec_name(PROXY_BINARY) } diff --git a/src/util.rs b/src/util.rs index 72fb41c..d7d7103 100644 --- a/src/util.rs +++ b/src/util.rs @@ -7,7 +7,8 @@ use std::{ path::{Path, PathBuf}, }; use zed_extension_api::{ - self as zed, Command, LanguageServerId, Os, Worktree, current_platform, + self as zed, Architecture, Command, DownloadedFileType, LanguageServerId, Os, Worktree, + current_platform, http_client::{HttpMethod, HttpRequest, fetch}, serde_json::Value, }; @@ -204,6 +205,44 @@ pub fn get_java_exec_name() -> String { } } +/// The platform-specific executable file name for a downloaded native binary +/// (appends `.exe` on Windows). Shared by the proxy and the Gradle bridge, which +/// differ only in `binary`. +pub fn platform_exec_name(binary: &str) -> String { + match current_platform().0 { + Os::Windows => format!("{binary}.exe"), + _ => binary.to_string(), + } +} + +/// The release-asset name and archive type for a downloaded native binary on the +/// current platform, e.g. `java-lsp-proxy-darwin-aarch64.tar.gz`. The proxy and +/// the bridge ship per-platform assets under the same release with this naming; +/// only `binary` differs. +/// +/// # Errors +/// +/// Returns an error on an unsupported CPU architecture. +pub fn platform_asset_name(binary: &str) -> zed::Result<(String, DownloadedFileType)> { + let (os, arch) = current_platform(); + let (os_str, file_type) = match os { + Os::Mac => ("darwin", DownloadedFileType::GzipTar), + Os::Linux => ("linux", DownloadedFileType::GzipTar), + Os::Windows => ("windows", DownloadedFileType::Zip), + }; + let arch_str = match arch { + Architecture::Aarch64 => "aarch64", + Architecture::X8664 => "x86_64", + _ => return Err("Unsupported architecture".into()), + }; + let ext = if matches!(file_type, DownloadedFileType::Zip) { + "zip" + } else { + "tar.gz" + }; + Ok((format!("{binary}-{os_str}-{arch_str}.{ext}"), file_type)) +} + /// Retrieve the java major version accessible by the extension /// /// # Arguments From 117f189c54c2d3687a91736fa80f273be32ac436 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 20:08:46 +0200 Subject: [PATCH 06/14] Consolidate native binary install paths to single constant Remove duplicate install path strings (`"proxy-bin"` and `"gradle-bridge-bin"`) and replace with a shared `NATIVE_BIN_DIR` constant set to `"bin"`. Both the proxy and Gradle bridge are versioned together and can safely co-locate in the same directory. --- src/gradle_bridge.rs | 8 +++----- src/proxy.rs | 6 +++--- src/util.rs | 6 ++++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs index b0940b6..c7aff35 100644 --- a/src/gradle_bridge.rs +++ b/src/gradle_bridge.rs @@ -10,15 +10,13 @@ use crate::{ config::get_gradle_bridge_path, downloadable::Downloadable, util::{ - mark_checked_once, platform_asset_name, platform_exec_name, remove_all_files_except, - should_use_local_or_download, + NATIVE_BIN_DIR, mark_checked_once, platform_asset_name, platform_exec_name, + remove_all_files_except, should_use_local_or_download, }, }; const BRIDGE_BINARY: &str = "gradle-lsp-bridge"; -const BRIDGE_INSTALL_PATH: &str = "gradle-bridge-bin"; -/// The bridge ships as a separate per-platform asset on the same GitHub release -/// as the JDTLS proxy (the extension version's release tag). +const BRIDGE_INSTALL_PATH: &str = NATIVE_BIN_DIR; const GITHUB_REPO: &str = "zed-extensions/java"; /// Downloads and locates the `gradle-lsp-bridge` binary — the native process diff --git a/src/proxy.rs b/src/proxy.rs index f5ecafb..01d1a08 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -10,13 +10,13 @@ use crate::{ config::get_lsp_proxy_path, downloadable::Downloadable, util::{ - mark_checked_once, platform_asset_name, platform_exec_name, remove_all_files_except, - should_use_local_or_download, + NATIVE_BIN_DIR, mark_checked_once, platform_asset_name, platform_exec_name, + remove_all_files_except, should_use_local_or_download, }, }; const PROXY_BINARY: &str = "java-lsp-proxy"; -const PROXY_INSTALL_PATH: &str = "bin"; +const PROXY_INSTALL_PATH: &str = NATIVE_BIN_DIR; const GITHUB_REPO: &str = "zed-extensions/java"; pub struct Proxy { diff --git a/src/util.rs b/src/util.rs index d7d7103..329faf6 100644 --- a/src/util.rs +++ b/src/util.rs @@ -205,6 +205,12 @@ pub fn get_java_exec_name() -> String { } } +/// The single install directory shared by every native binary the extension +/// downloads. They are versioned by the same release tag, +/// so they co-locate under `bin//` and survive each +/// other's `remove_all_files_except` cleanup. +pub const NATIVE_BIN_DIR: &str = "bin"; + /// The platform-specific executable file name for a downloaded native binary /// (appends `.exe` on Windows). Shared by the proxy and the Gradle bridge, which /// differ only in `binary`. From 8b54e4fdb2d51a4c519d1a4b8d56adbcd33daa3a Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 20 Jun 2026 20:23:16 +0200 Subject: [PATCH 07/14] Update README including java home settings for gradle --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index df3ada2..6d32ed3 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ Configuration, when you need it, goes under the `gradle-language-server` languag "settings": { // All optional — sensible defaults are used when omitted. + // JDK used to run gradle-server and the Gradle daemon. Falls back to the + // $JAVA_HOME environment variable. Modern Gradle requires JVM 17+, so set + // this if your default Java is older. Also accepts the legacy key + // "java.home". (Same key the jdtls server uses.) + "java_home": "/path/to/your/JDK17+", + // Gradle distribution (mirrors the Gradle Language Server's own schema). // By default the project's Gradle wrapper is used. "gradleWrapperEnabled": true, @@ -98,7 +104,9 @@ Configuration, when you need it, goes under the `gradle-language-server` languag } ``` -> **Note:** The bridge launches `gradle-server` with the same JDK the extension resolves for Java (`$JAVA_HOME` / the `java_home` setting / an auto-downloaded JDK). Modern Gradle requires JVM 17+, so ensure that JDK satisfies your Gradle distribution. +> **Note:** The bridge launches `gradle-server` with the JDK resolved from this server's own settings — the `java_home` value under `gradle-language-server` (falling back to the `$JAVA_HOME` environment variable, then an auto-downloaded JDK). Modern Gradle requires JVM 17+, so ensure that JDK satisfies your Gradle distribution. +> +> `java_home` is configured **per language server**: the value under `jdtls` does not carry over to `gradle-language-server` (and vice-versa). This is intentional — JDTLS needs a JDK 21+ to run, while the Gradle daemon only needs 17+ — but it means setting `java_home` under `jdtls` alone won't affect Gradle. Set it under `gradle-language-server` too, or rely on the shared `$JAVA_HOME` fallback that applies to both. ## Project Symbol Search From a9efb65cecd07295e42679a51fab740aeb0bd834 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 21 Jun 2026 20:51:12 +0200 Subject: [PATCH 08/14] Add symbols and completions highlighting --- languages/gradle-kts/outline.scm | 35 ++++++++++ languages/gradle/highlights.scm | 14 ++++ languages/gradle/outline.scm | 55 ++++++++++++++++ src/gradle_ls_server.rs | 109 ++++++++++++++++++++++++++++++- src/java.rs | 6 ++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 languages/gradle/outline.scm diff --git a/languages/gradle-kts/outline.scm b/languages/gradle-kts/outline.scm index 62ec6ef..327f0e1 100644 --- a/languages/gradle-kts/outline.scm +++ b/languages/gradle-kts/outline.scm @@ -48,3 +48,38 @@ (anonymous_initializer "init" @name) @item + +; --- Gradle build-script DSL ------------------------------------------------- +; The declarations above cover generic Kotlin, but a `build.gradle.kts` is mostly +; configuration blocks and assignments. Surface those so the outline reflects the +; build structure rather than just the rare top-level `val`/`fun`/`class`. + +; Configuration blocks: `name { … }` — a call with a trailing lambda, e.g. +; `plugins { … }`, `dependencies { … }`, `repositories { … }`, `doLast { … }`. +; The lambda body is captured as `@item` so members nest underneath. +(call_expression + (simple_identifier) @name + (call_suffix + (annotated_lambda + (lambda_literal) @item))) + +; Task containers with a name argument and a trailing lambda, e.g. +; `tasks.register("myTask") { … }`, `tasks.named("test") { … }`. The +; method (`register`/`named`) is the context and the string name is the label. +(call_expression + (call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @context)) + (call_suffix + (value_arguments + (value_argument + (string_literal) @name)))) + (call_suffix + (annotated_lambda + (lambda_literal) @item))) + +; Top-level property assignments, e.g. `group = "com.example"`, `version = "…"`. +(assignment + (directly_assignable_expression + (simple_identifier) @name)) @item diff --git a/languages/gradle/highlights.scm b/languages/gradle/highlights.scm index d3d2747..3a7aff3 100644 --- a/languages/gradle/highlights.scm +++ b/languages/gradle/highlights.scm @@ -93,6 +93,20 @@ ((identifier) @variable.parameter (#is? @variable.parameter "local.parameter")) +; An identifier on the left of an assignment (e.g. `group = '…'`, `version = '…'` +; in a build script) is a property reference, not a parameter. Re-capture it as +; `@variable` after the `@variable.parameter` rule above so it wins last-match. +(assignment + . + (identifier) @variable) + +; Members of a dotted access (e.g. `tasks` in `tasks.named(…)`, the receiver +; segments of `project.ext.foo`) are object/property references, not parameters. +; Re-capture as `@variable` after the parameter rule; the `function_call` rules +; below still override the trailing method segment to `@function`. +(dotted_identifier + (identifier) @variable) + ((identifier) @constant (#match? @constant "^[A-Z][A-Z_]+")) diff --git a/languages/gradle/outline.scm b/languages/gradle/outline.scm new file mode 100644 index 0000000..0f18fbe --- /dev/null +++ b/languages/gradle/outline.scm @@ -0,0 +1,55 @@ +; Outline for Gradle build scripts (Groovy DSL). +; +; Build scripts are structured around configuration closures +; (`dependencies { … }`, `repositories { … }`, `android { … }`, `task foo { … }`), +; property assignments (`group = "…"`), `def`/typed declarations, and — in +; buildSrc / build logic — function and class definitions. We surface those so +; the symbol/breadcrumb navigation works for `.gradle` files. +; +; Following the Java `outline.scm` convention the closure/body is captured as +; `@item` so its members nest underneath it in the outline tree. We deliberately +; do not list every bare method call (e.g. `mavenCentral()`, `println …`) to keep +; the outline structural rather than one-row-per-statement. + +; Configuration closures: `name { … }` — a call whose argument is a closure. +(juxt_function_call + function: (identifier) @name + (argument_list + (closure) @item)) + +(juxt_function_call + function: (dotted_identifier + (identifier) @name .) + (argument_list + (closure) @item)) + +(function_call + function: (identifier) @name + (argument_list + (closure) @item)) + +; Property assignments, e.g. `group = "com.example"`, `version = "1.0"`. +(assignment + . + (identifier) @name) @item + +(assignment + . + (dotted_identifier) @name) @item + +; `def`/typed declarations, e.g. `def libs = …`, `String x = …`. +(declaration + name: (identifier) @name) @item + +; Function and method definitions in build logic. +(function_definition + function: (identifier) @name + body: (closure) @item) + +(function_declaration + function: (identifier) @name) @item + +; Class definitions (buildSrc / inline helper classes). +(class_definition + name: (identifier) @name + body: (closure) @item) diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs index 8ce7699..cab36e8 100644 --- a/src/gradle_ls_server.rs +++ b/src/gradle_ls_server.rs @@ -1,7 +1,8 @@ use std::{env, fs}; use zed_extension_api::{ - self as zed, LanguageServerId, Os, Worktree, current_platform, + self as zed, CodeLabel, CodeLabelSpan, LanguageServerId, Os, Worktree, current_platform, + lsp::{Completion, CompletionKind, Symbol, SymbolKind}, serde_json::{Value, json}, settings::LspSettings, }; @@ -113,6 +114,112 @@ impl LanguageServer for GradleLsServer { .and_then(|lsp_settings| lsp_settings.settings), ) } + + /// Syntax-highlight Gradle build-script completions. The Microsoft Gradle LS + /// emits three kinds (verified against `CompletionHandler`/`CompletionUtils` + /// in `gradle-language-server.jar`): + /// + /// - `Property` — DSL closure fields (e.g. `group`, `version`) and extension + /// properties; the bare name is the label, with parameters in `insertText`. + /// - `Function` — DSL methods/closures (e.g. `dependencies`, `implementation`); + /// again the label is the bare name. + /// - `Module` — Maven coordinate completions (group/artifact/version) from the + /// Maven Central / local / index handlers. + /// + /// None of them carry a `detail`, so we render the label as Groovy code so it + /// picks up the `gradle` theme colors (functions, properties/fields) rather + /// than appearing as flat, uncolored text. + fn label_for_completion( + &self, + _language_server_id: &LanguageServerId, + completion: Completion, + ) -> Option { + let label = &completion.label; + let len = label.len(); + + match completion.kind? { + CompletionKind::Function | CompletionKind::Method => { + // The Gradle LS emits class-method labels as `name(TypeA a,TypeB b)` + // (simple type names + abbreviated arg names; the parens are part of + // the label). Rendering that as a *call* puts the type tokens in + // argument position, so they get `@variable.parameter` — the same + // color as the arg names. Render it as a method *definition* instead + // (`def name(TypeA a,TypeB b) {}`) so the grammar tags the parameter + // types as `@type`, visually distinct from the names. The leading + // `def ` and trailing ` {}` are outside the displayed code range. + // + // Extension-closure labels are bare names with no parens; render + // those as a call so the name still picks up `@function`. + if let Some(name_len) = label.find('(') { + let prefix = "def "; + let code = format!("{prefix}{label} {{}}"); + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range( + prefix.len()..prefix.len() + len, + )], + filter_range: (0..name_len).into(), + code, + }) + } else { + let code = format!("{label}()"); + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..len)], + filter_range: (0..len).into(), + code, + }) + } + } + // Render as a bare reference; the Groovy grammar highlights a lone + // identifier as `@variable`, matching DSL property access. + CompletionKind::Property | CompletionKind::Field => Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..len)], + filter_range: (0..len).into(), + code: label.clone(), + }), + // Maven coordinates (and any other kind) have no meaningful Groovy + // syntax, so emit them as a plain literal span. + _ => Some(CodeLabel { + spans: vec![CodeLabelSpan::literal(label.clone(), None)], + filter_range: (0..len).into(), + code: String::new(), + }), + } + } + + /// Highlight document/workspace symbols for `.gradle` files. The Gradle LS + /// `DocumentSymbolVisitor` emits `Function` (configuration closures and + /// method-call statements), `Property` (`a = b` assignments), and `Constant` + /// (dependency entries). We render the name as Groovy code so it inherits the + /// theme color instead of showing as plain text. + fn label_for_symbol( + &self, + _language_server_id: &LanguageServerId, + symbol: Symbol, + ) -> Option { + let name = &symbol.name; + let len = name.len(); + + match symbol.kind { + SymbolKind::Function | SymbolKind::Method => { + let code = format!("{name}()"); + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..len)], + filter_range: (0..len).into(), + code, + }) + } + SymbolKind::Property | SymbolKind::Field | SymbolKind::Constant => Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..len)], + filter_range: (0..len).into(), + code: name.clone(), + }), + _ => Some(CodeLabel { + spans: vec![CodeLabelSpan::literal(name.clone(), None)], + filter_range: (0..len).into(), + code: String::new(), + }), + } + } } /// Build the environment that conveys Gradle distribution settings to the diff --git a/src/java.rs b/src/java.rs index 3b1b259..d6223a5 100644 --- a/src/java.rs +++ b/src/java.rs @@ -101,6 +101,9 @@ impl Extension for Java { JdtlsServer::SERVER_ID => self .jdtls_server .label_for_completion(language_server_id, completion), + GradleLsServer::SERVER_ID => self + .gradle_ls_server + .label_for_completion(language_server_id, completion), _ => None, } } @@ -114,6 +117,9 @@ impl Extension for Java { JdtlsServer::SERVER_ID => self .jdtls_server .label_for_symbol(language_server_id, symbol), + GradleLsServer::SERVER_ID => self + .gradle_ls_server + .label_for_symbol(language_server_id, symbol), _ => None, } } From b527ae29a50904feb4c7bdd11f82514105eb1fdc Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 21 Jun 2026 21:07:20 +0200 Subject: [PATCH 09/14] Lint and format --- languages/gradle-kts/outline.scm | 1 - languages/gradle/outline.scm | 1 - src/gradle_ls_server.rs | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/languages/gradle-kts/outline.scm b/languages/gradle-kts/outline.scm index 327f0e1..67227e6 100644 --- a/languages/gradle-kts/outline.scm +++ b/languages/gradle-kts/outline.scm @@ -53,7 +53,6 @@ ; The declarations above cover generic Kotlin, but a `build.gradle.kts` is mostly ; configuration blocks and assignments. Surface those so the outline reflects the ; build structure rather than just the rare top-level `val`/`fun`/`class`. - ; Configuration blocks: `name { … }` — a call with a trailing lambda, e.g. ; `plugins { … }`, `dependencies { … }`, `repositories { … }`, `doLast { … }`. ; The lambda body is captured as `@item` so members nest underneath. diff --git a/languages/gradle/outline.scm b/languages/gradle/outline.scm index 0f18fbe..8f73736 100644 --- a/languages/gradle/outline.scm +++ b/languages/gradle/outline.scm @@ -10,7 +10,6 @@ ; `@item` so its members nest underneath it in the outline tree. We deliberately ; do not list every bare method call (e.g. `mavenCentral()`, `println …`) to keep ; the outline structural rather than one-row-per-statement. - ; Configuration closures: `name { … }` — a call whose argument is a closure. (juxt_function_call function: (identifier) @name diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs index cab36e8..9f4c4e1 100644 --- a/src/gradle_ls_server.rs +++ b/src/gradle_ls_server.rs @@ -154,9 +154,7 @@ impl LanguageServer for GradleLsServer { let prefix = "def "; let code = format!("{prefix}{label} {{}}"); Some(CodeLabel { - spans: vec![CodeLabelSpan::code_range( - prefix.len()..prefix.len() + len, - )], + spans: vec![CodeLabelSpan::code_range(prefix.len()..prefix.len() + len)], filter_range: (0..name_len).into(), code, }) From 815dab920bbe837cad3414928583f9c993a5d968 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 28 Jun 2026 20:28:41 +0200 Subject: [PATCH 10/14] Move async reader to common --- Cargo.lock | 1 + gradle-bridge/Cargo.toml | 2 +- gradle-bridge/src/transport.rs | 39 ++-------------------------------- proxy-common/Cargo.toml | 4 ++++ proxy-common/src/lib.rs | 2 ++ proxy-common/src/lsp.rs | 38 +++++++++++++++++++++++++++++++++ 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32fa43b..bc6fff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -816,6 +816,7 @@ dependencies = [ "libc", "serde", "serde_json", + "tokio", "windows-sys 0.59.0", ] diff --git a/gradle-bridge/Cargo.toml b/gradle-bridge/Cargo.toml index 0f3bb96..43a92b4 100644 --- a/gradle-bridge/Cargo.toml +++ b/gradle-bridge/Cargo.toml @@ -11,7 +11,7 @@ name = "gradle-lsp-bridge" path = "src/main.rs" [dependencies] -proxy-common.workspace = true +proxy-common = { workspace = true, features = ["tokio"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" prost = "0.14" diff --git a/gradle-bridge/src/transport.rs b/gradle-bridge/src/transport.rs index 80a87d0..ba1bce9 100644 --- a/gradle-bridge/src/transport.rs +++ b/gradle-bridge/src/transport.rs @@ -9,7 +9,8 @@ use std::sync::Arc; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use proxy_common::AsyncLspReader; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::sync::Mutex; use crate::channel::{ @@ -42,42 +43,6 @@ impl LsWriter { } } -/// Async LSP message reader: reads `Content-Length`-framed messages from an -/// `AsyncRead`, returning each complete message (headers + body) as raw bytes. -pub struct AsyncLspReader { - reader: R, -} - -impl AsyncLspReader { - pub fn new(reader: R) -> Self { - Self { reader } - } - - /// Read the next message, or `None` at EOF. - pub async fn read_message(&mut self) -> std::io::Result>> { - let mut header_buf: Vec = Vec::new(); - loop { - let mut byte = [0u8; 1]; - match self.reader.read(&mut byte).await { - Ok(0) => return Ok(None), - Ok(_) => header_buf.push(byte[0]), - Err(e) => return Err(e), - } - if header_buf.ends_with(b"\r\n\r\n") { - break; - } - } - - let content_length = proxy_common::parse_content_length(&header_buf); - let mut content = vec![0u8; content_length]; - self.reader.read_exact(&mut content).await?; - - let mut message = header_buf; - message.extend_from_slice(&content); - Ok(Some(message)) - } -} - /// Pump messages from the language server to the editor via `channel`, parsing /// LSP framing so that: /// - responses to the bridge's own injected requests are dropped (the editor diff --git a/proxy-common/Cargo.toml b/proxy-common/Cargo.toml index fe48327..8f6bc44 100644 --- a/proxy-common/Cargo.toml +++ b/proxy-common/Cargo.toml @@ -10,9 +10,13 @@ description = "Shared primitives (LSP framing, parent-process monitor) for the Z name = "proxy_common" path = "src/lib.rs" +[features] +tokio = ["dep:tokio"] + [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +tokio = { version = "1", default-features = false, features = ["io-util"], optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/proxy-common/src/lib.rs b/proxy-common/src/lib.rs index 87c75ea..2bf9fe9 100644 --- a/proxy-common/src/lib.rs +++ b/proxy-common/src/lib.rs @@ -11,6 +11,8 @@ pub mod lsp; pub mod platform; pub mod uri; +#[cfg(feature = "tokio")] +pub use lsp::AsyncLspReader; pub use lsp::{ contains_subslice, encode_lsp, lsp_body, parse_content_length, parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader, CONTENT_LENGTH, HEADER_SEP, diff --git a/proxy-common/src/lsp.rs b/proxy-common/src/lsp.rs index 0c37f63..6346000 100644 --- a/proxy-common/src/lsp.rs +++ b/proxy-common/src/lsp.rs @@ -1,6 +1,9 @@ use serde::Serialize; use std::io::{self, Read, Write}; +#[cfg(feature = "tokio")] +use tokio::io::{AsyncRead, AsyncReadExt}; + pub const CONTENT_LENGTH: &str = "Content-Length"; pub const HEADER_SEP: &[u8] = b"\r\n\r\n"; @@ -37,6 +40,41 @@ impl LspReader { } } +#[cfg(feature = "tokio")] +pub struct AsyncLspReader { + reader: R, +} + +#[cfg(feature = "tokio")] +impl AsyncLspReader { + pub fn new(reader: R) -> Self { + Self { reader } + } + + pub async fn read_message(&mut self) -> io::Result>> { + let mut header_buf = Vec::new(); + loop { + let mut byte = [0u8; 1]; + match self.reader.read(&mut byte).await { + Ok(0) => return Ok(None), + Ok(_) => header_buf.push(byte[0]), + Err(e) => return Err(e), + } + if header_buf.ends_with(HEADER_SEP) { + break; + } + } + + let content_length = parse_content_length(&header_buf); + let mut content = vec![0u8; content_length]; + self.reader.read_exact(&mut content).await?; + + let mut message = header_buf; + message.extend_from_slice(&content); + Ok(Some(message)) + } +} + /// Parse the `Content-Length` value from a complete LSP header block (the bytes /// up to and including the `\r\n\r\n` separator). Returns `0` when absent. /// From 07f86ebf5c7accbd5f03986363e4a871df7793b3 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Fri, 10 Jul 2026 00:43:14 +0200 Subject: [PATCH 11/14] Update Cargo.lock --- Cargo.lock | 286 +++++++++++++---------------------------------------- 1 file changed, 70 insertions(+), 216 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc6fff0..a4351e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-trait" @@ -60,9 +60,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -75,15 +75,15 @@ dependencies = [ [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "shlex", @@ -97,9 +97,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -326,15 +326,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] @@ -619,9 +617,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -689,15 +687,15 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miniz_oxide" @@ -711,9 +709,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -822,9 +820,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -837,9 +835,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -849,9 +847,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -860,9 +858,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -919,9 +917,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -944,15 +942,11 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" name = "signal-hook-registry" version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -962,6 +956,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -976,9 +976,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -1013,9 +1013,9 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1064,9 +1064,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -1080,9 +1080,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1222,9 +1222,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.9" +version = "0.26.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55" dependencies = [ "cc", "regex", @@ -1258,9 +1258,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1313,24 +1313,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-encoder" version = "0.227.1" @@ -1338,17 +1320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" dependencies = [ "leb128fmt", - "wasmparser 0.227.1", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -1366,20 +1338,8 @@ dependencies = [ "serde_json", "spdx", "url", - "wasm-encoder 0.227.1", - "wasmparser 0.227.1", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -1394,18 +1354,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -1501,24 +1449,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" dependencies = [ "wit-bindgen-rt", - "wit-bindgen-rust-macro 0.41.0", + "wit-bindgen-rust-macro", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro 0.51.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wit-bindgen-core" version = "0.41.0" @@ -1527,18 +1460,7 @@ checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" dependencies = [ "anyhow", "heck", - "wit-parser 0.227.1", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -1563,25 +1485,9 @@ dependencies = [ "indexmap", "prettyplease", "syn", - "wasm-metadata 0.227.1", - "wit-bindgen-core 0.41.0", - "wit-component 0.227.1", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata 0.244.0", - "wit-bindgen-core 0.51.0", - "wit-component 0.244.0", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] @@ -1595,23 +1501,8 @@ dependencies = [ "proc-macro2", "quote", "syn", - "wit-bindgen-core 0.41.0", - "wit-bindgen-rust 0.41.0", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core 0.51.0", - "wit-bindgen-rust 0.51.0", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] @@ -1627,29 +1518,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.227.1", - "wasm-metadata 0.227.1", - "wasmparser 0.227.1", - "wit-parser 0.227.1", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata 0.244.0", - "wasmparser 0.244.0", - "wit-parser 0.244.0", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] @@ -1667,25 +1539,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -1696,9 +1550,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -1725,7 +1579,7 @@ checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", - "wit-bindgen 0.41.0", + "wit-bindgen", ] [[package]] From 08b89c8aea599a7eee3f967b9b4ac941aee81830 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Fri, 10 Jul 2026 01:09:32 +0200 Subject: [PATCH 12/14] Reconcile README and justfile after rebase --- README.md | 77 +++++++++++++++++++++++++++++++------------------------ justfile | 11 ++++++-- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6d32ed3..f699712 100644 --- a/README.md +++ b/README.md @@ -601,7 +601,7 @@ If you're working without a Gradle or Maven project, and the following error `Th MyProject/ ├── .zed/ │ └── settings.json - ``` +``` ```jsonc "lsp": { @@ -674,15 +674,42 @@ The project includes a `justfile` with common development tasks: | `just task-test` | Run task helper tests | | `just bridge-build` | Build the gradle-lsp-bridge binary in debug mode | | `just bridge-release` | Build the gradle-lsp-bridge binary in release mode | +| `just bridge-install` | Build release gradle-lsp-bridge and copy it to the extension workdir | | `just ext-build` | Build the WASM extension in release mode | | `just fmt` | Format all code (Rust + tree-sitter queries) | | `just clippy` | Run clippy on all crates | | `just lint` | Format and lint all code | -| `just all` | Lint, build extension, and install binaries | +| `just all` | Lint, build extension, and install all native binaries | ### Testing Local Binary Changes -The two native binaries (`java-lsp-proxy` in `proxy/`, `gradle-lsp-bridge` in `gradle-bridge/`) are **not** rebuilt when you use "Rebuild Dev Extension" — and by default the extension downloads release binaries from GitHub. To test a local build, point the extension directly at the binary in your `target/` directory with the corresponding path setting: +The three native binaries (`java-lsp-proxy` in `proxy/`, `gradle-lsp-bridge` in `gradle-bridge/`, `java-task-helper` in `task_helper/`) are **not** rebuilt when you use "Rebuild Dev Extension" — and by default the extension downloads release binaries from GitHub. There are two ways to run a local build instead. + +#### Option A: Install into the extension workdir (recommended) + +The `*-install` recipes build the release binary and copy it into the extension's working directory, where the extension picks it up in place of the downloaded release: + +```sh +just proxy-install # java-lsp-proxy +just bridge-install # gradle-lsp-bridge +just task-install # java-task-helper +``` + +Each copies to the `bin/` directory of the Zed extension workdir: + +- **macOS**: `~/Library/Application Support/Zed/extensions/work/java/bin/` +- **Linux**: `~/.local/share/zed/extensions/work/java/bin/` +- **Windows**: `%LOCALAPPDATA%/Zed/extensions/work/java/bin/` + +After installing, restart the language server in Zed (`jdtls` or `gradle-language-server`) for the change to take effect. + +#### Option B: Point a path setting at your `target/` build + +Build the binary without installing it, then point the extension directly at it with the corresponding path setting: + +```sh +just proxy-release # or: just bridge-release +``` ```jsonc "lsp": { @@ -701,23 +728,7 @@ The two native binaries (`java-lsp-proxy` in `proxy/`, `gradle-lsp-bridge` in `g } ``` -This compiles the proxy for your native target and copies it to the appropriate Zed extension working directory: - -- **macOS**: `~/Library/Application Support/Zed/extensions/work/java/bin/` -- **Linux**: `~/.local/share/zed/extensions/work/java/bin/` -- **Windows**: `%LOCALAPPDATA%/Zed/extensions/work/java/bin/` - -After installing the proxy, restart the language server in Zed for the changes to take effect. - -If you prefer not to use `just`, you can build and copy manually: -When a path setting is provided, the extension uses that binary as-is and skips the managed download entirely — so there's no need to set `check_updates`. -Just rebuild and restart the language server to pick up changes: - -```sh -just proxy-release # or: just bridge-release -``` - -After rebuilding, restart the language server in Zed (`jdtls` or `gradle-language-server`) for the new binary to take effect. +When a path setting is provided, the extension uses that binary as-is and skips the managed download entirely — so there's no need to set `check_updates`. Rebuild and restart the language server to pick up changes. > **Note:** The gRPC bindings the bridge uses are committed under `gradle-bridge/src/gen/`, so building it needs no `protoc`. They are regenerated only when the bundled Gradle Language Server's `gradle.proto` contract changes — see the header of `gradle-bridge/proto/gradle.proto`. @@ -725,9 +736,9 @@ After rebuilding, restart the language server in Zed (`jdtls` or `gradle-languag When using [Zed's remote development](https://zed.dev/docs/remote-development) over SSH, the language server and all native binaries run on the **remote host**, not your local machine. For standard use they are auto-downloaded from GitHub releases for the remote server's platform — no action is needed. -For standard use, the proxy binary is auto-downloaded from GitHub releases for the remote server's platform — no action is needed. +However, if you're **testing local binary changes** against a remote host, you need to get the binary onto the remote server yourself. The path settings (`lsp_proxy_path`, `gradle_bridge_path`) are resolved on the remote host, so once the binary is there you point the setting at it exactly as you would locally. -However, if you're **testing local proxy changes** against a remote host, you need to get the binary onto the remote server yourself. The key thing to be aware of is that on remote hosts, extensions are stored under a **different path** than on your local machine — typically: +On remote hosts, extensions are stored under a **different path** than on your local machine — typically: ```text ~/.local/share/zed/remote_extensions/work/java/bin/ @@ -738,8 +749,6 @@ However, if you're **testing local proxy changes** against a remote host, you ne > ```sh > find ~/.local/share/zed -type d -name "bin" 2>/dev/null > ``` -> -To test **local binary changes** against a remote host, get the binary onto the remote (anywhere you like) and point the path setting at that remote location. The path settings (`lsp_proxy_path`, `gradle_bridge_path`) are resolved on the remote host, so this works the same as locally. #### Option A: Build on the remote directly @@ -748,15 +757,16 @@ If you have Rust installed on the remote server, clone the repo there and build ```sh # On the remote host git clone https://github.com/zed-extensions/java.git -cd java/proxy -cargo build --release +cd java +cargo build --release -p java-lsp-proxy -p gradle-lsp-bridge -p java-task-helper +# Binaries are at: /target/release/{java-lsp-proxy,gradle-lsp-bridge,java-task-helper} # Copy to the remote extensions workdir mkdir -p ~/.local/share/zed/remote_extensions/work/java/bin -cp target/release/java-lsp-proxy ~/.local/share/zed/remote_extensions/work/java/bin/ -cd java -cargo build --release -p java-lsp-proxy -p gradle-lsp-bridge -p -# Binaries are at: /target/release/{java-lsp-proxy,gradle-lsp-bridge} +cp target/release/java-lsp-proxy \ + target/release/gradle-lsp-bridge \ + target/release/java-task-helper \ + ~/.local/share/zed/remote_extensions/work/java/bin/ ``` #### Option B: Cross-compile locally and copy @@ -764,17 +774,16 @@ cargo build --release -p java-lsp-proxy -p gradle-lsp-bridge -p If you prefer to build on your local machine, cross-compile for the remote target (typically Linux x86_64 or aarch64) and `scp` the binaries anywhere on the remote: ```sh +# Build for the remote's target cargo build --release --target x86_64-unknown-linux-gnu -p java-lsp-proxy -p gradle-lsp-bridge -p java-task-helper # You may need: rustup target add x86_64-unknown-linux-gnu (and a linker in .cargo/config.toml) -2. Copy the binary to the remote server: - ```sh - +# Copy the binaries to the remote server scp target/x86_64-unknown-linux-gnu/release/java-lsp-proxy \ target/x86_64-unknown-linux-gnu/release/java-task-helper \ target/x86_64-unknown-linux-gnu/release/gradle-lsp-bridge \ user@remote:~/java-bins/ -```text +``` Then set the path settings to the remote paths and restart the language server: diff --git a/justfile b/justfile index 36903a1..03d038d 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,7 @@ native_target := `rustc -vV | grep host | awk '{print $2}'` ext_dir := if os() == "macos" { env("HOME") / "Library/Application Support/Zed/extensions/work/java" } else if os() == "linux" { env("HOME") / ".local/share/zed/extensions/work/java" } else { env("LOCALAPPDATA") / "Zed/extensions/work/java" } proxy_bin := ext_dir / "bin" / "java-lsp-proxy" tasks_bin := ext_dir / "bin" / "java-task-helper" +bridge_bin := ext_dir / "bin" / "gradle-lsp-bridge" # Build proxy in debug mode proxy-build: @@ -50,6 +51,12 @@ bridge-build: bridge-release: cargo build --release --target {{ native_target }} -p gradle-lsp-bridge +# Build gradle-lsp-bridge release and install to extension workdir for testing +bridge-install: bridge-release + mkdir -p "{{ ext_dir }}/bin" + cp "target/{{ native_target }}/release/gradle-lsp-bridge" "{{ bridge_bin }}" + @echo "Installed to {{ ext_dir }}" + # Build WASM extension in release mode ext-build: cargo build --release @@ -67,5 +74,5 @@ clippy: # Format and lint all code lint: fmt clippy -# Build everything: lint, extension, and install proxy & task helper -all: lint ext-build proxy-install task-install +# Build everything: lint, extension, and install proxy, task helper & bridge +all: lint ext-build proxy-install task-install bridge-install From fc50a13b83dd5ddc8324804b736a87f9335b9db0 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Fri, 10 Jul 2026 01:33:06 +0200 Subject: [PATCH 13/14] Simplify clippy recipe and apply to workspace --- justfile | 5 ++--- task_helper/src/build_tool/gradle.rs | 2 +- task_helper/src/build_tool/mod.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/justfile b/justfile index 03d038d..f03ee0f 100644 --- a/justfile +++ b/justfile @@ -66,10 +66,9 @@ fmt: cargo fmt --all ts_query_ls format languages -# Run clippy on the WASM extension and the native crates (proxy, bridge, common) +# Run clippy on all workspace crates (WASM extension + native binaries) clippy: - cargo clippy --all-targets --fix --allow-dirty - cargo clippy --fix --allow-dirty --target {{ native_target }} -p java-lsp-proxy -p gradle-lsp-bridge -p proxy-common + cargo clippy --workspace --all-targets --fix --allow-dirty # Format and lint all code lint: fmt clippy diff --git a/task_helper/src/build_tool/gradle.rs b/task_helper/src/build_tool/gradle.rs index 906ba5e..8f4099e 100644 --- a/task_helper/src/build_tool/gradle.rs +++ b/task_helper/src/build_tool/gradle.rs @@ -27,7 +27,7 @@ impl Gradle { fn gradle_task(&self, module: &Option, task_name: &str) -> String { match module { Some(m) => { - let gradle_path = m.to_string_lossy().replace('/', ":").replace('\\', ":"); + let gradle_path = m.to_string_lossy().replace(['/', '\\'], ":"); format!(":{gradle_path}:{task_name}") } None => format!(":{task_name}"), diff --git a/task_helper/src/build_tool/mod.rs b/task_helper/src/build_tool/mod.rs index e2e202f..e14bcef 100644 --- a/task_helper/src/build_tool/mod.rs +++ b/task_helper/src/build_tool/mod.rs @@ -233,7 +233,7 @@ mod tests { File::create(&deep_path).unwrap(); // Call detect_build_tool with a deep file dir — should walk up to root - let (_tool, root) = detect_build_tool(&deep_path.parent().unwrap()); + let (_tool, root) = detect_build_tool(deep_path.parent().unwrap()); assert_eq!(root, root_path); // Call from submodule dir — should still walk up to root From b67f5f6da2255f245c595bb6a0d26a751e86c0da Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Fri, 10 Jul 2026 13:30:01 +0200 Subject: [PATCH 14/14] Update comment on justfile --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index f03ee0f..b02f477 100644 --- a/justfile +++ b/justfile @@ -70,7 +70,7 @@ fmt: clippy: cargo clippy --workspace --all-targets --fix --allow-dirty -# Format and lint all code +# Format and clippy all code lint: fmt clippy # Build everything: lint, extension, and install proxy, task helper & bridge