feat: add live-cycling LSP completion

This commit is contained in:
2026-10-10 18:32:39 +02:00
parent 9d3b25e9d3
commit b6059d5ed9
11 changed files with 1916 additions and 55 deletions
+473 -1
View File
@@ -68,12 +68,13 @@ initialize_id = nil
initialized_sent = false
held = []
semantic_held = []
completion_held = []
definition_count = 0
source_uri = uri(source)
documents = {}
control_id = nil
loop do
if initialize_id && !initialized_sent && (!['gated', 'hover-gated', 'controlled-gated'].include?(mode) || File.exist?(release))
if initialize_id && !initialized_sent && (!['gated', 'hover-gated', 'completion-gated', 'controlled-gated'].include?(mode) || File.exist?(release))
sync = case mode
when 'numeric' then 1
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
@@ -83,6 +84,7 @@ loop do
capabilities = {'positionEncoding' => 'utf-16',
'textDocumentSync' => sync, 'definitionProvider' => ['unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false},
'hoverProvider' => ['hover-unsupported', 'controlled-unsupported'].include?(mode) ? false : {'workDoneProgress' => false}}
capabilities['completionProvider'] = {'resolveProvider' => true} unless ['completion-unsupported', 'controlled-unsupported'].include?(mode)
if mode.include?('semantic')
capabilities['semanticTokensProvider'] = {
'legend' => {'tokenTypes' => ['custom', 'variable', 'function', 'comment', 'type'],
@@ -115,6 +117,10 @@ loop do
request = semantic_held.delete_at(command.fetch('index', 0))
raise 'no held semantic request' unless request
emit({'id' => request.fetch('id')}.merge(command.fetch('response')))
when 'completion-result'
request = completion_held.delete_at(command.fetch('index', 0))
raise 'no held completion request' unless request
emit({'id' => request.fetch('id')}.merge(command.fetch('response')))
when 'refresh'
emit({'id' => "refresh-#{control_id}", 'method' => 'workspace/semanticTokens/refresh'})
when 'die'
@@ -223,6 +229,21 @@ loop do
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
result(frame['id'], hover)
end
when 'textDocument/completion'
if mode.include?('completion-controlled')
completion_held << frame
elsif mode == 'completion-empty'
result(frame['id'], nil)
else
result(frame['id'], [
{'label' => 'alps', 'detail' => 'second candidate', 'sortText' => '2'},
{'label' => 'alpha', 'detail' => 'first candidate', 'sortText' => '1'},
{'label' => 'unrelated', 'sortText' => '0'},
{'label' => 'alSnippet', 'insertTextFormat' => 2, 'insertText' => 'al${1:snippet}'},
{'label' => 'alCommand', 'command' => {'title' => 'unsupported', 'command' => 'fix'}},
{'label' => 'alIndented', 'insertTextMode' => 2}
])
end
when 'textDocument/semanticTokens/full', 'textDocument/semanticTokens/range'
if mode.start_with?('controlled')
semantic_held << frame
@@ -2238,3 +2259,454 @@ fn lsp_semantic_primary_retirement_clears_active_and_parked_without_sibling_take
);
}
}
fn completion_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, response: Value) {
fixture.control(
0,
id,
json!({"action": "completion-result", "response": response}),
);
until(session, fixture, |_| fixture.acknowledged(0, id));
// The acknowledgement is sent after the worker queued preceding result updates.
session.poll();
}
#[test]
fn lsp_completion_live_cycle_original_and_ordinary_keys_use_one_request() {
for (input, expected, mode) in [
(Input::Char('!'), "alpha! tail\n", Mode::Insert),
(Input::Enter, "alpha\n tail\n", Mode::Insert),
(Input::Char('\t'), "alpha\t tail\n", Mode::Insert),
(Input::Escape, "alpha tail\n", Mode::Normal),
] {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
assert!(session.editor().completion().is_none());
assert_eq!(fixture.method_count("textDocument/completion"), 0);
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
let popup = session.editor().completion().unwrap();
assert_eq!(
popup
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
["alpha", "alps"]
);
assert_eq!(popup.items[0].detail.as_deref(), Some("first candidate"));
assert_eq!(popup.selected, 1);
assert_eq!(session.editor().context().text, "alpha tail\n");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 5 });
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "alps 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, "alps tail\n");
session.handle(Input::Ctrl('k'));
assert_eq!(session.editor().context().text, "alpha tail\n");
session.handle(input);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, expected);
assert_eq!(session.editor().mode(), mode);
assert_eq!(fixture.method_count("textDocument/completion"), 1);
assert_eq!(fixture.method_count("completionItem/resolve"), 0);
if mode == Mode::Insert {
session.handle(Input::Escape);
}
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\n");
}
}
#[test]
fn lsp_completion_groups_typed_prefix_and_only_repeats_the_final_candidate() {
let fixture = Fixture::new();
fs::write(&fixture.source, " tail\n").unwrap();
let mut session = fixture.open("completion");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
session.handle(Input::Char('a'));
session.handle(Input::Char('l'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
session.handle(Input::Ctrl('n'));
session.handle(Input::Ctrl('j'));
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "alps tail\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, "alps tail\n");
assert_eq!(fixture.method_count("textDocument/completion"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, " tail\n");
}
#[test]
fn lsp_completion_primary_only_translates_and_preserves_secondary_carets() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al tail\nal 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'));
assert_eq!(session.editor().carets().len(), 2);
session.handle(Input::Char('i'));
session.handle(Input::Right);
session.handle(Input::Right);
let original_carets = session.editor().carets();
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 2 });
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [{
"label": "alpha",
"textEdit": {"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 2}}, "newText": "alpha"},
"additionalTextEdits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "use X;\n"}]
}]}),
);
assert_eq!(
session.editor().context().text,
"use X;\nal tail\nalpha tail\n"
);
assert_eq!(
session.editor().carets(),
[Cursor { row: 1, column: 2 }, Cursor { row: 2, column: 5 }]
);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
assert_eq!(session.editor().carets(), original_carets);
session.handle(Input::Ctrl('k'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "al tail\nal tail\n");
let request = fixture
.frames()
.into_iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 1, "character": 2})
);
}
#[test]
fn lsp_completion_utf16_text_edit_insert_replace_and_defaults_are_atomic() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
session.handle(Input::Char('i'));
for _ in 0..3 {
session.handle(Input::Right);
}
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {
"isIncomplete": true,
"itemDefaults": {"editRange": {"insert": range(1, 5), "replace": range(1, 6)}, "insertTextFormat": 1, "insertTextMode": 1},
"items": [
{"label": "first", "sortText": "1", "textEdit": {"range": range(1, 5), "newText": "λ"}},
{"label": "second", "sortText": "2", "textEdit": {"insert": range(1, 5), "replace": range(1, 6), "newText": "omega"}, "additionalTextEdits": [
{"range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "tail"}
]},
{"label": "default", "sortText": "3", "textEditText": "δ"},
{"label": "filtered", "sortText": "0", "filterText": "does-not-match", "textEditText": "bad"}
]
}}),
);
assert_eq!(session.editor().context().text, "AλZ\n");
let popup = session.editor().completion().unwrap();
assert_eq!(popup.items.len(), 3);
assert_eq!(
popup.items[0].edits[0].start,
TextPosition { row: 0, column: 1 }
);
assert_eq!(
popup.items[0].edits[0].end,
TextPosition { row: 0, column: 3 }
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aomega\ntail");
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 6 });
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "Aδ\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
session.handle(Input::Escape);
assert!(!session.editor().dirty());
let frames = fixture.frames();
let request = frames
.iter()
.find(|frame| frame["method"] == "textDocument/completion")
.unwrap();
assert_eq!(
request["params"]["position"],
json!({"line": 0, "character": 5})
);
assert_eq!(request["params"]["context"], json!({"triggerKind": 1}));
let capabilities = &frames
.iter()
.find(|frame| frame["method"] == "initialize")
.unwrap()["params"]["capabilities"]["textDocument"]["completion"];
assert_eq!(capabilities["completionItem"]["snippetSupport"], false);
assert_eq!(capabilities["completionItem"]["insertReplaceSupport"], true);
assert_eq!(
capabilities["completionItem"]["resolveSupport"]["properties"],
json!([])
);
}
#[test]
fn lsp_completion_waits_for_initialize_and_stale_payloads_are_not_decoded() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
assert_eq!(fixture.method_count("textDocument/completion"), 0);
fixture.release();
until(&mut session, &fixture, |session| {
session.editor().completion().is_some()
});
assert_eq!(session.editor().context().text, "alpha\n");
drop(session);
for input in [Input::Escape, Input::Right, Input::Char('!')] {
let fixture = Fixture::new();
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::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 1
});
session.handle(input);
let text = session.editor().context().text;
completion_reply(
&mut session,
&fixture,
1,
json!({"result": {"invalid": "stale"}}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, text);
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
}
}
#[test]
fn lsp_completion_feature_errors_keep_healthy_services() {
let fixture = Fixture::new();
let mut session = fixture.open("completion-controlled");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let range = |start, end| json!({"start": {"line": 0, "character": start}, "end": {"line": 0, "character": end}});
let invalid = vec![
json!({"result": {"items": []}}),
json!({"result": [{"detail": "missing label"}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 99), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 2), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 4), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(1, 5), "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": {"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}}, "newText": "x"}}]}),
json!({"result": [{"label": "x", "textEdit": {"range": range(0, 1), "newText": "\u{1b}"}}]}),
json!({"result": [{"label": "x", "textEdit": {"insert": range(0, 3), "replace": range(0, 1), "newText": "x"}}]}),
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!({"error": {"code": -32603, "message": "completion unavailable"}}),
json!({"error": {"message": "malformed error"}}),
];
for (index, response) in invalid.into_iter().enumerate() {
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 1
});
completion_reply(&mut session, &fixture, index as u64 + 1, response);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
assert_eq!(session.editor().diagnostics().count(), 2);
session.handle(Input::Escape);
}
session.execute("lsp.hover");
until(&mut session, &fixture, |session| {
session.editor().hover().is_some()
});
session.handle(Input::Escape);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
}
#[test]
fn lsp_completion_fallback_filter_text_insert_text_and_empty_shapes() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al suffix\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
});
completion_reply(
&mut session,
&fixture,
1,
json!({"result": [
{"label": "display name", "filterText": "alpha", "insertText": "actual", "sortText": "1"},
{"label": "alLabel", "sortText": "2"},
{"label": "does not match", "filterText": "no", "insertText": "must not insert"}
]}),
);
assert_eq!(session.editor().context().text, "actual suffix\n");
assert_eq!(
session.editor().completion().unwrap().items[0].label,
"display name"
);
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "alLabel suffix\n");
session.handle(Input::Ctrl('j'));
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
for (index, result) in [
Value::Null,
json!([]),
json!({"isIncomplete": false, "items": []}),
json!([{"label": "alSnippet", "insertTextFormat": 2, "insertText": "${1:al}"}]),
]
.into_iter()
.enumerate()
{
session.handle(Input::Char('i'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == index + 2
});
completion_reply(
&mut session,
&fixture,
index as u64 + 2,
json!({"result": result}),
);
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al suffix\n");
session.handle(Input::Escape);
}
}
#[test]
fn lsp_completion_superseded_and_preinitialize_changed_sources_never_replace_text() {
let fixture = Fixture::new();
fs::write(&fixture.source, "al\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'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/completion") == 2
});
fixture.control(0, 1, json!({"action": "completion-result", "index": 1, "response": {"result": [{"label": "alLatest"}]}}));
until(&mut session, &fixture, |_| fixture.acknowledged(0, 1));
assert_eq!(session.editor().context().text, "alLatest\n");
completion_reply(
&mut session,
&fixture,
2,
json!({"result": {"malformed": "superseded"}}),
);
assert_eq!(session.editor().context().text, "alLatest\n");
assert!(session.editor().completion().is_some());
drop(session);
let fixture = Fixture::new();
fs::write(&fixture.source, "al\n").unwrap();
let mut session = fixture.open("completion-gated");
session.handle(Input::Char('A'));
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |_| {
fixture.method_count("initialize") == 1
});
session.handle(Input::Char('!'));
fixture.release();
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/didOpen") == 1
});
assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "al!\n");
assert_eq!(fixture.method_count("textDocument/completion"), 0);
}
#[test]
fn lsp_completion_routes_only_to_first_configured_server_without_fallback() {
let fixture = Fixture::new();
let mut session = fixture.open_stack(["controlled-unsupported", "controlled"], false);
until(&mut session, &fixture, |_| {
fixture.worker_count(0, "textDocument/didOpen") == 1
&& fixture.worker_count(1, "textDocument/didOpen") == 1
});
session.handle(Input::Char('i'));
let previous_message = session.editor().message().to_owned();
session.handle(Input::Ctrl('n'));
until(&mut session, &fixture, |session| {
session.editor().message() != previous_message
});
assert!(session.editor().completion().is_none());
assert_eq!(fixture.worker_count(0, "textDocument/completion"), 0);
assert_eq!(fixture.worker_count(1, "textDocument/completion"), 0);
session.handle(Input::Char('!'));
assert_eq!(session.editor().context().text, "!A😀e\u{301}Z\n");
}