diff --git a/.github/workflows/release-proxy.yml b/.github/workflows/release-proxy.yml index fdf9dc6..cb23641 100644 --- a/.github/workflows/release-proxy.yml +++ b/.github/workflows/release-proxy.yml @@ -9,7 +9,7 @@ permissions: jobs: build: - name: Build ${{ matrix.asset_name }} + name: Build ${{ matrix.target }} runs-on: ${{ matrix.runner }} strategy: fail-fast: true @@ -17,22 +17,28 @@ jobs: include: - target: aarch64-apple-darwin runner: macos-15 - asset_name: java-lsp-proxy-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 + 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 + 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 + 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 + 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 + proxy_asset: java-lsp-proxy-windows-aarch64.zip + bridge_asset: gradle-lsp-bridge-windows-aarch64.zip steps: - name: Checkout @@ -43,29 +49,38 @@ 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: Package binary (Unix) + - 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.bridge_asset }} \ + -C target/${{ matrix.target }}/release \ + gradle-lsp-bridge - - name: Package binary (Windows) + - 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 }} + 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 artifact + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: ${{ matrix.asset_name }} - path: ${{ matrix.asset_name }} + name: ${{ matrix.target }} + path: | + ${{ matrix.proxy_asset }} + ${{ matrix.bridge_asset }} retention-days: 1 release: diff --git a/Cargo.lock b/Cargo.lock index cf77ed6..7f8cd40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,23 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[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.9.3" @@ -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" @@ -145,7 +180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -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" @@ -297,6 +338,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" @@ -318,6 +391,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.0.0" @@ -455,6 +628,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.15" @@ -463,12 +645,11 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[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]] @@ -516,6 +697,17 @@ dependencies = [ "adler2", ] +[[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.3" @@ -528,6 +720,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.16" @@ -568,6 +780,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.40" @@ -622,7 +867,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -701,6 +946,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +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" version = "2.7.0" @@ -719,6 +974,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" @@ -751,6 +1016,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" @@ -772,7 +1043,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -785,12 +1056,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.8" @@ -821,6 +1244,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.19.0" @@ -863,6 +1292,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 d247174..41ac5f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,14 @@ [workspace] members = [ ".", - "proxy" + "proxy", + "proxy-common", + "gradle-bridge" ] +[workspace.dependencies] +proxy-common = { path = "proxy-common" } + [package] name = "zed_java" version = "6.8.20" diff --git a/README.md b/README.md index eaaf3d7..20f210a 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 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 Install the extension via Zeds extension manager. It should work out of the box for most people. However, there are some things to know: @@ -57,6 +59,55 @@ Here is a common `settings.json` including the above mentioned configurations: } ``` +## Gradle Build Files + +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 +"lsp": { + "gradle-language-server": { + "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, + "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 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 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. @@ -568,7 +619,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 @@ -607,87 +661,85 @@ The project includes a `justfile` with common development tasks: |--------|-------------| | `just proxy-build` | Build the proxy binary in debug mode | | `just proxy-release` | Build the proxy binary in release mode | -| `just proxy-install` | Build release proxy and copy it to the extension workdir | +| `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 both crates | +| `just clippy` | Run clippy on the WASM extension and native crates | | `just lint` | Format and lint all code | -| `just all` | Lint, build extension, and install proxy | +| `just all` | Lint, build extension, and build both native 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/proxy-bin/` -- **Linux**: `~/.local/share/zed/extensions/work/java/proxy-bin/` -- **Windows**: `%LOCALAPPDATA%/Zed/extensions/work/java/proxy-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 -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 ``` -### 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. +After rebuilding, restart the language server in Zed (`jdtls` or `gradle-language-server`) for the new binary to take effect. -For standard use, the proxy binary is auto-downloaded from GitHub releases for the remote server's platform — no action is needed. +> **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`. -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: +### Remote Development (SSH) -``` -~/.local/share/zed/remote_extensions/work/java/proxy-bin/ -``` +When using [Zed's remote development](https://zed.dev/docs/remote-development) over SSH, the language server and both 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. -> **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 "proxy-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 git clone https://github.com/zed-extensions/java.git -cd java/proxy -cargo build --release - -# Copy to the remote extensions workdir -mkdir -p ~/.local/share/zed/remote_extensions/work/java/proxy-bin -cp target/release/java-lsp-proxy ~/.local/share/zed/remote_extensions/work/java/proxy-bin/ +cd java +cargo build --release -p java-lsp-proxy -p gradle-lsp-bridge +# 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 +# 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/proxy-bin/java-lsp-proxy - ``` +scp target/x86_64-unknown-linux-gnu/release/java-lsp-proxy \ + target/x86_64-unknown-linux-gnu/release/gradle-lsp-bridge \ + user@remote:~/java-bins/ +``` + +Then set the path settings to the remote paths and restart the language server: -After either option, restart the language server in Zed for the changes to take effect. +```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 3f06ca9..31a0a17 100644 --- a/extension.toml +++ b/extension.toml @@ -16,10 +16,24 @@ 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" + +# 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" +languages = ["Gradle", "Gradle KTS"] + [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..05ca632 --- /dev/null +++ b/gradle-bridge/src/channel.rs @@ -0,0 +1,407 @@ +//! 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 std::path::Path; + +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}; + +/// 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") +} + +/// Whether a raw LSP message is a response to one of the bridge's injected +/// 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; + }; + 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)) +} + +/// 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::new(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() +} + +/// 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)); + + // 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] + 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 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_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 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 = + "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..d852cc6 --- /dev/null +++ b/gradle-bridge/src/grpc.rs @@ -0,0 +1,506 @@ +//! 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::{ + 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. +#[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(), + // 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(), + } + } +} + +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..80a87d0 --- /dev/null +++ b/gradle-bridge/src/transport.rs @@ -0,0 +1,160 @@ +//! 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, + 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 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 +/// 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) { + // 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; + } + 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 — 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) + }; + + 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 806a8f3..a1bd6a6 100644 --- a/justfile +++ b/justfile @@ -1,20 +1,20 @@ 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 / "proxy-bin" / "java-lsp-proxy" # 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: - cd proxy && cargo build --release --target {{ native_target }} + cargo build --release --target {{ native_target }} -p java-lsp-proxy -# Build proxy release and install to extension workdir for testing -proxy-install: proxy-release - mkdir -p "{{ ext_dir }}/proxy-bin" - cp "target/{{ native_target }}/release/java-lsp-proxy" "{{ proxy_bin }}" - @echo "Installed to {{ proxy_bin }}" +# 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: @@ -25,13 +25,13 @@ 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 -# Build everything: lint, extension, proxy install -all: lint ext-build proxy-install +# Build everything: lint, extension, and both native binaries +all: lint ext-build proxy-release bridge-release 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..67227e6 --- /dev/null +++ b/languages/gradle-kts/outline.scm @@ -0,0 +1,84 @@ +(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 + +; --- 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-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 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..3a7aff3 --- /dev/null +++ b/languages/gradle/highlights.scm @@ -0,0 +1,274 @@ +[ + "!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")) + +; 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_]+")) + +[ + "%" + "*" + "/" + "+" + "-" + "<<" + ">>" + ">>>" + ".." + "..<" + "<..<" + "<.." + "<" + "<=" + ">" + ">=" + "==" + "!=" + "<=>" + "===" + "!==" + "=~" + "==~" + "&" + "^" + "|" + "&&" + "||" + "?:" + "+" + "*" + ".&" + ".@" + "?." + "*." + "*" + "*:" + "++" + "--" + "!" +] @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/languages/gradle/outline.scm b/languages/gradle/outline.scm new file mode 100644 index 0000000..8f73736 --- /dev/null +++ b/languages/gradle/outline.scm @@ -0,0 +1,54 @@ +; 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/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..87c75ea --- /dev/null +++ b/proxy-common/src/lib.rs @@ -0,0 +1,19 @@ +//! 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. +//! - [`uri`]: filesystem-path-to-`file://`-URI conversion. + +pub mod lsp; +pub mod platform; +pub mod uri; + +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, +}; +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 new file mode 100644 index 0000000..0c37f63 --- /dev/null +++ b/proxy-common/src/lsp.rs @@ -0,0 +1,147 @@ +use serde::Serialize; +use std::io::{self, Read, Write}; + +pub const CONTENT_LENGTH: &str = "Content-Length"; +pub const HEADER_SEP: &[u8] = b"\r\n\r\n"; + +pub struct LspReader { + reader: R, +} + +impl LspReader { + pub fn new(reader: R) -> Self { + Self { reader } + } + + pub 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) { + 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)?; + + 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. +/// +/// 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)?; + 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 { + lsp_body(raw).is_some_and(|body| contains_subslice(body, b"\"id\":")) +} + +pub fn encode_lsp(value: &impl Serialize) -> String { + let json = serde_json::to_string(value).unwrap(); + format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) +} + +/// Write raw LSP bytes to a writer, flushing afterward. +pub fn write_raw(w: &mut impl Write, raw: &[u8]) { + let _ = w.write_all(raw); + let _ = w.flush(); +} + +/// Encode a value as an LSP message and write it to stdout. +pub fn write_to_stdout(value: &impl Serialize) { + let out = encode_lsp(value); + let mut w = io::stdout().lock(); + 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"}"#))); + } +} 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-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/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..f8e079c 100644 --- a/proxy/src/decompile.rs +++ b/proxy/src/decompile.rs @@ -4,32 +4,15 @@ use std::{ env, fs, hash::{Hash, Hasher}, io::Write, - path::{Path, PathBuf}, + path::PathBuf, sync::{mpsc, Arc, Mutex}, }; -use crate::{lsp::encode_lsp, lsp_error, lsp_warn}; +use crate::{lsp_error, lsp_warn}; +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/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/lsp.rs b/proxy/src/lsp.rs deleted file mode 100644 index 7493861..0000000 --- a/proxy/src/lsp.rs +++ /dev/null @@ -1,85 +0,0 @@ -use serde::Serialize; -use std::io::{self, Read, Write}; - -pub const CONTENT_LENGTH: &str = "Content-Length"; -pub const HEADER_SEP: &[u8] = b"\r\n\r\n"; - -pub struct LspReader { - reader: R, -} - -impl LspReader { - pub fn new(reader: R) -> Self { - Self { reader } - } - - pub 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) { - 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 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)?; - - let mut message = header_buf; - message.extend_from_slice(&content); - Ok(Some(message)) - } -} - -pub fn parse_lsp_content(raw: &[u8]) -> Option { - let sep_pos = raw.windows(4).position(|w| w == HEADER_SEP)?; - serde_json::from_slice(&raw[sep_pos + 4..]).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\":") -} - -pub fn encode_lsp(value: &impl Serialize) -> String { - let json = serde_json::to_string(value).unwrap(); - format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) -} - -/// Write raw LSP bytes to a writer, flushing afterward. -pub fn write_raw(w: &mut impl Write, raw: &[u8]) { - let _ = w.write_all(raw); - let _ = w.flush(); -} - -/// Encode a value as an LSP message and write it to stdout. -pub fn write_to_stdout(value: &impl Serialize) { - let out = encode_lsp(value); - let mut w = io::stdout().lock(); - let _ = w.write_all(out.as_bytes()); - let _ = w.flush(); -} diff --git a/proxy/src/main.rs b/proxy/src/main.rs index b07ce68..1022cfe 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -2,14 +2,13 @@ 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 +32,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..c7aff35 --- /dev/null +++ b/src/gradle_bridge.rs @@ -0,0 +1,188 @@ +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, + downloadable::Downloadable, + util::{ + 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 = NATIVE_BIN_DIR; +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)); + } + + // 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()) { + return Ok(PathBuf::from(path)); + } + + Err(format!("'{}' not found", bridge_exec())) + } + + fn user_configured_path( + &self, + configuration: &Option, + worktree: &Worktree, + ) -> Option { + if let Some(path) = get_gradle_bridge_path(configuration, worktree) { + return Some(path); + } + + None + } +} + +fn asset_name() -> zed::Result<(String, DownloadedFileType)> { + platform_asset_name(BRIDGE_BINARY) +} + +fn bridge_exec() -> String { + platform_exec_name(BRIDGE_BINARY) +} 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..9f4c4e1 --- /dev/null +++ b/src/gradle_ls_server.rs @@ -0,0 +1,300 @@ +use std::{env, fs}; + +use zed_extension_api::{ + self as zed, CodeLabel, CodeLabelSpan, LanguageServerId, Os, Worktree, current_platform, + lsp::{Completion, CompletionKind, Symbol, SymbolKind}, + 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> { + 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> { + Ok( + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .ok() + .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 +/// `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 bfc9b39..b65526b 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; @@ -20,13 +23,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 { @@ -36,6 +41,7 @@ impl Extension for Java { { Self { jdtls_server: JdtlsServer::new(), + gradle_ls_server: GradleLsServer::new(), } } @@ -46,6 +52,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}")), } } @@ -59,6 +68,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), } } @@ -72,6 +84,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), } } @@ -85,6 +100,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, } } @@ -98,6 +116,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, } } diff --git a/src/proxy.rs b/src/proxy.rs index aaa5bfa..01d1a08 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -9,11 +9,14 @@ 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::{ + 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 = "proxy-bin"; +const PROXY_INSTALL_PATH: &str = NATIVE_BIN_DIR; const GITHUB_REPO: &str = "zed-extensions/java"; pub struct Proxy { @@ -130,19 +133,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()) { @@ -162,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..329faf6 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,50 @@ 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`. +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