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
+423 -4
View File
@@ -18,9 +18,9 @@ use unicode_segmentation::UnicodeSegmentation;
use crate::{
editor::Context,
plugin::{
Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot,
PluginUpdate, SemanticToken, SemanticTokenKind, SemanticTokenModifier, TextPosition,
UpdateEffect,
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
SemanticTokenModifier, TextPosition, UpdateEffect,
},
};
@@ -94,6 +94,7 @@ enum Input {
enum PositionKind {
Definition,
Hover,
Completion,
}
impl PositionKind {
@@ -101,6 +102,7 @@ impl PositionKind {
match self {
Self::Definition => "definition",
Self::Hover => "hover",
Self::Completion => "completion",
}
}
}
@@ -265,6 +267,10 @@ impl Lsp {
self.position(context, request_id, PositionKind::Hover)
}
pub fn completion(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Completion)
}
fn position(
&mut self,
context: &Context,
@@ -600,6 +606,7 @@ enum Request {
Initialize,
Definition(Source),
Hover(Source),
Completion(Source, Arc<DocumentSnapshot>),
SemanticTokens(Arc<DocumentSnapshot>),
Shutdown,
}
@@ -620,6 +627,7 @@ struct Runtime {
ready: bool,
definition: bool,
hover: bool,
completion: bool,
semantic: Option<SemanticOptions>,
sync: SyncOptions,
}
@@ -649,6 +657,7 @@ impl Runtime {
ready: false,
definition: false,
hover: false,
completion: false,
semantic: None,
sync: SyncOptions::default(),
};
@@ -670,6 +679,19 @@ impl Runtime {
"definition": {"dynamicRegistration": false, "linkSupport": true},
"hover": {"dynamicRegistration": false,
"contentFormat": ["plaintext", "markdown"]},
"completion": {
"dynamicRegistration": false, "contextSupport": true,
"insertTextMode": 1,
"completionItem": {
"snippetSupport": false, "commitCharactersSupport": false,
"insertReplaceSupport": true,
"insertTextModeSupport": {"valueSet": [1]},
"resolveSupport": {"properties": []}
},
"completionList": {
"itemDefaults": ["editRange", "insertTextFormat", "insertTextMode"]
}
},
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true},
"semanticTokens": {
"dynamicRegistration": false,
@@ -942,17 +964,26 @@ impl Runtime {
"textDocument/hover",
"server does not support hover",
),
PositionKind::Completion => (
self.completion,
"textDocument/completion",
"server does not support completion",
),
};
if !supported {
return self.emit(message(unsupported, Some(source.request_id)));
}
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
let params = json!({
let mut params = json!({
"textDocument": {"uri": doc.uri}, "position": position
});
if matches!(kind, PositionKind::Completion) {
params["context"] = json!({"triggerKind": 1});
}
let request = match kind {
PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source),
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
};
self.request(method, Some(params), request)
}
@@ -986,6 +1017,35 @@ impl Runtime {
Ok(())
}
fn completion_result(
&self,
source: Source,
snapshot: Arc<DocumentSnapshot>,
result: Result<&Value, String>,
) -> Result<(), String> {
if !self
.documents
.get(&source.buffer_id)
.is_some_and(|doc| doc.primary && Arc::ptr_eq(&doc.snapshot, &snapshot))
{
return Ok(());
}
match result.and_then(|value| decode_completion(&snapshot.text, value, &source)) {
Ok(items) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Completion {
buffer_id: source.buffer_id,
revision: source.revision,
items,
}],
}),
Err(reason) => self.emit(message(
&format!("completion failed: {reason}"),
Some(source.request_id),
)),
}
}
fn semantic_result(
&mut self,
snapshot: Arc<DocumentSnapshot>,
@@ -1074,6 +1134,11 @@ impl Runtime {
Request::Hover(source) => {
self.hover_result(source, Err("unexpected LSP response".into()))
}
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("unexpected LSP response".into()),
),
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("unexpected LSP response".into()))
}
@@ -1089,6 +1154,11 @@ impl Runtime {
Request::Hover(source) => {
self.hover_result(source, Err("invalid LSP error response".into()))
}
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("invalid LSP error response".into()),
),
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("invalid LSP error response".into()))
}
@@ -1102,6 +1172,9 @@ impl Runtime {
Some(source.request_id),
)),
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Err(text.to_owned()))
}
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err(text.to_owned()))
}
@@ -1112,6 +1185,9 @@ impl Runtime {
Request::Initialize => self.initialized(&frame["result"]),
Request::Definition(source) => self.definition_result(source, &frame["result"]),
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Ok(&frame["result"]))
}
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Ok(&frame["result"]))
}
@@ -1138,6 +1214,9 @@ impl Runtime {
self.hover = capabilities
.get("hoverProvider")
.is_some_and(|value| value == true || value.is_object());
self.completion = capabilities
.get("completionProvider")
.is_some_and(Value::is_object);
match semantic_options(capabilities.get("semanticTokensProvider")) {
Ok(options) => self.semantic = options,
Err(reason) => {
@@ -2364,6 +2443,319 @@ fn normalized(text: &str) -> std::borrow::Cow<'_, str> {
}
}
fn completion_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
value
.get(field)
.map(|value| {
value
.as_str()
.ok_or_else(|| format!("invalid completion {field}"))
})
.transpose()
}
fn completion_text(text: &str, multiline: bool) -> Result<(), String> {
if text
.chars()
.any(|ch| ch.is_control() && !(multiline && matches!(ch, '\n' | '\r' | '\t')))
{
return Err("unsupported control character in completion".into());
}
Ok(())
}
fn completion_position(lines: &[&str], value: &Value) -> Result<TextPosition, String> {
let (row, units) = coordinates(value)?;
let line = lines
.get(row)
.ok_or("completion line is outside the document")?;
let mut offset = 0;
let mut column = 0;
for cluster in line.graphemes(true) {
if units == offset {
return Ok(TextPosition { row, column });
}
offset += cluster.encode_utf16().count();
if units < offset {
return Err("completion position splits a grapheme".into());
}
column += 1;
}
if units != offset {
return Err("completion position is outside the line".into());
}
Ok(TextPosition { row, column })
}
fn completion_range(lines: &[&str], value: &Value) -> Result<(TextPosition, TextPosition), String> {
let start = completion_position(lines, &value["start"])?;
let end = completion_position(lines, &value["end"])?;
if (start.row, start.column) > (end.row, end.column) {
return Err("reversed completion range".into());
}
Ok((start, end))
}
fn completion_primary_range(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
let (start, end) = completion_range(lines, value)?;
if start.row != source.row
|| end.row != source.row
|| start.column > source.column
|| end.column < source.column
{
return Err(
"completion primary range must be single-line and contain the request position".into(),
);
}
Ok((start, end))
}
fn completion_replacement(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
if value.get("insert").is_some() || value.get("replace").is_some() {
if value.get("range").is_some() || value.get("start").is_some() {
return Err("ambiguous completion edit range".into());
}
let (start, end) = completion_primary_range(lines, &value["replace"], source)?;
let (insert_start, insert_end) = completion_primary_range(lines, &value["insert"], source)?;
if insert_start != start || insert_end.column > end.column {
return Err("completion insert range is not a prefix of replace range".into());
}
Ok((start, end))
} else {
completion_primary_range(lines, value.get("range").unwrap_or(value), source)
}
}
fn decode_completion(
text: &str,
result: &Value,
source: &Source,
) -> Result<Vec<CompletionItem>, String> {
if result.is_null() {
return Ok(Vec::new());
}
let (items, defaults) = if let Some(items) = result.as_array() {
(items, None)
} else {
if !result["isIncomplete"].is_boolean() {
return Err("invalid completion list isIncomplete".into());
}
let defaults = result.get("itemDefaults");
if defaults.is_some_and(|value| !value.is_object()) {
return Err("invalid completion itemDefaults".into());
}
if let Some(defaults) = defaults {
for key in defaults.as_object().unwrap().keys() {
if !matches!(
key.as_str(),
"editRange" | "insertTextFormat" | "insertTextMode"
) {
return Err("unsupported completion item default".into());
}
}
}
(
result["items"]
.as_array()
.ok_or("invalid completion items")?,
defaults,
)
};
let text = normalized(text);
let lines: Vec<_> = text.split('\n').collect();
let line = lines
.get(source.row)
.ok_or("completion request line is outside the document")?;
let mut prefix_start = 0;
let mut prefix_byte = 0;
let mut caret_byte = line.len();
let mut columns = 0;
for (column, (byte, cluster)) in line.grapheme_indices(true).enumerate() {
if column == source.column {
caret_byte = byte;
break;
}
if !cluster
.chars()
.next()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
{
prefix_start = column + 1;
prefix_byte = byte + cluster.len();
}
columns = column + 1;
}
if source.column > columns {
return Err("completion request column is outside the line".into());
}
let prefix = &line[prefix_byte..caret_byte];
// The prefix is the only guessed range; explicit server edits remain authoritative.
if let Some(defaults) = defaults {
if let Some(range) = defaults.get("editRange") {
completion_replacement(&lines, range, source)?;
}
for name in ["insertTextFormat", "insertTextMode"] {
if defaults
.get(name)
.is_some_and(|value| !matches!(value.as_u64(), Some(1 | 2)))
{
return Err(format!("invalid completion default {name}"));
}
}
}
let mut decoded = Vec::with_capacity(items.len());
let mut bytes = 0usize;
for item in items {
if !item.is_object() {
return Err("invalid completion item".into());
}
let label = completion_string(item, "label")?.ok_or("missing completion label")?;
completion_text(label, false)?;
let detail = completion_string(item, "detail")?;
let sort = completion_string(item, "sortText")?.unwrap_or(label);
let filter = completion_string(item, "filterText")?;
let insert = completion_string(item, "insertText")?;
let edit_text = completion_string(item, "textEditText")?;
if let Some(detail) = detail {
completion_text(detail, true)?;
}
completion_text(sort, false)?;
if let Some(filter) = filter {
completion_text(filter, false)?;
}
let property = |name: &str| {
item.get(name)
.or_else(|| defaults.and_then(|value| value.get(name)))
};
let format = property("insertTextFormat")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextFormat")
})
.transpose()?
.unwrap_or(1);
let mode = property("insertTextMode")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextMode")
})
.transpose()?
.unwrap_or(1);
// No snippets, indentation adjustment, server commands or lazy edit resolution.
if format == 2 || mode == 2 || item.get("command").is_some() {
continue;
}
let explicit = item.get("textEdit");
let default_range = defaults.and_then(|value| value.get("editRange"));
let (start, end, new_text) = if let Some(edit) = explicit {
if !edit.is_object() {
return Err("invalid completion textEdit".into());
}
let (start, end) = completion_replacement(&lines, edit, source)?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing completion newText")?;
(start, end, new_text)
} else if let Some(range) = default_range {
let (start, end) = completion_replacement(&lines, range, source)?;
(start, end, edit_text.unwrap_or(label))
} else {
(
TextPosition {
row: source.row,
column: prefix_start,
},
TextPosition {
row: source.row,
column: source.column,
},
insert.unwrap_or(label),
)
};
let filter_prefix = if explicit.is_some() || default_range.is_some() {
let byte = line
.grapheme_indices(true)
.nth(start.column)
.map_or(line.len(), |(byte, _)| byte);
&line[byte..caret_byte]
} else {
prefix
};
completion_text(new_text, true)?;
let mut edits = vec![CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
}];
if let Some(additional) = item.get("additionalTextEdits") {
for edit in additional
.as_array()
.ok_or("invalid completion additionalTextEdits")?
{
let (start, end) = completion_range(&lines, &edit["range"])?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing additional edit newText")?;
completion_text(new_text, true)?;
edits.push(CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
});
}
}
edits.sort_unstable_by_key(|edit| {
(
(edit.start.row, edit.start.column),
(edit.end.row, edit.end.column),
)
});
if edits.windows(2).any(|pair| {
(pair[0].end.row, pair[0].end.column) > (pair[1].start.row, pair[1].start.column)
|| pair[0].start == pair[1].start
}) {
return Err("overlapping completion edits".into());
}
let size = label.len()
+ detail.map_or(0, str::len)
+ edits.iter().map(|edit| edit.text.len()).sum::<usize>();
bytes = bytes
.checked_add(size)
.filter(|bytes| *bytes <= MAX_BODY)
.ok_or("completion text exceeds 4 MiB")?;
if (filter.is_some() || (explicit.is_none() && default_range.is_none()))
&& !filter.unwrap_or(label).starts_with(filter_prefix)
{
continue;
}
let primary_edit = edits
.iter()
.position(|edit| edit.start == start && edit.end == end)
.unwrap();
decoded.push((
sort,
CompletionItem {
label: label.to_owned(),
detail: detail.map(str::to_owned),
primary_edit,
edits,
},
));
}
decoded.sort_by(|a, b| a.0.cmp(b.0));
Ok(decoded.into_iter().map(|(_, item)| item).collect())
}
fn coordinates(value: &Value) -> Result<(usize, usize), String> {
let line = value["line"]
.as_u64()
@@ -2673,4 +3065,31 @@ mod tests {
assert!(hover_text(&value, source).is_err());
}
}
#[test]
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
let source = Source {
buffer_id: 1,
revision: 0,
row: 0,
column: 0,
request_id: 1,
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
);
let over_limit = json!([
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source)
.unwrap_err()
.contains("4 MiB")
);
}
}