From 296ace10abbb62c6ae79684d096302ddb6f27817 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 8 Mar 2026 20:32:33 +0100 Subject: [PATCH 01/14] Add native Rust LSP proxy with release automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a high-performance LSP proxy in Rust to replace the Node.js version, eliminating the 50MB runtime dependency. The proxy forwards LSP messages between Zed and JDTLS while sorting completion items by parameter count. Key improvements: - 2.5x faster message processing (13µs vs 33µs average) - 771KB static binary vs 50MB Node.js runtime - Cross-platform CI builds for all supported architectures - HTTP server for extension requests with 5s timeout - Parent process monitoring to prevent orphaned JDTLS instances --- .github/workflows/release-proxy.yml | 78 +++++ .gitignore | 1 + proxy/BENCHMARK.md | 85 +++++ proxy/Cargo.lock | 188 ++++++++++ proxy/Cargo.toml | 21 ++ proxy/src/main.rs | 523 ++++++++++++++++++++++++++++ src/java.rs | 25 +- src/proxy.rs | 136 ++++++++ 8 files changed, 1048 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/release-proxy.yml create mode 100644 proxy/BENCHMARK.md create mode 100644 proxy/Cargo.lock create mode 100644 proxy/Cargo.toml create mode 100644 proxy/src/main.rs create mode 100644 src/proxy.rs diff --git a/.github/workflows/release-proxy.yml b/.github/workflows/release-proxy.yml new file mode 100644 index 0000000..dacbdcf --- /dev/null +++ b/.github/workflows/release-proxy.yml @@ -0,0 +1,78 @@ +name: Release Proxy Binary + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.asset_name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-14 + asset_name: java-lsp-proxy-darwin-aarch64.tar.gz + - target: x86_64-apple-darwin + runner: macos-14 + asset_name: java-lsp-proxy-darwin-x86_64.tar.gz + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + asset_name: java-lsp-proxy-linux-x86_64.tar.gz + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + asset_name: java-lsp-proxy-linux-aarch64.tar.gz + cross: true + - target: x86_64-pc-windows-msvc + runner: windows-latest + asset_name: java-lsp-proxy-windows-x86_64.zip + - target: aarch64-pc-windows-msvc + runner: windows-latest + asset_name: java-lsp-proxy-windows-aarch64.zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross (Linux aarch64) + if: matrix.cross + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build proxy binary + working-directory: proxy + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + shell: bash + + - name: Package binary (Unix) + if: runner.os != 'Windows' + run: | + tar -czf ${{ matrix.asset_name }} \ + -C proxy/target/${{ matrix.target }}/release \ + java-lsp-proxy + + - name: Package binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path proxy/target/${{ matrix.target }}/release/java-lsp-proxy.exe -DestinationPath ${{ matrix.asset_name }} + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.asset_name }} diff --git a/.gitignore b/.gitignore index fdf4353..45f1f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ grammars/ target/ +proxy/target/ extension.wasm .DS_Store diff --git a/proxy/BENCHMARK.md b/proxy/BENCHMARK.md new file mode 100644 index 0000000..1de82da --- /dev/null +++ b/proxy/BENCHMARK.md @@ -0,0 +1,85 @@ +# LSP Proxy Benchmark: Node.js vs Rust + +## Overview + +This report compares the performance of the existing Node.js LSP proxy (`proxy.mjs`) against the new native Rust replacement (`java-lsp-proxy`). Both proxies sit between Zed and JDTLS, forwarding LSP messages bidirectionally, sorting completion responses by parameter count, and exposing an HTTP server for extension-originated requests. + +## Methodology + +- Both proxies were instrumented with high-resolution timing (nanosecond on JS via `hrtime.bigint()`, microsecond on Rust via `std::time::Instant`) +- Benchmarking was gated behind `LSP_PROXY_BENCH=1` — zero overhead when disabled +- Each message records: direction, LSP method, payload size, and proxy processing overhead in microseconds +- Overhead measures only the proxy's own processing time (parse → transform → forward), excluding JDTLS response latency +- Tests were run on the same machine (macOS, Apple Silicon) with the same Zed configuration and JDTLS version, performing typical editing workflows: navigation, completions, saves, diagnostics + +## Test Environment + +| | Details | +|---|---| +| Machine | macOS, Apple Silicon (aarch64) | +| JDTLS | 1.57.0-202602261110 | +| Node.js | v24.11.0 (Zed-bundled) | +| Rust proxy | Release build, 771 KB binary | +| Zed | Dev extension | + +## Results + +### Node.js Proxy (3,700 messages) + +| Direction | Count | Min (µs) | Median (µs) | P95 (µs) | P99 (µs) | Max (µs) | Avg (µs) | +|---|---:|---:|---:|---:|---:|---:|---:| +| client → server | 1,399 | 3 | 16 | 81 | 147 | 429 | 28 | +| server → client | 2,011 | 4 | 24 | 74 | 121 | 4,501 | 32 | +| server → client (completion) | 290 | 13 | 50 | 179 | 272 | 458 | 71 | +| **Total** | **3,700** | | | | | | **33** | + +Total overhead: **124,796 µs** (~125 ms) + +### Rust Proxy (5,277 messages) + +| Direction | Count | Min (µs) | Median (µs) | P95 (µs) | P99 (µs) | Max (µs) | Avg (µs) | +|---|---:|---:|---:|---:|---:|---:|---:| +| client → server | 2,093 | 0 | 7 | 32 | 58 | 269 | 10 | +| server → client | 2,666 | 1 | 8 | 32 | 63 | 1,185 | 12 | +| server → client (completion) | 523 | 4 | 17 | 116 | 143 | 253 | 29 | +| **Total** | **5,277** | | | | | | **13** | + +Total overhead: **72,026 µs** (~72 ms) + +### Head-to-Head Comparison (Median) + +| Direction | Node.js | Rust | Speedup | +|---|---:|---:|---:| +| client → server (passthrough) | 16 µs | 7 µs | **2.3x** | +| server → client (passthrough) | 24 µs | 8 µs | **3.0x** | +| server → client (completion sort) | 50 µs | 17 µs | **2.9x** | +| **Overall average** | **33 µs** | **13 µs** | **2.5x** | + +### Tail Latency Comparison (P99) + +| Direction | Node.js | Rust | Improvement | +|---|---:|---:|---:| +| client → server | 147 µs | 58 µs | **2.5x** | +| server → client | 121 µs | 63 µs | **1.9x** | +| server → client (completion sort) | 272 µs | 143 µs | **1.9x** | + +## Analysis + +- The Rust proxy is **2.5x faster on average** across all message types +- The completion sorting path — which involves full JSON parse, field mutation, and re-serialization — shows a **2.9x improvement** at the median (17 µs vs 50 µs) +- Tail latency (P99) is **~2x tighter** in Rust, meaning more predictable performance +- Both proxies add negligible latency compared to JDTLS response times (typically 10-500 ms), so the user-perceived difference is minimal +- The primary benefits of the Rust proxy are architectural: + - **No Node.js runtime dependency** — eliminates ~50 MB runtime + - **771 KB static binary** — trivial to distribute + - **Faster cold start** — no V8 JIT warmup + - **Lower memory footprint** — no garbage collector overhead + - **Cross-compiled** — single binary per platform via CI + +## Appendix: Message Size Distribution (Rust run) + +| Direction | Min | Median | Max | Avg | +|---|---:|---:|---:|---:| +| client → server | 62 B | 381 B | 6,151 B | 366 B | +| server → client | 49 B | 549 B | 50,675 B | 1,228 B | +| server → client (completion) | 58 B | 110 B | 25,049 B | 2,060 B | diff --git a/proxy/Cargo.lock b/proxy/Cargo.lock new file mode 100644 index 0000000..956558d --- /dev/null +++ b/proxy/Cargo.lock @@ -0,0 +1,188 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "java-lsp-proxy" +version = "6.8.12" +dependencies = [ + "libc", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml new file mode 100644 index 0000000..1a1936f --- /dev/null +++ b/proxy/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "java-lsp-proxy" +version = "6.8.12" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "Native LSP proxy for the Zed Java extension" + +[[bin]] +name = "java-lsp-proxy" +path = "src/main.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"] } diff --git a/proxy/src/main.rs b/proxy/src/main.rs new file mode 100644 index 0000000..4febf21 --- /dev/null +++ b/proxy/src/main.rs @@ -0,0 +1,523 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + env, + fs, + io::{self, BufRead, BufReader, Read, Write}, + net::TcpListener, + process::{self, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + thread, + time::Duration, +}; + +const CONTENT_LENGTH: &str = "Content-Length"; +const HEADER_SEP: &[u8] = b"\r\n\r\n"; +const TIMEOUT: Duration = Duration::from_secs(5); + +fn main() { + let args: Vec = env::args().skip(1).collect(); + if args.len() < 2 { + eprintln!("Usage: java-lsp-proxy [args...]"); + process::exit(1); + } + + let workdir = &args[0]; + let bin = &args[1]; + let child_args = &args[2..]; + + let proxy_id = hex_encode(env::current_dir().unwrap().to_string_lossy().trim_end_matches('/')); + + // Spawn JDTLS (use shell on Windows for .bat files) + let mut cmd = Command::new(bin); + cmd.args(child_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + #[cfg(windows)] + if bin.ends_with(".bat") || bin.ends_with(".cmd") { + cmd = Command::new("cmd"); + cmd.arg("/C") + .arg(bin) + .args(child_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + } + + let mut child = cmd.spawn().unwrap_or_else(|e| { + eprintln!("Failed to spawn {bin}: {e}"); + process::exit(1); + }); + + let child_stdin = Arc::new(Mutex::new(child.stdin.take().unwrap())); + let child_stdout = child.stdout.take().unwrap(); + let alive = Arc::new(AtomicBool::new(true)); + + // Pending HTTP requests: id -> sender + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + + // HTTP server on random port + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let port_file = std::path::Path::new(workdir) + .join("proxy") + .join(&proxy_id); + fs::create_dir_all(port_file.parent().unwrap()).unwrap(); + fs::write(&port_file, port.to_string()).unwrap(); + + // ID generator for HTTP-originated requests + let id_counter = Arc::new(std::sync::atomic::AtomicU64::new(1)); + + // --- Thread 1: Zed stdin -> JDTLS stdin (passthrough) --- + let stdin_writer = Arc::clone(&child_stdin); + let alive_stdin = Arc::clone(&alive); + thread::spawn(move || { + let stdin = io::stdin().lock(); + let mut reader = LspReader::new(stdin); + while alive_stdin.load(Ordering::Relaxed) { + match reader.read_message() { + Ok(Some(raw)) => { + let mut w = stdin_writer.lock().unwrap(); + if w.write_all(&raw).is_err() || w.flush().is_err() { + break; + } + } + Ok(None) => break, + Err(_) => break, + } + } + alive_stdin.store(false, Ordering::Relaxed); + }); + + // --- Thread 2: JDTLS stdout -> modify completions -> Zed stdout / resolve pending --- + let pending_out = Arc::clone(&pending); + let alive_out = Arc::clone(&alive); + thread::spawn(move || { + let mut reader = LspReader::new(BufReader::new(child_stdout)); + let stdout = io::stdout(); + while alive_out.load(Ordering::Relaxed) { + match reader.read_message() { + Ok(Some(raw)) => { + let parsed: Option = parse_lsp_content(&raw); + + // Check if this is a response to a pending HTTP request + if let Some(ref msg) = parsed { + if let Some(id) = msg.get("id") { + let sender = pending_out.lock().unwrap().remove(id); + if let Some(tx) = sender { + let _ = tx.send(msg.clone()); + continue; + } + } + } + + // Modify completion responses + if let Some(mut msg) = parsed { + if should_sort_completions(&msg) { + sort_completions_by_param_count(&mut msg); + let out = encode_lsp(&msg); + let mut w = stdout.lock(); + let _ = w.write_all(out.as_bytes()); + let _ = w.flush(); + continue; + } + } + + // Passthrough + let mut w = stdout.lock(); + let _ = w.write_all(&raw); + let _ = w.flush(); + } + Ok(None) => break, + Err(_) => break, + } + } + alive_out.store(false, Ordering::Relaxed); + }); + + // --- Thread 3: HTTP server for extension requests --- + let http_writer = Arc::clone(&child_stdin); + let http_pending = Arc::clone(&pending); + let http_alive = Arc::clone(&alive); + let http_id_counter = Arc::clone(&id_counter); + let http_proxy_id = proxy_id.clone(); + thread::spawn(move || { + listener.set_nonblocking(false).unwrap(); + for stream in listener.incoming() { + if !http_alive.load(Ordering::Relaxed) { + break; + } + let stream = match stream { + Ok(s) => s, + Err(_) => continue, + }; + let writer = Arc::clone(&http_writer); + let pend = Arc::clone(&http_pending); + let counter = Arc::clone(&http_id_counter); + let pid = http_proxy_id.clone(); + + thread::spawn(move || { + handle_http(stream, writer, pend, counter, &pid); + }); + } + }); + + // --- Thread 4: Parent process monitor --- + let alive_monitor = Arc::clone(&alive); + let child_pid = child.id(); + spawn_parent_monitor(alive_monitor, child_pid); + + // Wait for child to exit + let _ = child.wait(); + alive.store(false, Ordering::Relaxed); + + // Cleanup port file + let _ = fs::remove_file(&port_file); +} + +// --- Platform-specific parent process monitoring --- + +#[cfg(unix)] +fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + thread::spawn(move || { + let ppid = unsafe { libc::getppid() }; + loop { + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; + } + if unsafe { libc::kill(ppid, 0) } != 0 { + alive.store(false, Ordering::Relaxed); + unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; + break; + } + } + }); +} + +#[cfg(windows)] +fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}, + }; + + // Get parent PID via environment or OS API + let ppid = parent_pid_windows(); + + thread::spawn(move || { + // Open a handle to the parent process + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; + if handle.is_null() { + // Can't monitor parent — fall back to polling + return; + } + + loop { + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; + } + // WaitForSingleObject with 0 timeout = non-blocking check + // Returns 0 (WAIT_OBJECT_0) if process has exited + if unsafe { WaitForSingleObject(handle, 0) } == 0 { + alive.store(false, Ordering::Relaxed); + // taskkill /T /F kills the process tree + let _ = Command::new("taskkill") + .args(["/pid", &child_pid.to_string(), "/T", "/F"]) + .spawn(); + break; + } + } + unsafe { CloseHandle(handle) }; + }); +} + +#[cfg(windows)] +fn parent_pid_windows() -> u32 { + use windows_sys::Win32::System::Threading::{ + GetCurrentProcessId, CreateToolhelp32Snapshot, Process32First, Process32Next, + PROCESSENTRY32, TH32CS_SNAPPROCESS, + }; + + unsafe { + let pid = GetCurrentProcessId(); + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap.is_null() { + return 0; + } + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + if Process32First(snap, &mut entry) != 0 { + loop { + if entry.th32ProcessID == pid { + CloseHandle(snap); + return entry.th32ParentProcessID; + } + if Process32Next(snap, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snap); + 0 + } +} + +// --- LSP message reader --- + +struct LspReader { + reader: R, +} + +impl LspReader { + fn new(reader: R) -> Self { + Self { reader } + } + + 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)) + } +} + +// --- LSP message encoding/parsing --- + +fn parse_lsp_content(raw: &[u8]) -> Option { + let sep_pos = raw.windows(4).position(|w| w == HEADER_SEP)?; + let content = &raw[sep_pos + 4..]; + serde_json::from_slice(content).ok() +} + +fn encode_lsp(value: &Value) -> String { + let json = serde_json::to_string(value).unwrap(); + format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) +} + +// --- Completion sorting --- + +fn should_sort_completions(msg: &Value) -> bool { + if let Some(result) = msg.get("result") { + result.get("items").is_some_and(|v| v.is_array()) || result.is_array() + } else { + false + } +} + +fn sort_completions_by_param_count(msg: &mut Value) { + let items = if let Some(result) = msg.get_mut("result") { + if result.is_array() { + result.as_array_mut() + } else { + result.get_mut("items").and_then(|v| v.as_array_mut()) + } + } else { + None + }; + + if let Some(items) = items { + for item in items.iter_mut() { + let kind = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + if kind == 2 || kind == 3 { + let detail = item + .pointer("/labelDetails/detail") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let count = count_params(detail); + let prefix = format!("{count:02}"); + let existing = item + .get("sortText") + .and_then(|v| v.as_str()) + .unwrap_or(""); + item["sortText"] = Value::String(format!("{prefix}{existing}")); + } + } + } +} + +fn count_params(detail: &str) -> usize { + if detail.is_empty() || detail == "()" { + return 0; + } + let inner = detail + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(detail) + .trim(); + if inner.is_empty() { + return 0; + } + let mut count = 1usize; + let mut depth = 0i32; + for ch in inner.chars() { + match ch { + '<' => depth += 1, + '>' => depth -= 1, + ',' if depth == 0 => count += 1, + _ => {} + } + } + count +} + +// --- HTTP handler --- + +#[derive(Deserialize)] +struct HttpBody { + method: String, + params: Value, +} + +#[derive(Serialize)] +struct LspRequest { + jsonrpc: &'static str, + id: Value, + method: String, + params: Value, +} + +fn handle_http( + mut stream: std::net::TcpStream, + writer: Arc>, + pending: Arc>>>, + counter: Arc, + proxy_id: &str, +) { + let mut reader = BufReader::new(&stream); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + + if !request_line.starts_with("POST") { + let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n"); + return; + } + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + break; + } + if let Some((name, value)) = trimmed.split_once(": ") { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().unwrap_or(0); + } + } + } + + let mut body = vec![0u8; content_length]; + if reader.read_exact(&mut body).is_err() { + return; + } + + let req: HttpBody = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n"); + return; + } + }; + + let seq = counter.fetch_add(1, Ordering::Relaxed); + let id = Value::String(format!("{proxy_id}-{seq}")); + + let (tx, rx) = mpsc::channel(); + pending.lock().unwrap().insert(id.clone(), tx); + + let lsp_req = LspRequest { + jsonrpc: "2.0", + id: id.clone(), + method: req.method, + params: req.params, + }; + let encoded = encode_lsp(&serde_json::to_value(&lsp_req).unwrap()); + { + let mut w = writer.lock().unwrap(); + let _ = w.write_all(encoded.as_bytes()); + let _ = w.flush(); + } + + let response = match rx.recv_timeout(TIMEOUT) { + Ok(resp) => resp, + Err(_) => { + pending.lock().unwrap().remove(&id); + let cancel = encode_lsp(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": id } + })); + let mut w = writer.lock().unwrap(); + let _ = w.write_all(cancel.as_bytes()); + let _ = w.flush(); + + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32803, + "message": "Request to language server timed out after 5000ms." + } + }) + } + }; + + let resp_body = serde_json::to_vec(&response).unwrap(); + let http_resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + resp_body.len() + ); + let _ = stream.write_all(http_resp.as_bytes()); + let _ = stream.write_all(&resp_body); +} + +// --- Utilities --- + +fn hex_encode(s: &str) -> String { + s.as_bytes().iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/java.rs b/src/java.rs index 05f7438..038aac9 100644 --- a/src/java.rs +++ b/src/java.rs @@ -3,6 +3,7 @@ mod debugger; mod jdk; mod jdtls; mod lsp; +mod proxy; mod util; use std::{ @@ -13,8 +14,9 @@ use std::{ }; use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, Extension, - LanguageServerId, LanguageServerInstallationStatus, StartDebuggingRequestArguments, + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, + Extension, LanguageServerId, + LanguageServerInstallationStatus, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, Worktree, lsp::{Completion, CompletionKind, Symbol, SymbolKind}, register_extension, @@ -35,13 +37,13 @@ use crate::{ util::path_to_string, }; -const PROXY_FILE: &str = include_str!("proxy.mjs"); const DEBUG_ADAPTER_NAME: &str = "Java"; const LSP_INIT_ERROR: &str = "Lsp client is not initialized yet"; struct Java { cached_binary_path: Option, cached_lombok_path: Option, + cached_proxy_path: Option, integrations: Option<(LspWrapper, Debugger)>, } @@ -151,6 +153,7 @@ impl Extension for Java { Self { cached_binary_path: None, cached_lombok_path: None, + cached_proxy_path: None, integrations: None, } } @@ -289,11 +292,16 @@ impl Extension for Java { env.push(("JAVA_HOME".to_string(), java_home)); } - // our proxy takes workdir, bin, argv + let proxy_path = proxy::binary_path( + &mut self.cached_proxy_path, + &configuration, + language_server_id, + worktree, + ) + .map_err(|err| format!("Failed to get proxy binary path: {err}"))?; + + // proxy takes: workdir, bin, [args...] let mut args = vec![ - "--input-type=module".to_string(), - "-e".to_string(), - PROXY_FILE.to_string(), path_to_string(current_dir.clone()) .map_err(|err| format!("Failed to convert current directory to string: {err}"))?, ]; @@ -354,8 +362,7 @@ impl Extension for Java { .map_err(|err| format!("Failed to switch LSP workspace: {err}"))?; Ok(zed::Command { - command: zed::node_binary_path() - .map_err(|err| format!("Failed to get Node.js binary path: {err}"))?, + command: proxy_path, args, env, }) diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..ee90dea --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,136 @@ +use std::{fs::metadata, path::PathBuf}; + +use serde_json::Value; +use zed_extension_api::{ + self as zed, DownloadedFileType, GithubReleaseOptions, LanguageServerId, + LanguageServerInstallationStatus, Worktree, set_language_server_installation_status, + serde_json, +}; + +use crate::util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}; + +const PROXY_BINARY: &str = "java-lsp-proxy"; +const PROXY_INSTALL_PATH: &str = "proxy-bin"; +const GITHUB_REPO: &str = "tartarughina/java"; + +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)) +} + +fn find_latest_local() -> Option { + let local_binary = PathBuf::from(PROXY_INSTALL_PATH).join(PROXY_BINARY); + if metadata(&local_binary).is_ok_and(|m| m.is_file()) { + return Some(local_binary); + } + + // Check versioned downloads (e.g. proxy-bin/v1.0.0/java-lsp-proxy) + std::fs::read_dir(PROXY_INSTALL_PATH) + .ok()? + .filter_map(Result::ok) + .map(|e| e.path().join(PROXY_BINARY)) + .filter(|p| metadata(p).is_ok_and(|m| m.is_file())) + .last() +} + +pub fn binary_path( + cached: &mut Option, + configuration: &Option, + language_server_id: &LanguageServerId, + worktree: &Worktree, +) -> zed::Result { + // 1. Respect check_updates setting (Never/Once/Always) + // Returns Some(path) when local install exists and policy says use it. + // Returns None when policy allows downloading. + // Returns Err when policy is Never/Once-exhausted with no local install. + match should_use_local_or_download(configuration, find_latest_local(), PROXY_INSTALL_PATH) { + Ok(Some(path)) => { + let s = path.to_string_lossy().to_string(); + *cached = Some(s.clone()); + return Ok(s); + } + Ok(None) => { /* policy allows download, continue */ } + Err(_) => { + // Never/Once with no managed install — fall through to PATH as last resort + } + } + + // 2. Auto-download from GitHub releases + if let Ok((name, file_type)) = asset_name() { + if let Ok(release) = zed::latest_github_release( + GITHUB_REPO, + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + ) { + let bin_path = format!("{PROXY_INSTALL_PATH}/{}/java-lsp-proxy", release.version); + + if metadata(&bin_path).is_ok() { + *cached = Some(bin_path.clone()); + return Ok(bin_path); + } + + if let Some(asset) = release.assets.iter().find(|a| a.name == name) { + let version_dir = format!("{PROXY_INSTALL_PATH}/{}", release.version); + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + if zed::download_file( + &asset.download_url, + &version_dir, + file_type, + ) + .is_ok() + { + let _ = zed::make_file_executable(&bin_path); + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + let _ = remove_all_files_except(PROXY_INSTALL_PATH, &release.version); + let _ = mark_checked_once(PROXY_INSTALL_PATH, &release.version); + *cached = Some(bin_path.clone()); + return Ok(bin_path); + } + } + } + } + + // 3. Fallback: binary on $PATH + if let Some(path) = worktree.which(PROXY_BINARY) { + return Ok(path); + } + + // 4. Stale cache fallback + if let Some(path) = cached.as_deref() { + if metadata(path).is_ok() { + return Ok(path.to_string()); + } + } + + Err(format!( + "'{PROXY_BINARY}' not found. Either: \ + (a) add it to $PATH, or \ + (b) place it at /{PROXY_INSTALL_PATH}/{PROXY_BINARY}. \ + Build with: cd proxy && cargo build --release --target $(rustc -vV | grep host | awk '{{print $2}}')" + )) +} From 9c088c657070a72b9cb15c99a07ef1e6c7c80090 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 8 Mar 2026 22:29:35 +0100 Subject: [PATCH 02/14] Apply format, remove old proxy and add justfile --- justfile | 42 ++++++ proxy/src/main.rs | 115 +++++++--------- src/java.rs | 5 +- src/proxy.mjs | 341 ---------------------------------------------- src/proxy.rs | 39 +++--- src/util.rs | 3 +- 6 files changed, 112 insertions(+), 433 deletions(-) create mode 100644 justfile delete mode 100644 src/proxy.mjs diff --git a/justfile b/justfile new file mode 100644 index 0000000..e1cba49 --- /dev/null +++ b/justfile @@ -0,0 +1,42 @@ +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}} + +# Build proxy in release mode +proxy-release: + cd proxy && cargo build --release --target {{native_target}} + +# Build proxy release and install to extension workdir for testing +proxy-install: proxy-release + mkdir -p "{{ext_dir}}/proxy-bin" + cp "proxy/target/{{native_target}}/release/java-lsp-proxy" "{{proxy_bin}}" + @echo "Installed to {{proxy_bin}}" + +# Build WASM extension in release mode +ext-build: + cargo build --release + +# Format all code +fmt: + cargo fmt --all + cd proxy && cargo fmt --all + +# Run clippy on both crates +clippy: + cargo clippy --all-targets --fix --allow-dirty + cd proxy && cargo clippy --all-targets --fix --allow-dirty --target {{native_target}} + +# Build everything: fmt, clippy, extension, proxy install +all: fmt clippy ext-build proxy-install diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 4febf21..7969fd8 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -2,13 +2,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::HashMap, - env, - fs, + env, fs, io::{self, BufRead, BufReader, Read, Write}, net::TcpListener, + path::Path, process::{self, Command, Stdio}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, Arc, Mutex, }, thread, @@ -30,7 +30,12 @@ fn main() { let bin = &args[1]; let child_args = &args[2..]; - let proxy_id = hex_encode(env::current_dir().unwrap().to_string_lossy().trim_end_matches('/')); + let proxy_id = hex_encode( + env::current_dir() + .unwrap() + .to_string_lossy() + .trim_end_matches('/'), + ); // Spawn JDTLS (use shell on Windows for .bat files) let mut cmd = Command::new(bin); @@ -59,22 +64,17 @@ fn main() { let child_stdout = child.stdout.take().unwrap(); let alive = Arc::new(AtomicBool::new(true)); - // Pending HTTP requests: id -> sender let pending: Arc>>> = Arc::new(Mutex::new(HashMap::new())); - // HTTP server on random port let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); - let port_file = std::path::Path::new(workdir) - .join("proxy") - .join(&proxy_id); + let port_file = Path::new(workdir).join("proxy").join(&proxy_id); fs::create_dir_all(port_file.parent().unwrap()).unwrap(); fs::write(&port_file, port.to_string()).unwrap(); - // ID generator for HTTP-originated requests - let id_counter = Arc::new(std::sync::atomic::AtomicU64::new(1)); + let id_counter = Arc::new(AtomicU64::new(1)); // --- Thread 1: Zed stdin -> JDTLS stdin (passthrough) --- let stdin_writer = Arc::clone(&child_stdin); @@ -90,8 +90,7 @@ fn main() { break; } } - Ok(None) => break, - Err(_) => break, + Ok(None) | Err(_) => break, } } alive_stdin.store(false, Ordering::Relaxed); @@ -106,29 +105,29 @@ fn main() { while alive_out.load(Ordering::Relaxed) { match reader.read_message() { Ok(Some(raw)) => { - let parsed: Option = parse_lsp_content(&raw); - - // Check if this is a response to a pending HTTP request - if let Some(ref msg) = parsed { - if let Some(id) = msg.get("id") { - let sender = pending_out.lock().unwrap().remove(id); - if let Some(tx) = sender { - let _ = tx.send(msg.clone()); - continue; - } + let Some(mut msg) = parse_lsp_content(&raw) else { + let mut w = stdout.lock(); + let _ = w.write_all(&raw); + let _ = w.flush(); + continue; + }; + + // Route responses to pending HTTP requests + if let Some(id) = msg.get("id") { + if let Some(tx) = pending_out.lock().unwrap().remove(id) { + let _ = tx.send(msg); + continue; } } - // Modify completion responses - if let Some(mut msg) = parsed { - if should_sort_completions(&msg) { - sort_completions_by_param_count(&mut msg); - let out = encode_lsp(&msg); - let mut w = stdout.lock(); - let _ = w.write_all(out.as_bytes()); - let _ = w.flush(); - continue; - } + // Sort completion responses by param count + if should_sort_completions(&msg) { + sort_completions_by_param_count(&mut msg); + let out = encode_lsp(&msg); + let mut w = stdout.lock(); + let _ = w.write_all(out.as_bytes()); + let _ = w.flush(); + continue; } // Passthrough @@ -136,8 +135,7 @@ fn main() { let _ = w.write_all(&raw); let _ = w.flush(); } - Ok(None) => break, - Err(_) => break, + Ok(None) | Err(_) => break, } } alive_out.store(false, Ordering::Relaxed); @@ -150,15 +148,11 @@ fn main() { let http_id_counter = Arc::clone(&id_counter); let http_proxy_id = proxy_id.clone(); thread::spawn(move || { - listener.set_nonblocking(false).unwrap(); for stream in listener.incoming() { if !http_alive.load(Ordering::Relaxed) { break; } - let stream = match stream { - Ok(s) => s, - Err(_) => continue, - }; + let Ok(stream) = stream else { continue }; let writer = Arc::clone(&http_writer); let pend = Arc::clone(&http_pending); let counter = Arc::clone(&http_id_counter); @@ -171,15 +165,11 @@ fn main() { }); // --- Thread 4: Parent process monitor --- - let alive_monitor = Arc::clone(&alive); - let child_pid = child.id(); - spawn_parent_monitor(alive_monitor, child_pid); + spawn_parent_monitor(Arc::clone(&alive), child.id()); // Wait for child to exit let _ = child.wait(); alive.store(false, Ordering::Relaxed); - - // Cleanup port file let _ = fs::remove_file(&port_file); } @@ -210,14 +200,11 @@ fn spawn_parent_monitor(alive: Arc, child_pid: u32) { System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}, }; - // Get parent PID via environment or OS API let ppid = parent_pid_windows(); thread::spawn(move || { - // Open a handle to the parent process let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; if handle.is_null() { - // Can't monitor parent — fall back to polling return; } @@ -226,11 +213,8 @@ fn spawn_parent_monitor(alive: Arc, child_pid: u32) { if !alive.load(Ordering::Relaxed) { break; } - // WaitForSingleObject with 0 timeout = non-blocking check - // Returns 0 (WAIT_OBJECT_0) if process has exited if unsafe { WaitForSingleObject(handle, 0) } == 0 { alive.store(false, Ordering::Relaxed); - // taskkill /T /F kills the process tree let _ = Command::new("taskkill") .args(["/pid", &child_pid.to_string(), "/T", "/F"]) .spawn(); @@ -244,7 +228,7 @@ fn spawn_parent_monitor(alive: Arc, child_pid: u32) { #[cfg(windows)] fn parent_pid_windows() -> u32 { use windows_sys::Win32::System::Threading::{ - GetCurrentProcessId, CreateToolhelp32Snapshot, Process32First, Process32Next, + CreateToolhelp32Snapshot, GetCurrentProcessId, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, }; @@ -285,7 +269,6 @@ impl LspReader { 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) { @@ -324,8 +307,7 @@ impl LspReader { fn parse_lsp_content(raw: &[u8]) -> Option { let sep_pos = raw.windows(4).position(|w| w == HEADER_SEP)?; - let content = &raw[sep_pos + 4..]; - serde_json::from_slice(content).ok() + serde_json::from_slice(&raw[sep_pos + 4..]).ok() } fn encode_lsp(value: &Value) -> String { @@ -333,14 +315,17 @@ fn encode_lsp(value: &Value) -> String { format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) } +fn encode_lsp_serializable(value: &impl Serialize) -> String { + let json = serde_json::to_string(value).unwrap(); + format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) +} + // --- Completion sorting --- fn should_sort_completions(msg: &Value) -> bool { - if let Some(result) = msg.get("result") { + msg.get("result").is_some_and(|result| { result.get("items").is_some_and(|v| v.is_array()) || result.is_array() - } else { - false - } + }) } fn sort_completions_by_param_count(msg: &mut Value) { @@ -363,12 +348,8 @@ fn sort_completions_by_param_count(msg: &mut Value) { .and_then(|v| v.as_str()) .unwrap_or(""); let count = count_params(detail); - let prefix = format!("{count:02}"); - let existing = item - .get("sortText") - .and_then(|v| v.as_str()) - .unwrap_or(""); - item["sortText"] = Value::String(format!("{prefix}{existing}")); + let existing = item.get("sortText").and_then(|v| v.as_str()).unwrap_or(""); + item["sortText"] = Value::String(format!("{count:02}{existing}")); } } } @@ -419,7 +400,7 @@ fn handle_http( mut stream: std::net::TcpStream, writer: Arc>, pending: Arc>>>, - counter: Arc, + counter: Arc, proxy_id: &str, ) { let mut reader = BufReader::new(&stream); @@ -476,7 +457,7 @@ fn handle_http( method: req.method, params: req.params, }; - let encoded = encode_lsp(&serde_json::to_value(&lsp_req).unwrap()); + let encoded = encode_lsp_serializable(&lsp_req); { let mut w = writer.lock().unwrap(); let _ = w.write_all(encoded.as_bytes()); diff --git a/src/java.rs b/src/java.rs index 038aac9..e74afdb 100644 --- a/src/java.rs +++ b/src/java.rs @@ -14,9 +14,8 @@ use std::{ }; use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, - Extension, LanguageServerId, - LanguageServerInstallationStatus, StartDebuggingRequestArguments, + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, Extension, + LanguageServerId, LanguageServerInstallationStatus, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, Worktree, lsp::{Completion, CompletionKind, Symbol, SymbolKind}, register_extension, diff --git a/src/proxy.mjs b/src/proxy.mjs deleted file mode 100644 index 41bb04e..0000000 --- a/src/proxy.mjs +++ /dev/null @@ -1,341 +0,0 @@ -import { Buffer } from "node:buffer"; -import { spawn, exec } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { - existsSync, - mkdirSync, - readdirSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { Transform } from "node:stream"; -import { text } from "node:stream/consumers"; - -const HTTP_PORT = 0; // 0 - random free one -const HEADER_SEPARATOR = Buffer.from("\r\n", "ascii"); -const CONTENT_SEPARATOR = Buffer.from("\r\n\r\n", "ascii"); -const NAME_VALUE_SEPARATOR = Buffer.from(": ", "ascii"); -const LENGTH_HEADER = "Content-Length"; -const TIMEOUT = 5_000; - -const workdir = process.argv[1]; -const bin = process.argv[2]; -const args = process.argv.slice(3); - -const PROXY_ID = Buffer.from(process.cwd().replace(/\/+$/, "")).toString("hex"); -const PROXY_HTTP_PORT_FILE = join(workdir, "proxy", PROXY_ID); -const isWindows = process.platform === "win32"; -const command = (isWindows && bin.endsWith(".bat")) ? `"${bin}"` : bin; - -const lsp = spawn(command, args, { - shell: (isWindows && bin.endsWith(".bat")), - detached: false, -}); - -function cleanup() { - if (!lsp || lsp.killed || lsp.exitCode !== null) { - return; - } - - if (isWindows) { - // Windows: Use taskkill to kill the process tree (cmd.exe + the child) - // /T = Tree kill (child processes), /F = Force - exec(`taskkill /pid ${lsp.pid} /T /F`); - } else { - lsp.kill("SIGTERM"); - setTimeout(() => { - if (!lsp.killed && lsp.exitCode === null) { - lsp.kill("SIGKILL"); - } - }, 1000); - } -} - -// Handle graceful IDE shutdown via stdin close -process.stdin.on("end", () => { - cleanup(); - process.exit(0); -}); -// Ensure node is monitoring the pipe -process.stdin.resume(); - -// Fallback: monitor parent process for ungraceful shutdown -setInterval(() => { - try { - // Check if parent is still alive - process.kill(process.ppid, 0); - } catch (e) { - // On Windows, checking a process you don't own might throw EPERM. - // We only want to kill if the error is ESRCH (No Such Process). - if (e.code === "ESRCH") { - cleanup(); - process.exit(0); - } - // If e.code is EPERM, the parent is alive but we don't have permission to signal it. - // Do nothing. - } -}, 5000); - -const proxy = createLspProxy({ server: lsp, proxy: process }); - -proxy.on("client", (data, passthrough) => { - passthrough(); -}); -proxy.on("server", (data, passthrough) => { - passthrough(); -}); - -function countParams(detail) { - if (!detail || detail === "()") return 0; - const inner = detail.slice(1, -1).trim(); - if (inner === "") return 0; - let count = 1, depth = 0; - for (const ch of inner) { - if (ch === "<") depth++; - else if (ch === ">") depth--; - else if (ch === "," && depth === 0) count++; - } - return count; -} - -function sortCompletionsByParamCount(result) { - const items = Array.isArray(result) ? result : result?.items; - if (!Array.isArray(items)) return; - for (const item of items) { - if (item.kind === 2 || item.kind === 3) { // Method or Function - const paramCount = countParams(item.labelDetails?.detail); - item.sortText = String(paramCount).padStart(2, "0") + (item.sortText || ""); - } - } -} - -const server = createServer(async (req, res) => { - if (req.method !== "POST") { - res.status = 405; - res.end("Method not allowed"); - return; - } - - const data = await text(req) - .then(safeJsonParse) - .catch(() => null); - - if (!data) { - res.status = 400; - res.end("Bad Request"); - return; - } - - const result = await proxy.request(data.method, data.params); - res.statusCode = 200; - res.setHeader("Content-Type", "application/json"); - res.write(JSON.stringify(result)); - res.end(); -}).listen(HTTP_PORT, () => { - mkdirSync(dirname(PROXY_HTTP_PORT_FILE), { recursive: true }); - writeFileSync(PROXY_HTTP_PORT_FILE, server.address().port.toString()); -}); - -export function createLspProxy({ - server: { stdin: serverStdin, stdout: serverStdout, stderr: serverStderr }, - proxy: { stdin: proxyStdin, stdout: proxyStdout, stderr: proxyStderr }, -}) { - const events = new EventEmitter(); - const queue = new Map(); - const nextid = iterid(); - - proxyStdin.pipe(lspMessageSeparator()).on("data", (data) => { - events.emit("client", parse(data), () => serverStdin.write(data)); - }); - - serverStdout.pipe(lspMessageSeparator()).on("data", (data) => { - const message = parse(data); - - const pending = queue.get(message?.id); - if (pending) { - pending(message); - queue.delete(message.id); - return; - } - - // Modify completion responses to sort by param count - if (message?.result?.items || Array.isArray(message?.result)) { - sortCompletionsByParamCount(message.result); - proxyStdout.write(stringify(message)); - return; - } - - events.emit("server", message, () => proxyStdout.write(data)); - }); - - serverStderr.pipe(proxyStderr); - - return Object.assign(events, { - /** - * - * @param {string} method - * @param {any} params - * @returns void - */ - notification(method, params) { - proxyStdout.write(stringify({ jsonrpc: "2.0", method, params })); - }, - - /** - * - * @param {string} method - * @param {any} params - * @returns Promise - */ - request(method, params) { - return new Promise((resolve, reject) => { - const id = nextid(); - queue.set(id, resolve); - - setTimeout(() => { - if (queue.has(id)) { - reject({ - jsonrpc: "2.0", - id, - error: { - code: -32803, - message: "Request to language server timed out after 5000ms.", - }, - }); - this.cancel(id); - } - }, TIMEOUT); - - serverStdin.write(stringify({ jsonrpc: "2.0", id, method, params })); - }); - }, - - cancel(id) { - queue.delete(id); - - serverStdin.write( - stringify({ - jsonrpc: "2.0", - method: "$/cancelRequest", - params: { id }, - }), - ); - }, - }); -} - -function iterid() { - let acc = 1; - return () => PROXY_ID + "-" + acc++; -} - -/** - * The base protocol consists of a header and a content part (comparable to HTTP). - * The header and content part are separated by a ‘\r\n’. - * - * The header part consists of header fields. - * Each header field is comprised of a name and a value, - * separated by ‘: ‘ (a colon and a space). - * The structure of header fields conforms to the HTTP semantic. - * Each header field is terminated by ‘\r\n’. - * Considering the last header field and the overall header - * itself are each terminated with ‘\r\n’, - * and that at least one header is mandatory, - * this means that two ‘\r\n’ sequences always immediately precede - * the content part of a message. - * - * @returns {Transform} - * @see [language-server-protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart) - */ -function lspMessageSeparator() { - let buffer = Buffer.alloc(0); - let contentLength = null; - let headersLength = null; - - return new Transform({ - transform(chunk, encoding, callback) { - buffer = Buffer.concat([buffer, chunk]); - - // A single chunk may contain multiple messages - while (true) { - // Wait until we get the whole headers block - if (buffer.indexOf(CONTENT_SEPARATOR) === -1) { - break; - } - - if (!headersLength) { - const headersEnd = buffer.indexOf(CONTENT_SEPARATOR); - const headers = Object.fromEntries( - buffer - .subarray(0, headersEnd) - .toString() - .split(HEADER_SEPARATOR) - .map((header) => header.split(NAME_VALUE_SEPARATOR)) - .map(([name, value]) => [name.toLowerCase(), value]), - ); - - // A "Content-Length" header must always be present - contentLength = parseInt(headers[LENGTH_HEADER.toLowerCase()], 10); - headersLength = headersEnd + CONTENT_SEPARATOR.length; - } - - const msgLength = headersLength + contentLength; - - // Wait until we get the whole content part - if (buffer.length < msgLength) { - break; - } - - this.push(buffer.subarray(0, msgLength)); - - buffer = buffer.subarray(msgLength); - contentLength = null; - headersLength = null; - } - - callback(); - }, - }); -} - -/** - * - * @param {any} content - * @returns {string} - */ -function stringify(content) { - const json = JSON.stringify(content); - return ( - LENGTH_HEADER + - NAME_VALUE_SEPARATOR + - json.length + - CONTENT_SEPARATOR + - json - ); -} - -/** - * - * @param {string} message - * @returns {any | null} - */ -function parse(message) { - const content = message.slice(message.indexOf(CONTENT_SEPARATOR)); - return safeJsonParse(content); -} - -/** - * - * @param {string} json - * @returns {any | null} - */ -function safeJsonParse(json) { - try { - return JSON.parse(json); - } catch (err) { - return null; - } -} diff --git a/src/proxy.rs b/src/proxy.rs index ee90dea..f3a528d 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -3,15 +3,15 @@ use std::{fs::metadata, path::PathBuf}; use serde_json::Value; use zed_extension_api::{ self as zed, DownloadedFileType, GithubReleaseOptions, LanguageServerId, - LanguageServerInstallationStatus, Worktree, set_language_server_installation_status, - serde_json, + LanguageServerInstallationStatus, Worktree, serde_json, + set_language_server_installation_status, }; use crate::util::{mark_checked_once, remove_all_files_except, should_use_local_or_download}; const PROXY_BINARY: &str = "java-lsp-proxy"; const PROXY_INSTALL_PATH: &str = "proxy-bin"; -const GITHUB_REPO: &str = "tartarughina/java"; +const GITHUB_REPO: &str = "zed-extensions/java"; fn asset_name() -> zed::Result<(String, DownloadedFileType)> { let (os, arch) = zed::current_platform(); @@ -30,7 +30,10 @@ fn asset_name() -> zed::Result<(String, DownloadedFileType)> { } else { "tar.gz" }; - Ok((format!("java-lsp-proxy-{os_str}-{arch_str}.{ext}"), file_type)) + Ok(( + format!("java-lsp-proxy-{os_str}-{arch_str}.{ext}"), + file_type, + )) } fn find_latest_local() -> Option { @@ -39,7 +42,7 @@ fn find_latest_local() -> Option { return Some(local_binary); } - // Check versioned downloads (e.g. proxy-bin/v1.0.0/java-lsp-proxy) + // Check versioned downloads (e.g. proxy-bin/v6.8.12/java-lsp-proxy) std::fs::read_dir(PROXY_INSTALL_PATH) .ok()? .filter_map(Result::ok) @@ -94,13 +97,7 @@ pub fn binary_path( &LanguageServerInstallationStatus::Downloading, ); - if zed::download_file( - &asset.download_url, - &version_dir, - file_type, - ) - .is_ok() - { + if zed::download_file(&asset.download_url, &version_dir, file_type).is_ok() { let _ = zed::make_file_executable(&bin_path); set_language_server_installation_status( language_server_id, @@ -115,22 +112,24 @@ pub fn binary_path( } } - // 3. Fallback: binary on $PATH + // 3. Fallback: local install (covers "always" mode when download fails) + if let Some(path) = find_latest_local() { + let s = path.to_string_lossy().to_string(); + *cached = Some(s.clone()); + return Ok(s); + } + + // 4. Fallback: binary on $PATH if let Some(path) = worktree.which(PROXY_BINARY) { return Ok(path); } - // 4. Stale cache fallback + // 5. Stale cache fallback if let Some(path) = cached.as_deref() { if metadata(path).is_ok() { return Ok(path.to_string()); } } - Err(format!( - "'{PROXY_BINARY}' not found. Either: \ - (a) add it to $PATH, or \ - (b) place it at /{PROXY_INSTALL_PATH}/{PROXY_BINARY}. \ - Build with: cd proxy && cargo build --release --target $(rustc -vV | grep host | awk '{{print $2}}')" - )) + Err(format!("'{PROXY_BINARY}' not found")) } diff --git a/src/util.rs b/src/util.rs index 2f4984b..f78ebb1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -434,7 +434,7 @@ impl Serialize for ArgsStringOrList { .iter() .map(|s| { if s.contains(' ') { - format!("\"{}\"", s) + format!("\"{s}\"") } else { s.clone() } @@ -449,7 +449,6 @@ impl Serialize for ArgsStringOrList { #[cfg(test)] mod tests { use super::*; - use serde_json; #[derive(Deserialize, Serialize)] struct ArgsWrapper { From 5cb411646b0175f2397ac5ee66e637b02df49813 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Thu, 12 Mar 2026 14:55:52 +0100 Subject: [PATCH 03/14] Add Cargo target for cfg resolution and exe extension when OS is Windows --- proxy/.cargo/config.toml | 2 ++ src/proxy.rs | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 proxy/.cargo/config.toml diff --git a/proxy/.cargo/config.toml b/proxy/.cargo/config.toml new file mode 100644 index 0000000..e193946 --- /dev/null +++ b/proxy/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "aarch64-apple-darwin" diff --git a/src/proxy.rs b/src/proxy.rs index f3a528d..0fa30a5 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -36,8 +36,17 @@ fn asset_name() -> zed::Result<(String, DownloadedFileType)> { )) } +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"), + } +} + fn find_latest_local() -> Option { - let local_binary = PathBuf::from(PROXY_INSTALL_PATH).join(PROXY_BINARY); + let local_binary = PathBuf::from(PROXY_INSTALL_PATH).join(proxy_exec()); if metadata(&local_binary).is_ok_and(|m| m.is_file()) { return Some(local_binary); } @@ -46,7 +55,7 @@ fn find_latest_local() -> Option { std::fs::read_dir(PROXY_INSTALL_PATH) .ok()? .filter_map(Result::ok) - .map(|e| e.path().join(PROXY_BINARY)) + .map(|e| e.path().join(proxy_exec())) .filter(|p| metadata(p).is_ok_and(|m| m.is_file())) .last() } @@ -82,7 +91,7 @@ pub fn binary_path( pre_release: false, }, ) { - let bin_path = format!("{PROXY_INSTALL_PATH}/{}/java-lsp-proxy", release.version); + let bin_path = format!("{PROXY_INSTALL_PATH}/{}/{}", release.version, proxy_exec()); if metadata(&bin_path).is_ok() { *cached = Some(bin_path.clone()); @@ -120,7 +129,7 @@ pub fn binary_path( } // 4. Fallback: binary on $PATH - if let Some(path) = worktree.which(PROXY_BINARY) { + if let Some(path) = worktree.which(proxy_exec().as_str()) { return Ok(path); } @@ -131,5 +140,5 @@ pub fn binary_path( } } - Err(format!("'{PROXY_BINARY}' not found")) + Err(format!("'{}' not found", proxy_exec())) } From fecd28de32efb326f8a6cb1b359c3a75ed74ad0e Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 15:52:51 +0100 Subject: [PATCH 04/14] Fix imports and improve stability of Win path --- proxy/Cargo.toml | 2 +- proxy/src/main.rs | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index 1a1936f..595281a 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -18,4 +18,4 @@ serde_json = "1.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.59", features = ["Win32_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp"] } diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 7969fd8..52c191e 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -226,32 +226,53 @@ fn spawn_parent_monitor(alive: Arc, child_pid: u32) { } #[cfg(windows)] -fn parent_pid_windows() -> u32 { - use windows_sys::Win32::System::Threading::{ - CreateToolhelp32Snapshot, GetCurrentProcessId, Process32First, Process32Next, - PROCESSENTRY32, TH32CS_SNAPPROCESS, - }; +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, + }, + System::Threading::GetCurrentProcessId, +}; +#[cfg(windows)] +struct ScopedSnapshot(HANDLE); +#[cfg(windows)] +impl Drop for ScopedSnapshot { + fn drop(&mut self) { + if self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } +} +#[cfg(windows)] +pub fn parent_pid_windows() -> u32 { unsafe { let pid = GetCurrentProcessId(); - let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if snap.is_null() { + let snap_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if snap_handle == INVALID_HANDLE_VALUE { return 0; } + + // Wrap the handle. It will now automatically be closed when the function ends + let _snap = ScopedSnapshot(snap_handle); + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); entry.dwSize = std::mem::size_of::() as u32; - if Process32First(snap, &mut entry) != 0 { + + if Process32First(snap_handle, &mut entry) != 0 { loop { if entry.th32ProcessID == pid { - CloseHandle(snap); + // No need to manually call CloseHandle anymore! return entry.th32ParentProcessID; } - if Process32Next(snap, &mut entry) == 0 { + + if Process32Next(snap_handle, &mut entry) == 0 { break; } } } - CloseHandle(snap); + 0 } } From 917293a11e3ea00b7129f86b9114d4c3c9a870e9 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 16:33:30 +0100 Subject: [PATCH 05/14] Refactor platform-specific code into modules Move Unix and Windows parent monitoring implementations into separate `platform` modules --- proxy/src/main.rs | 164 +++++++++++++++++++++++++--------------------- 1 file changed, 89 insertions(+), 75 deletions(-) diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 52c191e..0dcd566 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -176,107 +176,121 @@ fn main() { // --- Platform-specific parent process monitoring --- #[cfg(unix)] -fn spawn_parent_monitor(alive: Arc, child_pid: u32) { - thread::spawn(move || { - let ppid = unsafe { libc::getppid() }; - loop { - thread::sleep(Duration::from_secs(5)); - if !alive.load(Ordering::Relaxed) { - break; - } - if unsafe { libc::kill(ppid, 0) } != 0 { - alive.store(false, Ordering::Relaxed); - unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; - break; +mod platform { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + use std::thread; + use std::time::Duration; + + pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + thread::spawn(move || { + let ppid = unsafe { libc::getppid() }; + loop { + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; + } + if unsafe { libc::kill(ppid, 0) } != 0 { + alive.store(false, Ordering::Relaxed); + unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; + break; + } } - } - }); + }); + } } #[cfg(windows)] -fn spawn_parent_monitor(alive: Arc, child_pid: u32) { +mod platform { + use std::process::Command; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + use std::thread; + use std::time::Duration; + use windows_sys::Win32::{ - Foundation::CloseHandle, - System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}, + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, + TH32CS_SNAPPROCESS, + }, + System::Threading::{ + GetCurrentProcessId, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, + }, }; - let ppid = parent_pid_windows(); + struct ScopedSnapshot(HANDLE); - thread::spawn(move || { - let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; - if handle.is_null() { - return; + impl Drop for ScopedSnapshot { + fn drop(&mut self) { + if self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } } + } - loop { - thread::sleep(Duration::from_secs(5)); - if !alive.load(Ordering::Relaxed) { - break; - } - if unsafe { WaitForSingleObject(handle, 0) } == 0 { - alive.store(false, Ordering::Relaxed); - let _ = Command::new("taskkill") - .args(["/pid", &child_pid.to_string(), "/T", "/F"]) - .spawn(); - break; + fn parent_pid() -> u32 { + unsafe { + let pid = GetCurrentProcessId(); + let snap_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if snap_handle == INVALID_HANDLE_VALUE { + return 0; } - } - unsafe { CloseHandle(handle) }; - }); -} -#[cfg(windows)] -use windows_sys::Win32::{ - Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, - System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, - }, - System::Threading::GetCurrentProcessId, -}; + let _snap = ScopedSnapshot(snap_handle); -#[cfg(windows)] -struct ScopedSnapshot(HANDLE); -#[cfg(windows)] -impl Drop for ScopedSnapshot { - fn drop(&mut self) { - if self.0 != INVALID_HANDLE_VALUE { - unsafe { CloseHandle(self.0) }; - } - } -} -#[cfg(windows)] -pub fn parent_pid_windows() -> u32 { - unsafe { - let pid = GetCurrentProcessId(); - let snap_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; - if snap_handle == INVALID_HANDLE_VALUE { - return 0; + if Process32First(snap_handle, &mut entry) != 0 { + loop { + if entry.th32ProcessID == pid { + return entry.th32ParentProcessID; + } + if Process32Next(snap_handle, &mut entry) == 0 { + break; + } + } + } + + 0 } + } - // Wrap the handle. It will now automatically be closed when the function ends - let _snap = ScopedSnapshot(snap_handle); + pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + let ppid = parent_pid(); - let mut entry: PROCESSENTRY32 = std::mem::zeroed(); - entry.dwSize = std::mem::size_of::() as u32; + thread::spawn(move || { + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; + if handle.is_null() { + return; + } - if Process32First(snap_handle, &mut entry) != 0 { loop { - if entry.th32ProcessID == pid { - // No need to manually call CloseHandle anymore! - return entry.th32ParentProcessID; + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; } - - if Process32Next(snap_handle, &mut entry) == 0 { + if unsafe { WaitForSingleObject(handle, 0) } == 0 { + alive.store(false, Ordering::Relaxed); + let _ = Command::new("taskkill") + .args(["/pid", &child_pid.to_string(), "/T", "/F"]) + .spawn(); break; } } - } - - 0 + unsafe { CloseHandle(handle) }; + }); } } +use platform::spawn_parent_monitor; + // --- LSP message reader --- struct LspReader { From 0863a763d198d18ffa5999c1e552f8bd2b7d58ae Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 22:46:55 +0100 Subject: [PATCH 06/14] Extract platform-specific code into separate module --- proxy/src/main.rs | 124 ++-------------------------------- proxy/src/platform/mod.rs | 9 +++ proxy/src/platform/unix.rs | 23 +++++++ proxy/src/platform/windows.rs | 82 ++++++++++++++++++++++ 4 files changed, 119 insertions(+), 119 deletions(-) create mode 100644 proxy/src/platform/mod.rs create mode 100644 proxy/src/platform/unix.rs create mode 100644 proxy/src/platform/windows.rs diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 0dcd566..0c2af43 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,3 +1,6 @@ +mod platform; + +use platform::spawn_parent_monitor; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ @@ -8,8 +11,9 @@ use std::{ path::Path, process::{self, Command, Stdio}, sync::{ + Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc, Arc, Mutex, + mpsc, }, thread, time::Duration, @@ -173,124 +177,6 @@ fn main() { let _ = fs::remove_file(&port_file); } -// --- Platform-specific parent process monitoring --- - -#[cfg(unix)] -mod platform { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }; - use std::thread; - use std::time::Duration; - - pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { - thread::spawn(move || { - let ppid = unsafe { libc::getppid() }; - loop { - thread::sleep(Duration::from_secs(5)); - if !alive.load(Ordering::Relaxed) { - break; - } - if unsafe { libc::kill(ppid, 0) } != 0 { - alive.store(false, Ordering::Relaxed); - unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; - break; - } - } - }); - } -} - -#[cfg(windows)] -mod platform { - use std::process::Command; - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }; - use std::thread; - use std::time::Duration; - - use windows_sys::Win32::{ - Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, - System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, - TH32CS_SNAPPROCESS, - }, - System::Threading::{ - GetCurrentProcessId, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, - }, - }; - - struct ScopedSnapshot(HANDLE); - - impl Drop for ScopedSnapshot { - fn drop(&mut self) { - if self.0 != INVALID_HANDLE_VALUE { - unsafe { CloseHandle(self.0) }; - } - } - } - - fn parent_pid() -> u32 { - unsafe { - let pid = GetCurrentProcessId(); - let snap_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - - if snap_handle == INVALID_HANDLE_VALUE { - return 0; - } - - let _snap = ScopedSnapshot(snap_handle); - - let mut entry: PROCESSENTRY32 = std::mem::zeroed(); - entry.dwSize = std::mem::size_of::() as u32; - - if Process32First(snap_handle, &mut entry) != 0 { - loop { - if entry.th32ProcessID == pid { - return entry.th32ParentProcessID; - } - if Process32Next(snap_handle, &mut entry) == 0 { - break; - } - } - } - - 0 - } - } - - pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { - let ppid = parent_pid(); - - thread::spawn(move || { - let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; - if handle.is_null() { - return; - } - - loop { - thread::sleep(Duration::from_secs(5)); - if !alive.load(Ordering::Relaxed) { - break; - } - if unsafe { WaitForSingleObject(handle, 0) } == 0 { - alive.store(false, Ordering::Relaxed); - let _ = Command::new("taskkill") - .args(["/pid", &child_pid.to_string(), "/T", "/F"]) - .spawn(); - break; - } - } - unsafe { CloseHandle(handle) }; - }); - } -} - -use platform::spawn_parent_monitor; - // --- LSP message reader --- struct LspReader { diff --git a/proxy/src/platform/mod.rs b/proxy/src/platform/mod.rs new file mode 100644 index 0000000..9e3dc8d --- /dev/null +++ b/proxy/src/platform/mod.rs @@ -0,0 +1,9 @@ +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +pub use unix::spawn_parent_monitor; +#[cfg(windows)] +pub use windows::spawn_parent_monitor; diff --git a/proxy/src/platform/unix.rs b/proxy/src/platform/unix.rs new file mode 100644 index 0000000..e2e68e0 --- /dev/null +++ b/proxy/src/platform/unix.rs @@ -0,0 +1,23 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; + +pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + thread::spawn(move || { + let ppid = unsafe { libc::getppid() }; + loop { + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; + } + if unsafe { libc::kill(ppid, 0) } != 0 { + alive.store(false, Ordering::Relaxed); + unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; + break; + } + } + }); +} diff --git a/proxy/src/platform/windows.rs b/proxy/src/platform/windows.rs new file mode 100644 index 0000000..d2dbd79 --- /dev/null +++ b/proxy/src/platform/windows.rs @@ -0,0 +1,82 @@ +use std::process::Command; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; + +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS, + }, + System::Threading::{ + GetCurrentProcessId, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }, +}; + +struct ScopedSnapshot(HANDLE); + +impl Drop for ScopedSnapshot { + fn drop(&mut self) { + if self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } +} + +fn parent_pid() -> u32 { + unsafe { + let pid = GetCurrentProcessId(); + let snap_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + + if snap_handle == INVALID_HANDLE_VALUE { + return 0; + } + + let _snap = ScopedSnapshot(snap_handle); + + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + + if Process32First(snap_handle, &mut entry) != 0 { + loop { + if entry.th32ProcessID == pid { + return entry.th32ParentProcessID; + } + if Process32Next(snap_handle, &mut entry) == 0 { + break; + } + } + } + + 0 + } +} + +pub fn spawn_parent_monitor(alive: Arc, child_pid: u32) { + let ppid = parent_pid(); + + thread::spawn(move || { + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, ppid) }; + if handle.is_null() { + return; + } + + loop { + thread::sleep(Duration::from_secs(5)); + if !alive.load(Ordering::Relaxed) { + break; + } + if unsafe { WaitForSingleObject(handle, 0) } == 0 { + alive.store(false, Ordering::Relaxed); + let _ = Command::new("taskkill") + .args(["/pid", &child_pid.to_string(), "/T", "/F"]) + .spawn(); + break; + } + } + unsafe { CloseHandle(handle) }; + }); +} From e3befe7e4c41fc00b94400d01b26c235ecec1f41 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 22:50:06 +0100 Subject: [PATCH 07/14] Remove benchmark report from git --- proxy/BENCHMARK.md | 85 ---------------------------------------------- 1 file changed, 85 deletions(-) delete mode 100644 proxy/BENCHMARK.md diff --git a/proxy/BENCHMARK.md b/proxy/BENCHMARK.md deleted file mode 100644 index 1de82da..0000000 --- a/proxy/BENCHMARK.md +++ /dev/null @@ -1,85 +0,0 @@ -# LSP Proxy Benchmark: Node.js vs Rust - -## Overview - -This report compares the performance of the existing Node.js LSP proxy (`proxy.mjs`) against the new native Rust replacement (`java-lsp-proxy`). Both proxies sit between Zed and JDTLS, forwarding LSP messages bidirectionally, sorting completion responses by parameter count, and exposing an HTTP server for extension-originated requests. - -## Methodology - -- Both proxies were instrumented with high-resolution timing (nanosecond on JS via `hrtime.bigint()`, microsecond on Rust via `std::time::Instant`) -- Benchmarking was gated behind `LSP_PROXY_BENCH=1` — zero overhead when disabled -- Each message records: direction, LSP method, payload size, and proxy processing overhead in microseconds -- Overhead measures only the proxy's own processing time (parse → transform → forward), excluding JDTLS response latency -- Tests were run on the same machine (macOS, Apple Silicon) with the same Zed configuration and JDTLS version, performing typical editing workflows: navigation, completions, saves, diagnostics - -## Test Environment - -| | Details | -|---|---| -| Machine | macOS, Apple Silicon (aarch64) | -| JDTLS | 1.57.0-202602261110 | -| Node.js | v24.11.0 (Zed-bundled) | -| Rust proxy | Release build, 771 KB binary | -| Zed | Dev extension | - -## Results - -### Node.js Proxy (3,700 messages) - -| Direction | Count | Min (µs) | Median (µs) | P95 (µs) | P99 (µs) | Max (µs) | Avg (µs) | -|---|---:|---:|---:|---:|---:|---:|---:| -| client → server | 1,399 | 3 | 16 | 81 | 147 | 429 | 28 | -| server → client | 2,011 | 4 | 24 | 74 | 121 | 4,501 | 32 | -| server → client (completion) | 290 | 13 | 50 | 179 | 272 | 458 | 71 | -| **Total** | **3,700** | | | | | | **33** | - -Total overhead: **124,796 µs** (~125 ms) - -### Rust Proxy (5,277 messages) - -| Direction | Count | Min (µs) | Median (µs) | P95 (µs) | P99 (µs) | Max (µs) | Avg (µs) | -|---|---:|---:|---:|---:|---:|---:|---:| -| client → server | 2,093 | 0 | 7 | 32 | 58 | 269 | 10 | -| server → client | 2,666 | 1 | 8 | 32 | 63 | 1,185 | 12 | -| server → client (completion) | 523 | 4 | 17 | 116 | 143 | 253 | 29 | -| **Total** | **5,277** | | | | | | **13** | - -Total overhead: **72,026 µs** (~72 ms) - -### Head-to-Head Comparison (Median) - -| Direction | Node.js | Rust | Speedup | -|---|---:|---:|---:| -| client → server (passthrough) | 16 µs | 7 µs | **2.3x** | -| server → client (passthrough) | 24 µs | 8 µs | **3.0x** | -| server → client (completion sort) | 50 µs | 17 µs | **2.9x** | -| **Overall average** | **33 µs** | **13 µs** | **2.5x** | - -### Tail Latency Comparison (P99) - -| Direction | Node.js | Rust | Improvement | -|---|---:|---:|---:| -| client → server | 147 µs | 58 µs | **2.5x** | -| server → client | 121 µs | 63 µs | **1.9x** | -| server → client (completion sort) | 272 µs | 143 µs | **1.9x** | - -## Analysis - -- The Rust proxy is **2.5x faster on average** across all message types -- The completion sorting path — which involves full JSON parse, field mutation, and re-serialization — shows a **2.9x improvement** at the median (17 µs vs 50 µs) -- Tail latency (P99) is **~2x tighter** in Rust, meaning more predictable performance -- Both proxies add negligible latency compared to JDTLS response times (typically 10-500 ms), so the user-perceived difference is minimal -- The primary benefits of the Rust proxy are architectural: - - **No Node.js runtime dependency** — eliminates ~50 MB runtime - - **771 KB static binary** — trivial to distribute - - **Faster cold start** — no V8 JIT warmup - - **Lower memory footprint** — no garbage collector overhead - - **Cross-compiled** — single binary per platform via CI - -## Appendix: Message Size Distribution (Rust run) - -| Direction | Min | Median | Max | Avg | -|---|---:|---:|---:|---:| -| client → server | 62 B | 381 B | 6,151 B | 366 B | -| server → client | 49 B | 549 B | 50,675 B | 1,228 B | -| server → client (completion) | 58 B | 110 B | 25,049 B | 2,060 B | From 76af1c5fa98cd5619c2144ebaa485eba4f879607 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 23:21:13 +0100 Subject: [PATCH 08/14] Format justfile --- justfile | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/justfile b/justfile index e1cba49..f05f89d 100644 --- a/justfile +++ b/justfile @@ -1,28 +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" -} - +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}} + cd proxy && cargo build --target {{ native_target }} # Build proxy in release mode proxy-release: - cd proxy && cargo build --release --target {{native_target}} + cd proxy && cargo build --release --target {{ native_target }} # Build proxy release and install to extension workdir for testing proxy-install: proxy-release - mkdir -p "{{ext_dir}}/proxy-bin" - cp "proxy/target/{{native_target}}/release/java-lsp-proxy" "{{proxy_bin}}" - @echo "Installed to {{proxy_bin}}" + mkdir -p "{{ ext_dir }}/proxy-bin" + cp "proxy/target/{{ native_target }}/release/java-lsp-proxy" "{{ proxy_bin }}" + @echo "Installed to {{ proxy_bin }}" # Build WASM extension in release mode ext-build: @@ -36,7 +28,7 @@ fmt: # Run clippy on both crates clippy: cargo clippy --all-targets --fix --allow-dirty - cd proxy && cargo clippy --all-targets --fix --allow-dirty --target {{native_target}} + cd proxy && cargo clippy --all-targets --fix --allow-dirty --target {{ native_target }} # Build everything: fmt, clippy, extension, proxy install all: fmt clippy ext-build proxy-install From 6a2e613fa121990d4fe0a66b14980e1430531b53 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 23:25:43 +0100 Subject: [PATCH 09/14] Address clippy warnings --- src/proxy.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/proxy.rs b/src/proxy.rs index 0fa30a5..58abfb0 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -83,8 +83,8 @@ pub fn binary_path( } // 2. Auto-download from GitHub releases - if let Ok((name, file_type)) = asset_name() { - if let Ok(release) = zed::latest_github_release( + if let Ok((name, file_type)) = asset_name() + && let Ok(release) = zed::latest_github_release( GITHUB_REPO, GithubReleaseOptions { require_assets: true, @@ -118,7 +118,6 @@ pub fn binary_path( return Ok(bin_path); } } - } } // 3. Fallback: local install (covers "always" mode when download fails) @@ -134,10 +133,9 @@ pub fn binary_path( } // 5. Stale cache fallback - if let Some(path) = cached.as_deref() { - if metadata(path).is_ok() { + if let Some(path) = cached.as_deref() + && metadata(path).is_ok() { return Ok(path.to_string()); - } } Err(format!("'{}' not found", proxy_exec())) From 8af3427b0f9ff35bae8adeec470aba2024f19a2c Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 14 Mar 2026 23:29:22 +0100 Subject: [PATCH 10/14] Format and clippy fix --- proxy/src/main.rs | 3 +-- proxy/src/platform/unix.rs | 2 +- proxy/src/platform/windows.rs | 6 ++--- src/proxy.rs | 48 ++++++++++++++++++----------------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 0c2af43..84e3031 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -11,9 +11,8 @@ use std::{ path::Path, process::{self, Command, Stdio}, sync::{ - Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc, + mpsc, Arc, Mutex, }, thread, time::Duration, diff --git a/proxy/src/platform/unix.rs b/proxy/src/platform/unix.rs index e2e68e0..3cdc754 100644 --- a/proxy/src/platform/unix.rs +++ b/proxy/src/platform/unix.rs @@ -1,6 +1,6 @@ use std::sync::{ - Arc, atomic::{AtomicBool, Ordering}, + Arc, }; use std::thread; use std::time::Duration; diff --git a/proxy/src/platform/windows.rs b/proxy/src/platform/windows.rs index d2dbd79..54470be 100644 --- a/proxy/src/platform/windows.rs +++ b/proxy/src/platform/windows.rs @@ -1,7 +1,7 @@ use std::process::Command; use std::sync::{ - Arc, atomic::{AtomicBool, Ordering}, + Arc, }; use std::thread; use std::time::Duration; @@ -9,10 +9,10 @@ use std::time::Duration; use windows_sys::Win32::{ Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS, + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, }, System::Threading::{ - GetCurrentProcessId, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + GetCurrentProcessId, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, }, }; diff --git a/src/proxy.rs b/src/proxy.rs index 58abfb0..62adf73 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -90,34 +90,35 @@ pub fn binary_path( require_assets: true, pre_release: false, }, - ) { - let bin_path = format!("{PROXY_INSTALL_PATH}/{}/{}", release.version, proxy_exec()); + ) + { + let bin_path = format!("{PROXY_INSTALL_PATH}/{}/{}", release.version, proxy_exec()); - if metadata(&bin_path).is_ok() { - *cached = Some(bin_path.clone()); - return Ok(bin_path); - } + if metadata(&bin_path).is_ok() { + *cached = Some(bin_path.clone()); + return Ok(bin_path); + } + + if let Some(asset) = release.assets.iter().find(|a| a.name == name) { + let version_dir = format!("{PROXY_INSTALL_PATH}/{}", release.version); - if let Some(asset) = release.assets.iter().find(|a| a.name == name) { - let version_dir = format!("{PROXY_INSTALL_PATH}/{}", release.version); + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + if zed::download_file(&asset.download_url, &version_dir, file_type).is_ok() { + let _ = zed::make_file_executable(&bin_path); set_language_server_installation_status( language_server_id, - &LanguageServerInstallationStatus::Downloading, + &LanguageServerInstallationStatus::None, ); - - if zed::download_file(&asset.download_url, &version_dir, file_type).is_ok() { - let _ = zed::make_file_executable(&bin_path); - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::None, - ); - let _ = remove_all_files_except(PROXY_INSTALL_PATH, &release.version); - let _ = mark_checked_once(PROXY_INSTALL_PATH, &release.version); - *cached = Some(bin_path.clone()); - return Ok(bin_path); - } + let _ = remove_all_files_except(PROXY_INSTALL_PATH, &release.version); + let _ = mark_checked_once(PROXY_INSTALL_PATH, &release.version); + *cached = Some(bin_path.clone()); + return Ok(bin_path); } + } } // 3. Fallback: local install (covers "always" mode when download fails) @@ -134,8 +135,9 @@ pub fn binary_path( // 5. Stale cache fallback if let Some(path) = cached.as_deref() - && metadata(path).is_ok() { - return Ok(path.to_string()); + && metadata(path).is_ok() + { + return Ok(path.to_string()); } Err(format!("'{}' not found", proxy_exec())) From 4c26f3bb23d04c3021a5db9fef432986608bfed6 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 15 Mar 2026 19:19:42 +0100 Subject: [PATCH 11/14] Remove duplicate method and add LSP logging feature --- proxy/src/log.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++++ proxy/src/main.rs | 23 +++---- 2 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 proxy/src/log.rs diff --git a/proxy/src/log.rs b/proxy/src/log.rs new file mode 100644 index 0000000..673b6ab --- /dev/null +++ b/proxy/src/log.rs @@ -0,0 +1,149 @@ +use serde::Serialize; +use std::io::{self, Write}; + +use crate::CONTENT_LENGTH; + +/// LSP `MessageType` constants as defined in the specification. +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageType +#[allow(dead_code)] +mod message_type { + pub const ERROR: u8 = 1; + pub const WARNING: u8 = 2; + pub const INFO: u8 = 3; + pub const LOG: u8 = 4; + pub const DEBUG: u8 = 5; +} + +#[derive(Serialize)] +struct LogMessageNotification<'a> { + jsonrpc: &'static str, + method: &'static str, + params: LogMessageParams<'a>, +} + +#[derive(Serialize)] +struct LogMessageParams<'a> { + r#type: u8, + message: &'a str, +} + +/// Sends a `window/logMessage` LSP notification to stdout so that Zed +/// displays the message in its Server Logs panel. +/// +/// This locks stdout for the duration of the write to ensure the LSP +/// framing is not interleaved with other output. +fn send_log_message(level: u8, message: &str) { + let notification = LogMessageNotification { + jsonrpc: "2.0", + method: "window/logMessage", + params: LogMessageParams { + r#type: level, + message, + }, + }; + + let json = match serde_json::to_string(¬ification) { + Ok(json) => json, + Err(_) => return, + }; + + let stdout = io::stdout(); + let mut w = stdout.lock(); + let _ = write!(w, "{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()); + let _ = w.flush(); +} + +#[allow(dead_code)] +pub fn error(message: &str) { + send_log_message(message_type::ERROR, message); +} + +#[allow(dead_code)] +pub fn warn(message: &str) { + send_log_message(message_type::WARNING, message); +} + +#[allow(dead_code)] +pub fn info(message: &str) { + send_log_message(message_type::INFO, message); +} + +#[allow(dead_code)] +pub fn log(message: &str) { + send_log_message(message_type::LOG, message); +} + +#[allow(dead_code)] +pub fn debug(message: &str) { + send_log_message(message_type::DEBUG, message); +} + +/// Logs a message at `Error` level (MessageType = 1) to Zed's Server Logs +/// via a `window/logMessage` LSP notification. +/// +/// Supports `format!`-style arguments: +/// ```ignore +/// lsp_error!("something failed: {}", err); +/// ``` +#[macro_export] +macro_rules! lsp_error { + ($($arg:tt)*) => { + $crate::log::error(&format!($($arg)*)) + }; +} + +/// Logs a message at `Warning` level (MessageType = 2) to Zed's Server Logs +/// via a `window/logMessage` LSP notification. +/// +/// Supports `format!`-style arguments: +/// ```ignore +/// lsp_warn!("unexpected value: {}", val); +/// ``` +#[macro_export] +macro_rules! lsp_warn { + ($($arg:tt)*) => { + $crate::log::warn(&format!($($arg)*)) + }; +} + +/// Logs a message at `Info` level (MessageType = 3) to Zed's Server Logs +/// via a `window/logMessage` LSP notification. +/// +/// Supports `format!`-style arguments: +/// ```ignore +/// lsp_info!("proxy started on port {}", port); +/// ``` +#[macro_export] +macro_rules! lsp_info { + ($($arg:tt)*) => { + $crate::log::info(&format!($($arg)*)) + }; +} + +/// Logs a message at `Log` level (MessageType = 4) to Zed's Server Logs +/// via a `window/logMessage` LSP notification. +/// +/// Supports `format!`-style arguments: +/// ```ignore +/// lsp_log!("forwarding request id={}", id); +/// ``` +#[macro_export] +macro_rules! lsp_log { + ($($arg:tt)*) => { + $crate::log::log(&format!($($arg)*)) + }; +} + +/// Logs a message at `Debug` level (MessageType = 5) to Zed's Server Logs +/// via a `window/logMessage` LSP notification. +/// +/// Supports `format!`-style arguments: +/// ```ignore +/// lsp_debug!("raw message bytes: {}", raw.len()); +/// ``` +#[macro_export] +macro_rules! lsp_debug { + ($($arg:tt)*) => { + $crate::log::debug(&format!($($arg)*)) + }; +} diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 84e3031..40f5470 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,3 +1,4 @@ +mod log; mod platform; use platform::spawn_parent_monitor; @@ -25,7 +26,7 @@ const TIMEOUT: Duration = Duration::from_secs(5); 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...]"); process::exit(1); } @@ -33,6 +34,8 @@ fn main() { let bin = &args[1]; let child_args = &args[2..]; + lsp_info!("java-lsp-proxy starting: bin={bin}, workdir={workdir}"); + let proxy_id = hex_encode( env::current_dir() .unwrap() @@ -59,10 +62,12 @@ fn main() { } let mut child = cmd.spawn().unwrap_or_else(|e| { - eprintln!("Failed to spawn {bin}: {e}"); + lsp_error!("Failed to spawn {bin}: {e}"); process::exit(1); }); + lsp_info!("JDTLS process spawned (pid={})", child.id()); + let child_stdin = Arc::new(Mutex::new(child.stdin.take().unwrap())); let child_stdout = child.stdout.take().unwrap(); let alive = Arc::new(AtomicBool::new(true)); @@ -77,6 +82,8 @@ fn main() { fs::create_dir_all(port_file.parent().unwrap()).unwrap(); fs::write(&port_file, port.to_string()).unwrap(); + lsp_info!("HTTP server listening on 127.0.0.1:{port}"); + let id_counter = Arc::new(AtomicU64::new(1)); // --- Thread 1: Zed stdin -> JDTLS stdin (passthrough) --- @@ -171,7 +178,8 @@ fn main() { spawn_parent_monitor(Arc::clone(&alive), child.id()); // Wait for child to exit - let _ = child.wait(); + let status = child.wait(); + lsp_info!("JDTLS process exited: {status:?}"); alive.store(false, Ordering::Relaxed); let _ = fs::remove_file(&port_file); } @@ -230,12 +238,7 @@ fn parse_lsp_content(raw: &[u8]) -> Option { serde_json::from_slice(&raw[sep_pos + 4..]).ok() } -fn encode_lsp(value: &Value) -> String { - let json = serde_json::to_string(value).unwrap(); - format!("{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()) -} - -fn encode_lsp_serializable(value: &impl Serialize) -> String { +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()) } @@ -377,7 +380,7 @@ fn handle_http( method: req.method, params: req.params, }; - let encoded = encode_lsp_serializable(&lsp_req); + let encoded = encode_lsp(&lsp_req); { let mut w = writer.lock().unwrap(); let _ = w.write_all(encoded.as_bytes()); From 3d3291ec25edaf3034afd711c82f6db073f42b71 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 15 Mar 2026 19:35:20 +0100 Subject: [PATCH 12/14] Split main.rs into sub-modules --- proxy/src/completions.rs | 59 +++++++++ proxy/src/http.rs | 130 ++++++++++++++++++++ proxy/src/log.rs | 9 +- proxy/src/lsp.rs | 60 +++++++++ proxy/src/main.rs | 257 +++------------------------------------ 5 files changed, 266 insertions(+), 249 deletions(-) create mode 100644 proxy/src/completions.rs create mode 100644 proxy/src/http.rs create mode 100644 proxy/src/lsp.rs diff --git a/proxy/src/completions.rs b/proxy/src/completions.rs new file mode 100644 index 0000000..6a7024c --- /dev/null +++ b/proxy/src/completions.rs @@ -0,0 +1,59 @@ +use serde_json::Value; + +pub fn should_sort_completions(msg: &Value) -> bool { + msg.get("result").is_some_and(|result| { + result.get("items").is_some_and(|v| v.is_array()) || result.is_array() + }) +} + +pub fn sort_completions_by_param_count(msg: &mut Value) { + let items = if let Some(result) = msg.get_mut("result") { + if result.is_array() { + result.as_array_mut() + } else { + result.get_mut("items").and_then(|v| v.as_array_mut()) + } + } else { + None + }; + + if let Some(items) = items { + for item in items.iter_mut() { + let kind = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + if kind == 2 || kind == 3 { + let detail = item + .pointer("/labelDetails/detail") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let count = count_params(detail); + let existing = item.get("sortText").and_then(|v| v.as_str()).unwrap_or(""); + item["sortText"] = Value::String(format!("{count:02}{existing}")); + } + } + } +} + +fn count_params(detail: &str) -> usize { + if detail.is_empty() || detail == "()" { + return 0; + } + let inner = detail + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(detail) + .trim(); + if inner.is_empty() { + return 0; + } + let mut count = 1usize; + let mut depth = 0i32; + for ch in inner.chars() { + match ch { + '<' => depth += 1, + '>' => depth -= 1, + ',' if depth == 0 => count += 1, + _ => {} + } + } + count +} diff --git a/proxy/src/http.rs b/proxy/src/http.rs new file mode 100644 index 0000000..7aa5494 --- /dev/null +++ b/proxy/src/http.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Read, Write}, + sync::{ + atomic::{AtomicU64, Ordering}, + mpsc, Arc, Mutex, + }, + time::Duration, +}; + +use crate::lsp::encode_lsp; + +pub const TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Deserialize)] +struct HttpBody { + method: String, + params: Value, +} + +#[derive(Serialize)] +struct LspRequest { + jsonrpc: &'static str, + id: Value, + method: String, + params: Value, +} + +pub fn handle_http( + mut stream: std::net::TcpStream, + writer: Arc>, + pending: Arc>>>, + counter: Arc, + proxy_id: &str, +) { + let mut reader = BufReader::new(&stream); + + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + + if !request_line.starts_with("POST") { + let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n"); + return; + } + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + break; + } + if let Some((name, value)) = trimmed.split_once(": ") { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().unwrap_or(0); + } + } + } + + let mut body = vec![0u8; content_length]; + if reader.read_exact(&mut body).is_err() { + return; + } + + let req: HttpBody = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n"); + return; + } + }; + + let seq = counter.fetch_add(1, Ordering::Relaxed); + let id = Value::String(format!("{proxy_id}-{seq}")); + + let (tx, rx) = mpsc::channel(); + pending.lock().unwrap().insert(id.clone(), tx); + + let lsp_req = LspRequest { + jsonrpc: "2.0", + id: id.clone(), + method: req.method, + params: req.params, + }; + let encoded = encode_lsp(&lsp_req); + { + let mut w = writer.lock().unwrap(); + let _ = w.write_all(encoded.as_bytes()); + let _ = w.flush(); + } + + let response = match rx.recv_timeout(TIMEOUT) { + Ok(resp) => resp, + Err(_) => { + pending.lock().unwrap().remove(&id); + let cancel = encode_lsp(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": id } + })); + let mut w = writer.lock().unwrap(); + let _ = w.write_all(cancel.as_bytes()); + let _ = w.flush(); + + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32803, + "message": "Request to language server timed out after 5000ms." + } + }) + } + }; + + let resp_body = serde_json::to_vec(&response).unwrap(); + let http_resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + resp_body.len() + ); + let _ = stream.write_all(http_resp.as_bytes()); + let _ = stream.write_all(&resp_body); +} diff --git a/proxy/src/log.rs b/proxy/src/log.rs index 673b6ab..c91f3b4 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::CONTENT_LENGTH; +use crate::lsp::encode_lsp; /// LSP `MessageType` constants as defined in the specification. /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageType @@ -42,14 +42,11 @@ fn send_log_message(level: u8, message: &str) { }, }; - let json = match serde_json::to_string(¬ification) { - Ok(json) => json, - Err(_) => return, - }; + let encoded = encode_lsp(¬ification); let stdout = io::stdout(); let mut w = stdout.lock(); - let _ = write!(w, "{CONTENT_LENGTH}: {}\r\n\r\n{json}", json.len()); + let _ = w.write_all(encoded.as_bytes()); let _ = w.flush(); } diff --git a/proxy/src/lsp.rs b/proxy/src/lsp.rs new file mode 100644 index 0000000..ac35ae8 --- /dev/null +++ b/proxy/src/lsp.rs @@ -0,0 +1,60 @@ +use serde::Serialize; +use std::io::{self, Read}; + +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() +} + +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()) +} diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 40f5470..822dd3d 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,13 +1,18 @@ +mod completions; +mod http; mod log; +mod lsp; mod platform; +use completions::{should_sort_completions, sort_completions_by_param_count}; +use http::handle_http; +use lsp::{encode_lsp, parse_lsp_content, LspReader}; use platform::spawn_parent_monitor; -use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::HashMap, env, fs, - io::{self, BufRead, BufReader, Read, Write}, + io::{self, BufReader, Write}, net::TcpListener, path::Path, process::{self, Command, Stdio}, @@ -16,13 +21,8 @@ use std::{ mpsc, Arc, Mutex, }, thread, - time::Duration, }; -const CONTENT_LENGTH: &str = "Content-Length"; -const HEADER_SEP: &[u8] = b"\r\n\r\n"; -const TIMEOUT: Duration = Duration::from_secs(5); - fn main() { let args: Vec = env::args().skip(1).collect(); if args.len() < 2 { @@ -84,6 +84,13 @@ fn main() { lsp_info!("HTTP server listening on 127.0.0.1:{port}"); + // TODO: Remove after verifying Zed Server Logs displays all levels correctly + lsp_error!("[TEST] This is an error message (level 1)"); + lsp_warn!("[TEST] This is a warning message (level 2)"); + lsp_info!("[TEST] This is an info message (level 3)"); + lsp_log!("[TEST] This is a log message (level 4)"); + lsp_debug!("[TEST] This is a debug message (level 5)"); + let id_counter = Arc::new(AtomicU64::new(1)); // --- Thread 1: Zed stdin -> JDTLS stdin (passthrough) --- @@ -184,242 +191,6 @@ fn main() { let _ = fs::remove_file(&port_file); } -// --- LSP message reader --- - -struct LspReader { - reader: R, -} - -impl LspReader { - fn new(reader: R) -> Self { - Self { reader } - } - - 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)) - } -} - -// --- LSP message encoding/parsing --- - -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() -} - -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()) -} - -// --- Completion sorting --- - -fn should_sort_completions(msg: &Value) -> bool { - msg.get("result").is_some_and(|result| { - result.get("items").is_some_and(|v| v.is_array()) || result.is_array() - }) -} - -fn sort_completions_by_param_count(msg: &mut Value) { - let items = if let Some(result) = msg.get_mut("result") { - if result.is_array() { - result.as_array_mut() - } else { - result.get_mut("items").and_then(|v| v.as_array_mut()) - } - } else { - None - }; - - if let Some(items) = items { - for item in items.iter_mut() { - let kind = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); - if kind == 2 || kind == 3 { - let detail = item - .pointer("/labelDetails/detail") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let count = count_params(detail); - let existing = item.get("sortText").and_then(|v| v.as_str()).unwrap_or(""); - item["sortText"] = Value::String(format!("{count:02}{existing}")); - } - } - } -} - -fn count_params(detail: &str) -> usize { - if detail.is_empty() || detail == "()" { - return 0; - } - let inner = detail - .strip_prefix('(') - .and_then(|s| s.strip_suffix(')')) - .unwrap_or(detail) - .trim(); - if inner.is_empty() { - return 0; - } - let mut count = 1usize; - let mut depth = 0i32; - for ch in inner.chars() { - match ch { - '<' => depth += 1, - '>' => depth -= 1, - ',' if depth == 0 => count += 1, - _ => {} - } - } - count -} - -// --- HTTP handler --- - -#[derive(Deserialize)] -struct HttpBody { - method: String, - params: Value, -} - -#[derive(Serialize)] -struct LspRequest { - jsonrpc: &'static str, - id: Value, - method: String, - params: Value, -} - -fn handle_http( - mut stream: std::net::TcpStream, - writer: Arc>, - pending: Arc>>>, - counter: Arc, - proxy_id: &str, -) { - let mut reader = BufReader::new(&stream); - - let mut request_line = String::new(); - if reader.read_line(&mut request_line).is_err() { - return; - } - - if !request_line.starts_with("POST") { - let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n"); - return; - } - - let mut content_length = 0usize; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).is_err() { - return; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - break; - } - if let Some((name, value)) = trimmed.split_once(": ") { - if name.eq_ignore_ascii_case("content-length") { - content_length = value.trim().parse().unwrap_or(0); - } - } - } - - let mut body = vec![0u8; content_length]; - if reader.read_exact(&mut body).is_err() { - return; - } - - let req: HttpBody = match serde_json::from_slice(&body) { - Ok(r) => r, - Err(_) => { - let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n"); - return; - } - }; - - let seq = counter.fetch_add(1, Ordering::Relaxed); - let id = Value::String(format!("{proxy_id}-{seq}")); - - let (tx, rx) = mpsc::channel(); - pending.lock().unwrap().insert(id.clone(), tx); - - let lsp_req = LspRequest { - jsonrpc: "2.0", - id: id.clone(), - method: req.method, - params: req.params, - }; - let encoded = encode_lsp(&lsp_req); - { - let mut w = writer.lock().unwrap(); - let _ = w.write_all(encoded.as_bytes()); - let _ = w.flush(); - } - - let response = match rx.recv_timeout(TIMEOUT) { - Ok(resp) => resp, - Err(_) => { - pending.lock().unwrap().remove(&id); - let cancel = encode_lsp(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "$/cancelRequest", - "params": { "id": id } - })); - let mut w = writer.lock().unwrap(); - let _ = w.write_all(cancel.as_bytes()); - let _ = w.flush(); - - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32803, - "message": "Request to language server timed out after 5000ms." - } - }) - } - }; - - let resp_body = serde_json::to_vec(&response).unwrap(); - let http_resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", - resp_body.len() - ); - let _ = stream.write_all(http_resp.as_bytes()); - let _ = stream.write_all(&resp_body); -} - // --- Utilities --- fn hex_encode(s: &str) -> String { From 1a617e5fef0f17eb89778f42fdcf3aa922567461 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 15 Mar 2026 19:36:37 +0100 Subject: [PATCH 13/14] Remove log testing --- proxy/src/main.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 822dd3d..0faa0f8 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -84,13 +84,6 @@ fn main() { lsp_info!("HTTP server listening on 127.0.0.1:{port}"); - // TODO: Remove after verifying Zed Server Logs displays all levels correctly - lsp_error!("[TEST] This is an error message (level 1)"); - lsp_warn!("[TEST] This is a warning message (level 2)"); - lsp_info!("[TEST] This is an info message (level 3)"); - lsp_log!("[TEST] This is a log message (level 4)"); - lsp_debug!("[TEST] This is a debug message (level 5)"); - let id_counter = Arc::new(AtomicU64::new(1)); // --- Thread 1: Zed stdin -> JDTLS stdin (passthrough) --- From 1c845b6c10212e7b5a5e6f59826200a68c003f95 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sun, 15 Mar 2026 21:59:42 +0100 Subject: [PATCH 14/14] Restore eprintln --- proxy/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 0faa0f8..b6ab51b 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -26,6 +26,7 @@ use std::{ 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...]"); process::exit(1); } @@ -62,6 +63,7 @@ fn main() { } let mut child = cmd.spawn().unwrap_or_else(|e| { + eprintln!("Failed to spawn {bin}: {e}"); lsp_error!("Failed to spawn {bin}: {e}"); process::exit(1); });