From 3de5084b1541947d9325126990e28e747aaa1db2 Mon Sep 17 00:00:00 2001 From: ckwang <849381206@qq.com> Date: Fri, 21 Aug 2026 17:00:29 +0800 Subject: [PATCH] feat(proxy): support multi-level navigation from decompiled JAR classes JDTLS reports classes inside jars with jdt:// URIs. The proxy already rewrites those into temporary decompiled .java files so Zed can open them, but navigation then stopped: Zed sends follow-up requests against the temporary file:// URI, which JDTLS does not understand. Persist the reverse mapping (file:// -> jdt://) next to each cached decompiled source, and rewrite incoming textDocument request URIs back to jdt:// before forwarding. Also suppress didOpen/didChange/didSave/didClose for decompiled-source worktrees so JDTLS never treats them as editable project files. The mapping lives on disk because Zed opens decompiled files in their own worktree, which spawns a fresh proxy process. --- proxy-common/src/lib.rs | 2 +- proxy-common/src/uri.rs | 43 +++++++++++- proxy/src/decompile.rs | 56 ++++++++++++++- proxy/src/main.rs | 149 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 5 deletions(-) diff --git a/proxy-common/src/lib.rs b/proxy-common/src/lib.rs index 2bf9fe9..fcc6ebb 100644 --- a/proxy-common/src/lib.rs +++ b/proxy-common/src/lib.rs @@ -18,4 +18,4 @@ pub use lsp::{ write_raw, write_to_stdout, LspReader, CONTENT_LENGTH, HEADER_SEP, }; pub use platform::spawn_parent_monitor; -pub use uri::path_to_file_uri; +pub use uri::{file_uri_to_path, path_to_file_uri}; diff --git a/proxy-common/src/uri.rs b/proxy-common/src/uri.rs index fead948..849094e 100644 --- a/proxy-common/src/uri.rs +++ b/proxy-common/src/uri.rs @@ -1,5 +1,5 @@ -use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; -use std::path::Path; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use std::path::{Path, PathBuf}; const PATH_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC .remove(b'/') @@ -15,6 +15,29 @@ pub fn path_to_file_uri(path: &Path) -> String { file_uri_from_path_string(&path.to_string_lossy(), cfg!(windows)) } +/// Convert a `file://` URI back to a filesystem path. +/// +/// This is the inverse of [`path_to_file_uri`] for the URIs this codebase +/// produces (Unix paths and Windows drive/UNC paths). Returns `None` for +/// non-`file://` URIs or invalid percent-encoding. +pub fn file_uri_to_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let decoded = percent_decode_str(rest).decode_utf8().ok()?; + + if cfg!(windows) { + if let Some(path) = rest.strip_prefix('/') { + // file:///C:/... -> C:\... + let decoded = percent_decode_str(path).decode_utf8().ok()?; + return Some(PathBuf::from(decoded.replace('/', "\\"))); + } + // file://server/share/... -> \\server\share\... + return Some(PathBuf::from(format!("\\\\{}", decoded.replace('/', "\\")))); + } + + // Unix: file:///path -> /path + Some(PathBuf::from(decoded.into_owned())) +} + fn file_uri_from_path_string(path: &str, windows: bool) -> String { let mut normalized = if windows { path.replace('\\', "/") @@ -70,6 +93,22 @@ mod tests { ); } + #[test] + fn converts_unix_file_uri_back_to_path() { + assert_eq!( + file_uri_to_path("file:///tmp/Java%20Sources/String.java").as_deref(), + Some(Path::new("/tmp/Java Sources/String.java")) + ); + } + + #[test] + fn rejects_non_file_uris() { + assert_eq!( + file_uri_to_path("jdt://contents/java.base/String.class"), + None + ); + } + #[test] fn normalizes_windows_verbatim_paths() { assert_eq!( diff --git a/proxy/src/decompile.rs b/proxy/src/decompile.rs index 2529c7e..4d38012 100644 --- a/proxy/src/decompile.rs +++ b/proxy/src/decompile.rs @@ -741,6 +741,32 @@ fn cache_path_in(directory: &Path, uri: &str) -> PathBuf { .join(format!("{name}.java")) } +fn sidecar_path(target: &Path) -> PathBuf { + let mut name = target.as_os_str().to_os_string(); + name.push(".jdt-uri"); + PathBuf::from(name) +} + +fn ensure_jdt_uri_sidecar(target: &Path, jdt_uri: &str) { + let sidecar = sidecar_path(target); + if sidecar.is_file() { + return; + } + let _ = fs::write(&sidecar, jdt_uri); +} + +/// Resolve the original `jdt://` URI for a cached decompiled source file. +/// +/// The mapping is stored on disk next to the source file so it survives across +/// proxy processes. Zed opens decompiled files in their own worktree, which +/// spawns a fresh `java-lsp-proxy`; that process must be able to reverse-map the +/// temporary `file://` document back to its `jdt://` class URI. +pub(crate) fn jdt_uri_for_cached_file(target: &Path) -> Option { + let content = fs::read_to_string(sidecar_path(target)).ok()?; + let uri = content.trim(); + (!uri.is_empty()).then(|| uri.to_string()) +} + fn write_cached_source(directory: &Path, uri: &str, content: &[u8]) -> Option { if content.is_empty() { return None; @@ -755,6 +781,7 @@ fn write_cached_source(directory: &Path, uri: &str, content: &[u8]) -> Option Option Some(path_to_file_uri(&target)), + Ok(()) => { + ensure_jdt_uri_sidecar(&target, uri); + Some(path_to_file_uri(&target)) + } Err(_error) if target.is_file() => { let _ = fs::remove_file(&temporary); + ensure_jdt_uri_sidecar(&target, uri); Some(path_to_file_uri(&target)) } Err(error) => { @@ -1454,4 +1485,27 @@ mod tests { assert_eq!(write_cached_source(&directory, &uri, b""), None); assert!(!path.exists()); } + + #[test] + fn cached_sources_keep_reverse_mapping() { + let uri = "jdt://contents/java.base/java.lang/String.class"; + let directory = session_cache_dir("reverse-mapping-test"); + let path = cache_path_in(&directory, uri); + let _ = fs::remove_file(&path); + let _ = fs::remove_file(sidecar_path(&path)); + + let file_uri = write_cached_source(&directory, uri, b"public class String {}").unwrap(); + assert_eq!(file_uri, path_to_file_uri(&path)); + assert_eq!(jdt_uri_for_cached_file(&path).as_deref(), Some(uri)); + + let _ = fs::remove_file(&path); + let _ = fs::remove_file(sidecar_path(&path)); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn reverse_mapping_is_missing_for_unrelated_files() { + let path = PathBuf::from("/tmp/no-sidecar/String.java"); + assert_eq!(jdt_uri_for_cached_file(&path), None); + } } diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 7d5191e..344e0b5 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -11,7 +11,8 @@ use http::handle_http; use output::Output; use pending::PendingResponses; use proxy_common::{ - contains_subslice, encode_lsp, parse_lsp_content, raw_has_id, spawn_parent_monitor, LspReader, + contains_subslice, encode_lsp, file_uri_to_path, parse_lsp_content, raw_has_id, + spawn_parent_monitor, LspReader, }; use serde_json::{json, Value}; use std::{ @@ -282,6 +283,11 @@ fn run_zed_input(context: StdinContext) { Ok(Some(raw)) => raw, Ok(None) | Err(_) => break, }; + let raw = if raw_has_id(&raw) { + rewrite_request_uri(raw) + } else { + raw + }; if route_zed_message(&context, &raw) == InputRoute::Forward && !write_to_jdtls(&context.writer, &raw) { @@ -294,6 +300,16 @@ fn run_zed_input(context: StdinContext) { fn route_zed_message(context: &StdinContext, raw: &[u8]) -> InputRoute { let has_id = raw_has_id(raw); if !has_id && !contains_subslice(raw, b"$/cancelRequest") { + // Editor notifications carry no id. Decompiled-source worktrees must not + // leak didOpen/didChange/didSave/didClose into JDTLS: those documents + // live under /tmp/jdtls_decompiled and only exist as a navigation target. + if contains_subslice(raw, b"jdtls_decompiled") { + if let Some(message) = parse_lsp_content(raw) { + if is_decompiled_document_notification(&message) { + return InputRoute::Consumed; + } + } + } return InputRoute::Forward; } let Some(message) = parse_lsp_content(raw) else { @@ -308,6 +324,56 @@ fn route_zed_message(context: &StdinContext, raw: &[u8]) -> InputRoute { InputRoute::Forward } +/// Rewrite `file://` document URIs that point at a cached decompiled source +/// file back to their original `jdt://` URI before forwarding to JDTLS. This is +/// what makes further navigation *inside* a decompiled class work. +fn rewrite_request_uri(raw: Vec) -> Vec { + if !contains_subslice(&raw, b"jdtls_decompiled") { + return raw; + } + let Some(mut message) = parse_lsp_content(&raw) else { + return raw; + }; + let Some(file_uri) = message + .pointer("/params/textDocument/uri") + .and_then(Value::as_str) + .map(str::to_string) + else { + return raw; + }; + let Some(path) = file_uri_to_path(&file_uri) else { + return raw; + }; + let Some(jdt_uri) = decompile::jdt_uri_for_cached_file(&path) else { + return raw; + }; + + *message.pointer_mut("/params/textDocument/uri").unwrap() = Value::String(jdt_uri); + encode_lsp(&message).into_bytes() +} + +/// Whether an editor notification concerns a decompiled-source document whose +/// lifecycle JDTLS must not observe. +fn is_decompiled_document_notification(message: &Value) -> bool { + let Some(method) = message.get("method").and_then(Value::as_str) else { + return false; + }; + if !matches!( + method, + "textDocument/didOpen" + | "textDocument/didChange" + | "textDocument/didSave" + | "textDocument/didClose" + ) { + return false; + } + + message + .pointer("/params/textDocument/uri") + .and_then(Value::as_str) + .is_some_and(|uri| uri.contains("jdtls_decompiled")) +} + fn route_zed_cancellation(context: &StdinContext, message: &Value) -> InputRoute { let Some(id) = message.pointer("/params/id") else { return InputRoute::Forward; @@ -803,6 +869,87 @@ mod tests { assert!(fixture.tracked.lock().unwrap().is_empty()); } + #[test] + fn rewrites_decompiled_file_request_uri() { + let dir = std::env::temp_dir().join("jdtls_decompiled/rewrite-test"); + fs::create_dir_all(&dir).unwrap(); + let target = dir.join("String.java"); + fs::write(&target, "class String {}").unwrap(); + fs::write( + format!("{}.jdt-uri", target.display()), + "jdt://contents/java.base/java.lang/String.class", + ) + .unwrap(); + + let file_uri = proxy_common::path_to_file_uri(&target); + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/definition", + "params": { + "textDocument": { "uri": file_uri }, + "position": { "line": 0, "character": 0 } + } + }); + + let rewritten = rewrite_request_uri(frame(&request)); + let parsed = parse_lsp_content(&rewritten).unwrap(); + assert_eq!( + parsed["params"]["textDocument"]["uri"], + json!("jdt://contents/java.base/java.lang/String.class") + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn decompiled_document_notifications_are_recognized() { + assert!(is_decompiled_document_notification(&json!({ + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///tmp/jdtls_decompiled/session_x/uri_y/String.java" + } + } + }))); + assert!(!is_decompiled_document_notification(&json!({ + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///workspace/Foo.java" + } + } + }))); + assert!(!is_decompiled_document_notification(&json!({ + "method": "textDocument/publishDiagnostics", + "params": { + "textDocument": { + "uri": "file:///tmp/jdtls_decompiled/session_x/uri_y/String.java" + } + } + }))); + } + + #[test] + fn suppresses_decompiled_document_notifications() { + let fixture = RoutingFixture::new(); + let context = fixture.stdin_context(); + let notification = json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///tmp/jdtls_decompiled/session_x/uri_y/String.java" + } + } + }); + + assert_eq!( + route_zed_message(&context, &frame(¬ification)), + InputRoute::Consumed + ); + } + #[test] fn stdout_router_returns_processed_completion_values() { let fixture = RoutingFixture::new();