feat: expand completion snippets and navigate placeholders

This commit is contained in:
2026-10-10 19:12:13 +02:00
parent b6059d5ed9
commit 232be6a032
12 changed files with 2194 additions and 49 deletions
+366 -3
View File
@@ -2301,7 +2301,7 @@ fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
["alpha", "alps"]
["alpha", "alps", "alSnippet"]
);
assert_eq!(popup.items[0].detail.as_deref(), Some("first candidate"));
assert_eq!(popup.selected, 1);
@@ -2310,9 +2310,13 @@ fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "alsnippet tail\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().completion().unwrap().selected, 0);
assert_eq!(session.editor().context().text, "al tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alsnippet tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alpha tail\n");
@@ -2481,7 +2485,7 @@ fn lsp_completion_utf16_text_edit_insert_replace_and_defaults_are_atomic() {
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap()["params"]["capabilities"]["textDocument"]["completion"];
assert_eq!(capabilities["completionItem"]["snippetSupport"], false);
assert_eq!(capabilities["completionItem"]["snippetSupport"], true);
assert_eq!(capabilities["completionItem"]["insertReplaceSupport"], true);
assert_eq!(
capabilities["completionItem"]["resolveSupport"]["properties"],
@@ -2556,6 +2560,10 @@ fn lsp_completion_feature_errors_keep_healthy_services() {
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 1), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 0), "newText": "x"}, "additionalTextEdits": [{"range": range(0, 0), "newText": "bad"}]}]}),
json!({"result": [{"label": "x", "additionalTextEdits": {}}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:$1}"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": "${1:e}\u{301}"}]}),
json!({"result": [{"label": "x", "insertTextFormat": 2, "insertText": format!("{}x{}", "${1:".repeat(33), "}".repeat(33))}]}),
json!({"error": {"code": -32603, "message": "completion unavailable"}}),
json!({"error": {"message": "malformed error"}}),
];
@@ -2623,7 +2631,6 @@ fn lsp_completion_fallback_filter_text_insert_text_and_empty_shapes() {
Value::Null,
json!([]),
json!({"isIncomplete": false, "items": []}),
json!([{"label": "alSnippet", "insertTextFormat": 2, "insertText": "${1:al}"}]),
]
.into_iter()
.enumerate()
@@ -2710,3 +2717,359 @@ fn lsp_completion_routes_only_to_first_configured_server_without_fallback() {
session.handle(Input::Char('!'));
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
}
#[test]
fn lsp_snippet_preview_mirrors_navigation_final_stop_undo_and_resolved_dot() {
let fixture = Fixture::new();
fs::write(&fixture.source, " tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Char('c'));
session.handle(Input::Char('a'));
let original_carets = session.editor().carets();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2,
"insertText": "call(${1:arg}, $1, ${2|left,right|})$0"
}]}),
);
assert_eq!(
session.editor().context().text,
"call(arg, arg, left) tail\n"
);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca tail\n");
assert_eq!(session.editor().carets(), original_carets);
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(arg, arg, left) tail\n"
);
session.handle(Input::Char('x'));
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(xy, xy, left) tail\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().snippet_selections().len(), 1);
session.handle(Input::BackTab);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('v'));
assert_eq!(session.editor().context().text, "call(v, v, left) tail\n");
session.handle(Input::Char('\t'));
session.handle(Input::Char('r'));
assert_eq!(session.editor().context().text, "call(v, v, r) tail\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 13 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('\t'));
session.handle(Input::Enter);
let resolved = "call(v, v, r)\t\n tail\n";
assert_eq!(session.editor().context().text, resolved);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, resolved);
assert_eq!(fixture.method_count("textDocument/completion"), 1);
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
}
#[test]
fn lsp_snippet_primary_edit_preserves_secondary_carets_and_escape_keeps_defaults() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca tail\nca tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Ctrl('e'));
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
let original = session.editor().carets();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg}, $1)$0",
"additionalTextEdits": [{
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
"newText": "use X;\n"
}]
}]}),
);
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(arg, arg) tail\n"
);
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
assert_eq!(session.editor().carets(), original);
session.handle(Input::Ctrl('n'));
session.handle(Input::Char('x'));
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(x, x) tail\n"
);
assert_eq!(session.editor().carets()[0], Cursor { row: 1, column: 2 });
session.handle(Input::Escape);
assert_eq!(
session.editor().context().text,
"use X;\nca tail\ncall(x, x) tail\n"
);
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca tail\nca tail\n");
}
#[test]
fn lsp_snippet_defaults_all_edit_shapes_context_and_unsupported_siblings() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca suffix\n// tail\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let range = json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 2}});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {
"isIncomplete": false,
"itemDefaults": {"editRange": range, "insertTextFormat": 2},
"items": [
{"label": "unsupported transform", "sortText": "0", "textEditText": "${1/a/b/}"},
{"label": "unknown context", "sortText": "0", "textEditText": "$CLIPBOARD"},
{"label": "replacement", "sortText": "1", "textEdit": {"range": range, "newText": "call(${1:λ})\r\n$0"},
"additionalTextEdits": [{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "// $1\n"}]},
{"label": "insert replace", "sortText": "2", "textEdit": {"insert": range, "replace": range, "newText": "call(${1:${2:arg}})$0"}},
{"label": "default text edit", "sortText": "3", "textEditText": "call(${1|one,two|})$0"},
{"label": "default insert text", "sortText": "4", "insertText": "call(${1:${UNKNOWN:${TM_LINE_NUMBER}}})$0"},
{"label": "default label ${1:arg}$0", "sortText": "5"}
]
}}),
);
assert_eq!(
session.editor().context().text,
"call(λ)\n suffix\n// $1\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(arg) suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"call(one) suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "call(1) suffix\n// tail\n");
session.handle(Input::Ctrl('n'));
assert_eq!(
session.editor().context().text,
"default label arg suffix\n// tail\n"
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "ca suffix\n// tail\n");
session.handle(Input::Escape);
assert!(!session.editor().dirty());
}
#[test]
fn lsp_snippet_escape_preserves_preview_and_ordinary_keys_remain_literal() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1:arg})$0"
}]}),
);
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().context().text, "call(arg)\n");
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('A'));
session.handle(Input::Char('\t'));
session.handle(Input::Enter);
assert_eq!(session.editor().context().text, "call(arg)\t\n\n");
assert_eq!(fixture.method_count("textDocument/completion"), 1);
}
#[test]
fn lsp_snippet_nested_defaults_update_enclosing_mirrors_then_replace_outer() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2,
"insertText": "call(${2:outer ${1:inner}}, $2)$0"
}]}),
);
assert_eq!(
session.editor().context().text,
"call(outer inner, outer inner)\n"
);
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('x'));
assert_eq!(session.editor().context().text, "call(outer x, outer x)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(y, y)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 10 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}
#[test]
fn lsp_snippet_ancestry_distinguishes_empty_child_from_adjacent_root() {
for (template, nested) in [("${1:foo${2}}-$3$0", true), ("${1:foo}$2-$3$0", false)] {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": template
}]}),
);
assert_eq!(session.editor().context().text, "foo-\n");
let snippet = session.editor().completion().unwrap().items[0]
.snippet
.as_ref()
.unwrap();
assert_eq!(
snippet.tabstops[1].ancestors,
[if nested { vec![1] } else { vec![] }]
);
session.handle(Input::Char('X'));
assert_eq!(session.editor().context().text, "X-\n");
session.handle(Input::Char('\t'));
if nested {
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
} else {
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "Xy-\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
}
session.handle(Input::Char('z'));
assert_eq!(
session.editor().context().text,
if nested { "X-z\n" } else { "Xy-z\n" }
);
session.handle(Input::Char('\t'));
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}
}
#[test]
fn lsp_snippet_coincident_empty_mirrors_insert_and_delete_each_occurrence() {
let fixture = Fixture::new();
fs::write(&fixture.source, "ca\n").unwrap();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "call", "insertTextFormat": 2, "insertText": "call(${1}$1)$0"
}]}),
);
assert_eq!(session.editor().context().text, "call()\n");
assert_eq!(session.editor().snippet_selections().len(), 2);
session.handle(Input::Char('x'));
session.handle(Input::Char('y'));
assert_eq!(session.editor().context().text, "call(xyxy)\n");
session.handle(Input::Backspace);
assert_eq!(session.editor().context().text, "call(xx)\n");
session.handle(Input::Backspace);
assert_eq!(session.editor().context().text, "call()\n");
session.handle(Input::Char('z'));
assert_eq!(session.editor().context().text, "call(zz)\n");
session.handle(Input::Char('\t'));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 });
assert!(session.editor().snippet_selections().is_empty());
session.handle(Input::Char('\t'));
assert_eq!(session.editor().context().text, "call(zz)\t\n");
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "ca\n");
}