Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion proxy-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
43 changes: 41 additions & 2 deletions proxy-common/src/uri.rs
Original file line number Diff line number Diff line change
@@ -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'/')
Expand All @@ -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<PathBuf> {
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('\\', "/")
Expand Down Expand Up @@ -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!(
Expand Down
56 changes: 55 additions & 1 deletion proxy/src/decompile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +745 to +747

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider to use set_file_name for a more streamlined operation

}

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<String> {
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<String> {
if content.is_empty() {
return None;
Expand All @@ -755,6 +781,7 @@ fn write_cached_source(directory: &Path, uri: &str, content: &[u8]) -> Option<St
}
let target = cache_path_in(directory, uri);
if target.is_file() {
ensure_jdt_uri_sidecar(&target, uri);
return Some(path_to_file_uri(&target));
}
if let Some(parent) = target.parent() {
Expand All @@ -778,9 +805,13 @@ fn write_cached_source(directory: &Path, uri: &str, content: &[u8]) -> Option<St
})();

match result {
Ok(()) => 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) => {
Expand Down Expand Up @@ -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);
}
}
149 changes: 148 additions & 1 deletion proxy/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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)
{
Expand All @@ -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 {
Expand All @@ -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<u8>) -> Vec<u8> {
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;
Expand Down Expand Up @@ -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(&notification)),
InputRoute::Consumed
);
}

#[test]
fn stdout_router_returns_processed_completion_values() {
let fixture = RoutingFixture::new();
Expand Down