Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proxy-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/lib.rs"
tokio = ["dep:tokio"]

[dependencies]
percent-encoding = "2.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", default-features = false, features = ["io-util"], optional = true }
Expand Down
93 changes: 78 additions & 15 deletions proxy-common/src/uri.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,84 @@
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use std::path::Path;

/// Convert a filesystem path to a `file://` URI, matching how language servers'
/// `publishDiagnostics` and the editor key documents.
///
/// On Unix the path already starts with `/`, so `file://` + path gives the
/// correct `file:///…` form with no extra work.
///
/// On Windows the backslashes are replaced with `/` and an extra `/` is
/// prepended before the drive letter, so we get `file:///C:/…` rather than
/// `file://C:\…`.
#[cfg(unix)]
const PATH_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
.remove(b'/')
.remove(b':')
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'@');

/// Convert a filesystem path to an RFC 3986 percent-encoded `file://` URI.
pub fn path_to_file_uri(path: &Path) -> String {
format!("file://{}", path.display())
file_uri_from_path_string(&path.to_string_lossy(), cfg!(windows))
}

#[cfg(windows)]
pub fn path_to_file_uri(path: &Path) -> String {
let s = path.display().to_string().replace('\\', "/");
format!("file:///{s}")
fn file_uri_from_path_string(path: &str, windows: bool) -> String {
let mut normalized = if windows {
path.replace('\\', "/")
} else {
path.to_string()
};
if windows {
if let Some(unc) = normalized.strip_prefix("//?/UNC/") {
normalized = format!("//{unc}");
} else if let Some(verbatim) = normalized.strip_prefix("//?/") {
normalized = verbatim.to_string();
}
if let Some(unc) = normalized.strip_prefix("//") {
return format!("file://{}", utf8_percent_encode(unc, &PATH_ENCODE_SET));
}
}
let prefix = if normalized.starts_with('/') {
"file://"
} else {
"file:///"
};
format!(
"{prefix}{}",
utf8_percent_encode(&normalized, &PATH_ENCODE_SET)
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn encodes_reserved_characters_in_unix_paths() {
assert_eq!(
file_uri_from_path_string("/tmp/Java Sources/#1%?.java", false),
"file:///tmp/Java%20Sources/%231%25%3F.java"
);
}

#[test]
fn normalizes_and_encodes_windows_paths() {
assert_eq!(
file_uri_from_path_string(r"C:\Users\Jane Doe\A#1.java", true),
"file:///C:/Users/Jane%20Doe/A%231.java"
);
}

#[test]
fn preserves_windows_unc_authority() {
assert_eq!(
file_uri_from_path_string(r"\\server\share\A File.java", true),
"file://server/share/A%20File.java"
);
}

#[test]
fn normalizes_windows_verbatim_paths() {
assert_eq!(
file_uri_from_path_string(r"\\?\C:\Users\Jane Doe\A.java", true),
"file:///C:/Users/Jane%20Doe/A.java"
);
assert_eq!(
file_uri_from_path_string(r"\\?\UNC\server\share\A.java", true),
"file://server/share/A.java"
);
}
}
2 changes: 2 additions & 0 deletions proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ path = "src/main.rs"

[dependencies]
proxy-common.workspace = true
hex = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha1 = "0.10"
177 changes: 152 additions & 25 deletions proxy/src/completions.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
use serde_json::Value;

/// Returns true if the message contains a completion response with items.
pub fn is_completion_response(msg: &Value) -> bool {
msg.get("result").is_some_and(|result| {
result.get("items").is_some_and(|v| v.is_array()) || result.is_array()
})
}

/// Single-pass processing of completion items:
/// - Sorts methods/functions by parameter count (prepends count to sortText)
/// - Strips unsupported VS Code snippet variables ($TM_SELECTED_TEXT) from snippets
pub fn process_completions(msg: &mut Value) {
let default_insert_text_format = msg
.pointer("/result/itemDefaults/insertTextFormat")
.and_then(Value::as_u64);
let items = match msg.get_mut("result") {
Some(result) if result.is_array() => result.as_array_mut(),
Some(result) => result.get_mut("items").and_then(|v| v.as_array_mut()),
Some(result) => result.get_mut("items").and_then(Value::as_array_mut),
None => None,
};

Expand All @@ -33,20 +29,29 @@ pub fn process_completions(msg: &mut Value) {
let existing = item.get("sortText").and_then(|v| v.as_str()).unwrap_or("");
item["sortText"] = Value::String(format!("{count:02}{existing}"));
}
// Snippet (15): strip $TM_SELECTED_TEXT
15 => {
strip_tm_selected_text(item, "textEditText");
strip_tm_selected_text(item, "insertText");
}
_ => {}
}

let insert_text_format = item
.get("insertTextFormat")
.and_then(Value::as_u64)
.or(default_insert_text_format);
if kind == 15 || insert_text_format == Some(2) {
sanitize_completion_item(item);
}
}
}

fn strip_tm_selected_text(item: &mut Value, key: &str) {
if let Some(text) = item.get(key).and_then(|v| v.as_str()) {
fn sanitize_completion_item(item: &mut Value) {
strip_tm_selected_text(item, "/textEditText");
strip_tm_selected_text(item, "/insertText");
strip_tm_selected_text(item, "/textEdit/newText");
}

fn strip_tm_selected_text(item: &mut Value, pointer: &str) {
if let Some(Value::String(text)) = item.pointer_mut(pointer) {
if text.contains("$TM_SELECTED_TEXT") {
item[key] = Value::String(text.replace("$TM_SELECTED_TEXT", ""));
*text = text.replace("$TM_SELECTED_TEXT", "");
}
}
}
Expand All @@ -56,15 +61,7 @@ pub fn sanitize_resolved_completion(msg: &mut Value) {
let Some(result) = msg.get_mut("result") else {
return;
};
strip_tm_selected_text(result, "textEditText");
strip_tm_selected_text(result, "insertText");
// Also check inside textEdit.newText
if let Some(new_text) = result.pointer("/textEdit/newText").and_then(|v| v.as_str()) {
if new_text.contains("$TM_SELECTED_TEXT") {
result["textEdit"]["newText"] =
Value::String(new_text.replace("$TM_SELECTED_TEXT", ""));
}
}
sanitize_completion_item(result);
}

fn count_params(detail: &str) -> usize {
Expand All @@ -87,3 +84,133 @@ fn count_params(detail: &str) -> usize {
}
count
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn processes_array_completion_results() {
let mut response = json!({
"result": [{
"kind": 2,
"labelDetails": { "detail": "(String, List<String>)" },
"sortText": "method"
}]
});

process_completions(&mut response);

assert_eq!(response["result"][0]["sortText"], json!("02method"));
}

#[test]
fn applies_list_item_default_snippet_format() {
let mut response = json!({
"result": {
"itemDefaults": { "insertTextFormat": 2 },
"items": [{
"kind": 2,
"insertText": "$TM_SELECTED_TEXT.trim()"
}]
}
});

process_completions(&mut response);

assert_eq!(
response["result"]["items"][0]["insertText"],
json!(".trim()")
);
}

#[test]
fn sanitizes_text_edit_text() {
let mut response = json!({
"result": [{
"kind": 15,
"textEditText": "$TM_SELECTED_TEXT.field"
}]
});

process_completions(&mut response);

assert_eq!(response["result"][0]["textEditText"], json!(".field"));
}

#[test]
fn sanitizes_insert_replace_edit_text() {
let mut response = json!({
"result": [{
"kind": 15,
"textEdit": {
"newText": "$TM_SELECTED_TEXT.var",
"insert": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"replace": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 3 }
}
}
}]
});

process_completions(&mut response);

assert_eq!(response["result"][0]["textEdit"]["newText"], json!(".var"));
}

#[test]
fn leaves_plain_text_completion_unchanged() {
let mut response = json!({
"result": [{
"kind": 1,
"insertTextFormat": 1,
"insertText": "$TM_SELECTED_TEXT"
}]
});

process_completions(&mut response);

assert_eq!(
response["result"][0]["insertText"],
json!("$TM_SELECTED_TEXT")
);
}

#[test]
fn postfix_var_completion_keeps_jdtls_spacing() {
let insertion = "var name = \"hello world\";";
let mut response = json!({
"result": [{
"label": ".var",
"kind": 15,
"insertTextFormat": 2,
"textEdit": {
"newText": insertion,
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 17 }
}
}
}]
});

process_completions(&mut response);

assert_eq!(
response["result"][0]["textEdit"]["newText"],
json!(insertion)
);
}

#[test]
fn counts_nested_generic_parameters() {
assert_eq!(count_params("(Map<String, List<Integer>>, int)"), 2);
assert_eq!(count_params("()"), 0);
assert_eq!(count_params("not-a-signature"), 0);
}
}
Loading