feat: add live-cycling LSP completion
This commit is contained in:
+423
-4
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user