Skip to content

Commit d391e69

Browse files
authored
Coordinate proxy decompilation and harden JDTLS response routing (#296)
- Move `jdt://` resolution to a bounded coordinator with request deduplication, priorities, deadlines, cancellation, and negative caching. - Add session-isolated, atomically written decompiled-source caches with valid filenames and encoded cross-platform file URIs. - Serialize proxy output and safely route proxy-owned, late, suppressed, and rewritten responses. - Handle workspace-symbol supersession and proxy shutdown without blocking transport threads. - Improve completion snippet sanitization and refactor stdin/stdout routing into testable handlers.
1 parent b0c19d2 commit d391e69

11 files changed

Lines changed: 3012 additions & 375 deletions

File tree

‎Cargo.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎proxy-common/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ path = "src/lib.rs"
1414
tokio = ["dep:tokio"]
1515

1616
[dependencies]
17+
percent-encoding = "2.3"
1718
serde = { version = "1.0", features = ["derive"] }
1819
serde_json = "1.0"
1920
tokio = { version = "1", default-features = false, features = ["io-util"], optional = true }

‎proxy-common/src/uri.rs‎

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,84 @@
1+
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
12
use std::path::Path;
23

3-
/// Convert a filesystem path to a `file://` URI, matching how language servers'
4-
/// `publishDiagnostics` and the editor key documents.
5-
///
6-
/// On Unix the path already starts with `/`, so `file://` + path gives the
7-
/// correct `file:///…` form with no extra work.
8-
///
9-
/// On Windows the backslashes are replaced with `/` and an extra `/` is
10-
/// prepended before the drive letter, so we get `file:///C:/…` rather than
11-
/// `file://C:\…`.
12-
#[cfg(unix)]
4+
const PATH_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
5+
.remove(b'/')
6+
.remove(b':')
7+
.remove(b'-')
8+
.remove(b'.')
9+
.remove(b'_')
10+
.remove(b'~')
11+
.remove(b'@');
12+
13+
/// Convert a filesystem path to an RFC 3986 percent-encoded `file://` URI.
1314
pub fn path_to_file_uri(path: &Path) -> String {
14-
format!("file://{}", path.display())
15+
file_uri_from_path_string(&path.to_string_lossy(), cfg!(windows))
1516
}
1617

17-
#[cfg(windows)]
18-
pub fn path_to_file_uri(path: &Path) -> String {
19-
let s = path.display().to_string().replace('\\', "/");
20-
format!("file:///{s}")
18+
fn file_uri_from_path_string(path: &str, windows: bool) -> String {
19+
let mut normalized = if windows {
20+
path.replace('\\', "/")
21+
} else {
22+
path.to_string()
23+
};
24+
if windows {
25+
if let Some(unc) = normalized.strip_prefix("//?/UNC/") {
26+
normalized = format!("//{unc}");
27+
} else if let Some(verbatim) = normalized.strip_prefix("//?/") {
28+
normalized = verbatim.to_string();
29+
}
30+
if let Some(unc) = normalized.strip_prefix("//") {
31+
return format!("file://{}", utf8_percent_encode(unc, &PATH_ENCODE_SET));
32+
}
33+
}
34+
let prefix = if normalized.starts_with('/') {
35+
"file://"
36+
} else {
37+
"file:///"
38+
};
39+
format!(
40+
"{prefix}{}",
41+
utf8_percent_encode(&normalized, &PATH_ENCODE_SET)
42+
)
43+
}
44+
45+
#[cfg(test)]
46+
mod tests {
47+
use super::*;
48+
49+
#[test]
50+
fn encodes_reserved_characters_in_unix_paths() {
51+
assert_eq!(
52+
file_uri_from_path_string("/tmp/Java Sources/#1%?.java", false),
53+
"file:///tmp/Java%20Sources/%231%25%3F.java"
54+
);
55+
}
56+
57+
#[test]
58+
fn normalizes_and_encodes_windows_paths() {
59+
assert_eq!(
60+
file_uri_from_path_string(r"C:\Users\Jane Doe\A#1.java", true),
61+
"file:///C:/Users/Jane%20Doe/A%231.java"
62+
);
63+
}
64+
65+
#[test]
66+
fn preserves_windows_unc_authority() {
67+
assert_eq!(
68+
file_uri_from_path_string(r"\\server\share\A File.java", true),
69+
"file://server/share/A%20File.java"
70+
);
71+
}
72+
73+
#[test]
74+
fn normalizes_windows_verbatim_paths() {
75+
assert_eq!(
76+
file_uri_from_path_string(r"\\?\C:\Users\Jane Doe\A.java", true),
77+
"file:///C:/Users/Jane%20Doe/A.java"
78+
);
79+
assert_eq!(
80+
file_uri_from_path_string(r"\\?\UNC\server\share\A.java", true),
81+
"file://server/share/A.java"
82+
);
83+
}
2184
}

‎proxy/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ path = "src/main.rs"
1212

1313
[dependencies]
1414
proxy-common.workspace = true
15+
hex = "0.4"
1516
serde = { version = "1.0", features = ["derive"] }
1617
serde_json = "1.0"
18+
sha1 = "0.10"

‎proxy/src/completions.rs‎

Lines changed: 152 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
use serde_json::Value;
22

3-
/// Returns true if the message contains a completion response with items.
4-
pub fn is_completion_response(msg: &Value) -> bool {
5-
msg.get("result").is_some_and(|result| {
6-
result.get("items").is_some_and(|v| v.is_array()) || result.is_array()
7-
})
8-
}
9-
103
/// Single-pass processing of completion items:
114
/// - Sorts methods/functions by parameter count (prepends count to sortText)
125
/// - Strips unsupported VS Code snippet variables ($TM_SELECTED_TEXT) from snippets
136
pub fn process_completions(msg: &mut Value) {
7+
let default_insert_text_format = msg
8+
.pointer("/result/itemDefaults/insertTextFormat")
9+
.and_then(Value::as_u64);
1410
let items = match msg.get_mut("result") {
1511
Some(result) if result.is_array() => result.as_array_mut(),
16-
Some(result) => result.get_mut("items").and_then(|v| v.as_array_mut()),
12+
Some(result) => result.get_mut("items").and_then(Value::as_array_mut),
1713
None => None,
1814
};
1915

@@ -33,20 +29,29 @@ pub fn process_completions(msg: &mut Value) {
3329
let existing = item.get("sortText").and_then(|v| v.as_str()).unwrap_or("");
3430
item["sortText"] = Value::String(format!("{count:02}{existing}"));
3531
}
36-
// Snippet (15): strip $TM_SELECTED_TEXT
37-
15 => {
38-
strip_tm_selected_text(item, "textEditText");
39-
strip_tm_selected_text(item, "insertText");
40-
}
4132
_ => {}
4233
}
34+
35+
let insert_text_format = item
36+
.get("insertTextFormat")
37+
.and_then(Value::as_u64)
38+
.or(default_insert_text_format);
39+
if kind == 15 || insert_text_format == Some(2) {
40+
sanitize_completion_item(item);
41+
}
4342
}
4443
}
4544

46-
fn strip_tm_selected_text(item: &mut Value, key: &str) {
47-
if let Some(text) = item.get(key).and_then(|v| v.as_str()) {
45+
fn sanitize_completion_item(item: &mut Value) {
46+
strip_tm_selected_text(item, "/textEditText");
47+
strip_tm_selected_text(item, "/insertText");
48+
strip_tm_selected_text(item, "/textEdit/newText");
49+
}
50+
51+
fn strip_tm_selected_text(item: &mut Value, pointer: &str) {
52+
if let Some(Value::String(text)) = item.pointer_mut(pointer) {
4853
if text.contains("$TM_SELECTED_TEXT") {
49-
item[key] = Value::String(text.replace("$TM_SELECTED_TEXT", ""));
54+
*text = text.replace("$TM_SELECTED_TEXT", "");
5055
}
5156
}
5257
}
@@ -56,15 +61,7 @@ pub fn sanitize_resolved_completion(msg: &mut Value) {
5661
let Some(result) = msg.get_mut("result") else {
5762
return;
5863
};
59-
strip_tm_selected_text(result, "textEditText");
60-
strip_tm_selected_text(result, "insertText");
61-
// Also check inside textEdit.newText
62-
if let Some(new_text) = result.pointer("/textEdit/newText").and_then(|v| v.as_str()) {
63-
if new_text.contains("$TM_SELECTED_TEXT") {
64-
result["textEdit"]["newText"] =
65-
Value::String(new_text.replace("$TM_SELECTED_TEXT", ""));
66-
}
67-
}
64+
sanitize_completion_item(result);
6865
}
6966

7067
fn count_params(detail: &str) -> usize {
@@ -87,3 +84,133 @@ fn count_params(detail: &str) -> usize {
8784
}
8885
count
8986
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::*;
91+
use serde_json::json;
92+
93+
#[test]
94+
fn processes_array_completion_results() {
95+
let mut response = json!({
96+
"result": [{
97+
"kind": 2,
98+
"labelDetails": { "detail": "(String, List<String>)" },
99+
"sortText": "method"
100+
}]
101+
});
102+
103+
process_completions(&mut response);
104+
105+
assert_eq!(response["result"][0]["sortText"], json!("02method"));
106+
}
107+
108+
#[test]
109+
fn applies_list_item_default_snippet_format() {
110+
let mut response = json!({
111+
"result": {
112+
"itemDefaults": { "insertTextFormat": 2 },
113+
"items": [{
114+
"kind": 2,
115+
"insertText": "$TM_SELECTED_TEXT.trim()"
116+
}]
117+
}
118+
});
119+
120+
process_completions(&mut response);
121+
122+
assert_eq!(
123+
response["result"]["items"][0]["insertText"],
124+
json!(".trim()")
125+
);
126+
}
127+
128+
#[test]
129+
fn sanitizes_text_edit_text() {
130+
let mut response = json!({
131+
"result": [{
132+
"kind": 15,
133+
"textEditText": "$TM_SELECTED_TEXT.field"
134+
}]
135+
});
136+
137+
process_completions(&mut response);
138+
139+
assert_eq!(response["result"][0]["textEditText"], json!(".field"));
140+
}
141+
142+
#[test]
143+
fn sanitizes_insert_replace_edit_text() {
144+
let mut response = json!({
145+
"result": [{
146+
"kind": 15,
147+
"textEdit": {
148+
"newText": "$TM_SELECTED_TEXT.var",
149+
"insert": {
150+
"start": { "line": 0, "character": 0 },
151+
"end": { "line": 0, "character": 0 }
152+
},
153+
"replace": {
154+
"start": { "line": 0, "character": 0 },
155+
"end": { "line": 0, "character": 3 }
156+
}
157+
}
158+
}]
159+
});
160+
161+
process_completions(&mut response);
162+
163+
assert_eq!(response["result"][0]["textEdit"]["newText"], json!(".var"));
164+
}
165+
166+
#[test]
167+
fn leaves_plain_text_completion_unchanged() {
168+
let mut response = json!({
169+
"result": [{
170+
"kind": 1,
171+
"insertTextFormat": 1,
172+
"insertText": "$TM_SELECTED_TEXT"
173+
}]
174+
});
175+
176+
process_completions(&mut response);
177+
178+
assert_eq!(
179+
response["result"][0]["insertText"],
180+
json!("$TM_SELECTED_TEXT")
181+
);
182+
}
183+
184+
#[test]
185+
fn postfix_var_completion_keeps_jdtls_spacing() {
186+
let insertion = "var name = \"hello world\";";
187+
let mut response = json!({
188+
"result": [{
189+
"label": ".var",
190+
"kind": 15,
191+
"insertTextFormat": 2,
192+
"textEdit": {
193+
"newText": insertion,
194+
"range": {
195+
"start": { "line": 0, "character": 0 },
196+
"end": { "line": 0, "character": 17 }
197+
}
198+
}
199+
}]
200+
});
201+
202+
process_completions(&mut response);
203+
204+
assert_eq!(
205+
response["result"][0]["textEdit"]["newText"],
206+
json!(insertion)
207+
);
208+
}
209+
210+
#[test]
211+
fn counts_nested_generic_parameters() {
212+
assert_eq!(count_params("(Map<String, List<Integer>>, int)"), 2);
213+
assert_eq!(count_params("()"), 0);
214+
assert_eq!(count_params("not-a-signature"), 0);
215+
}
216+
}

0 commit comments

Comments
 (0)