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
+71 -8
View File
@@ -22,6 +22,7 @@ use crate::{
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
SemanticTokenModifier, TextPosition, UpdateEffect,
},
snippet,
};
const MAX_BODY: usize = 4 * 1024 * 1024;
@@ -683,7 +684,7 @@ impl Runtime {
"dynamicRegistration": false, "contextSupport": true,
"insertTextMode": 1,
"completionItem": {
"snippetSupport": false, "commitCharactersSupport": false,
"snippetSupport": true, "commitCharactersSupport": false,
"insertReplaceSupport": true,
"insertTextModeSupport": {"valueSet": [1]},
"resolveSupport": {"properties": []}
@@ -1030,7 +1031,9 @@ impl Runtime {
{
return Ok(());
}
match result.and_then(|value| decode_completion(&snapshot.text, value, &source)) {
match result.and_then(|value| {
decode_completion(&snapshot.text, value, &source, snapshot.path.as_deref())
}) {
Ok(items) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Completion {
@@ -2538,6 +2541,7 @@ fn decode_completion(
text: &str,
result: &Value,
source: &Source,
path: Option<&str>,
) -> Result<Vec<CompletionItem>, String> {
if result.is_null() {
return Ok(Vec::new());
@@ -2611,6 +2615,17 @@ fn decode_completion(
}
}
}
let word_end = caret_byte
+ line[caret_byte..]
.graphemes(true)
.take_while(|cluster| {
cluster
.chars()
.next()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
})
.map(str::len)
.sum::<usize>();
let mut decoded = Vec::with_capacity(items.len());
let mut bytes = 0usize;
for item in items {
@@ -2653,8 +2668,8 @@ fn decode_completion(
})
.transpose()?
.unwrap_or(1);
// No snippets, indentation adjustment, server commands or lazy edit resolution.
if format == 2 || mode == 2 || item.get("command").is_some() {
// No indentation adjustment, server commands or lazy edit resolution.
if mode == 2 || item.get("command").is_some() {
continue;
}
let explicit = item.get("textEdit");
@@ -2669,7 +2684,7 @@ fn decode_completion(
(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))
(start, end, edit_text.or(insert).unwrap_or(label))
} else {
(
TextPosition {
@@ -2693,10 +2708,25 @@ fn decode_completion(
prefix
};
completion_text(new_text, true)?;
let (new_text, snippet) = if format == 2 {
match snippet::expand(new_text, |name| {
completion_variable(name, line, &line[prefix_byte..word_end], source.row, path)
}) {
Ok((text, snippet)) => (text, Some(snippet)),
Err(snippet::Error::Unsupported) => continue,
Err(snippet::Error::Invalid) => return Err("invalid completion snippet".into()),
Err(snippet::Error::Limit) => {
return Err("completion snippet exceeds expansion limits".into());
}
}
} else {
(normalized(new_text).into_owned(), None)
};
completion_text(&new_text, true)?;
let mut edits = vec![CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
text: new_text,
}];
if let Some(additional) = item.get("additionalTextEdits") {
for edit in additional
@@ -2749,6 +2779,7 @@ fn decode_completion(
detail: detail.map(str::to_owned),
primary_edit,
edits,
snippet,
},
));
}
@@ -2756,6 +2787,38 @@ fn decode_completion(
Ok(decoded.into_iter().map(|(_, item)| item).collect())
}
fn completion_variable(
name: &str,
line: &str,
word: &str,
row: usize,
path: Option<&str>,
) -> Option<String> {
let value = match name {
"TM_SELECTED_TEXT" => "",
"TM_CURRENT_LINE" => line,
"TM_CURRENT_WORD" => word,
"TM_LINE_INDEX" => return Some(row.to_string()),
"TM_LINE_NUMBER" => return Some((row + 1).to_string()),
"TM_FILEPATH" => path.unwrap_or(""),
"TM_FILENAME" => path
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.unwrap_or(""),
"TM_FILENAME_BASE" => path
.and_then(|path| Path::new(path).file_stem())
.and_then(|name| name.to_str())
.unwrap_or(""),
"TM_DIRECTORY" => path
.and_then(|path| Path::new(path).parent())
.and_then(|name| name.to_str())
.unwrap_or(""),
_ => return None,
};
// Context values have the same per-expansion bound as template text.
(value.len() <= 256 * 1024).then(|| value.to_owned())
}
fn coordinates(value: &Value) -> Result<(usize, usize), String> {
let line = value["line"]
.as_u64()
@@ -3077,7 +3140,7 @@ mod tests {
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source).unwrap()[0].edits[0]
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
@@ -3087,7 +3150,7 @@ mod tests {
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source)
decode_completion("", &over_limit, &source, None)
.unwrap_err()
.contains("4 MiB")
);