feat: expand completion snippets and navigate placeholders
This commit is contained in:
+1147
-24
File diff suppressed because it is too large
Load Diff
@@ -7,5 +7,6 @@ mod markdown;
|
||||
pub mod plugin;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
pub mod tui;
|
||||
|
||||
+71
-8
@@ -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")
|
||||
);
|
||||
|
||||
+3
-2
@@ -19,7 +19,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged)
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
@@ -32,7 +32,8 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
v/V switches selection shape; same selector or Esc cancels retaining carets
|
||||
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
|
||||
Candidates replace text live; Original text restores it; other keys close and act normally
|
||||
Enter inserts newline, Tab inserts tab, Esc returns to Normal retaining candidate/carets
|
||||
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
|
||||
@@ -113,6 +113,22 @@ pub struct CompletionEdit {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Expanded snippet tabstops, in byte offsets relative to the primary edit text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionSnippet {
|
||||
/// Positive tabstops in ascending order, followed by the final zero tabstop.
|
||||
pub tabstops: Vec<SnippetTabstop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SnippetTabstop {
|
||||
pub index: u32,
|
||||
/// Repeated occurrences of the same tabstop are edited together.
|
||||
pub ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Enclosing numbered stops per occurrence, outermost first; parallel to ranges.
|
||||
pub ancestors: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// Protocol-independent completion text and its atomic document edits.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CompletionItem {
|
||||
@@ -121,6 +137,7 @@ pub struct CompletionItem {
|
||||
/// Index of the primary replacement; other edits never determine caret placement.
|
||||
pub primary_edit: usize,
|
||||
pub edits: Vec<CompletionEdit>,
|
||||
pub snippet: Option<CompletionSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
use std::{collections::BTreeMap, ops::Range};
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::plugin::{CompletionSnippet, SnippetTabstop};
|
||||
|
||||
const MAX_BYTES: usize = 256 * 1024;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const MAX_STOPS: usize = 1024;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_WORK: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Error {
|
||||
Invalid,
|
||||
Unsupported,
|
||||
Limit,
|
||||
}
|
||||
|
||||
enum Node<'a> {
|
||||
Text(&'a str),
|
||||
Value(String),
|
||||
Stop(u32, Option<Vec<usize>>),
|
||||
Default(Vec<usize>),
|
||||
}
|
||||
|
||||
struct Parser<'a, F> {
|
||||
text: &'a str,
|
||||
cursor: usize,
|
||||
nodes: Vec<Node<'a>>,
|
||||
value_bytes: usize,
|
||||
variable: F,
|
||||
}
|
||||
|
||||
/// Parse normalized snippet text before assigning any byte offsets. Unsupported
|
||||
/// constructs omit the entire candidate, never leaking template syntax into text.
|
||||
pub(crate) fn expand(
|
||||
text: &str,
|
||||
variable: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<(String, CompletionSnippet), Error> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let normalized = if text.contains('\r') {
|
||||
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(text)
|
||||
};
|
||||
let mut parser = Parser {
|
||||
text: &normalized,
|
||||
cursor: 0,
|
||||
nodes: Vec::new(),
|
||||
value_bytes: 0,
|
||||
variable,
|
||||
};
|
||||
let roots = parser.sequence(false, 0)?;
|
||||
let mut definitions = BTreeMap::new();
|
||||
let mut pending: Vec<_> = roots.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
match &parser.nodes[id] {
|
||||
Node::Stop(index, Some(children)) => {
|
||||
definitions.entry(*index).or_insert(id);
|
||||
pending.extend(children.iter().rev().copied());
|
||||
}
|
||||
Node::Default(children) => pending.extend(children.iter().rev().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut rendered = Render {
|
||||
nodes: &parser.nodes,
|
||||
definitions: &definitions,
|
||||
text: String::new(),
|
||||
stops: BTreeMap::new(),
|
||||
ancestors: Vec::new(),
|
||||
occurrences: 0,
|
||||
work: 0,
|
||||
};
|
||||
rendered.sequence(&roots, 0)?;
|
||||
if !rendered.stops.contains_key(&0) {
|
||||
if rendered.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
rendered.stops.insert(
|
||||
0,
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: std::iter::once(rendered.text.len()..rendered.text.len()).collect(),
|
||||
ancestors: vec![Vec::new()],
|
||||
},
|
||||
);
|
||||
}
|
||||
let final_stop = rendered.stops.get(&0).unwrap();
|
||||
if final_stop.ranges.len() != 1
|
||||
|| !final_stop.ranges[0].is_empty()
|
||||
|| !final_stop.ancestors[0].is_empty()
|
||||
|| rendered.stops.values().any(|stop| {
|
||||
stop.ancestors
|
||||
.iter()
|
||||
.any(|ancestors| ancestors.contains(&0))
|
||||
})
|
||||
{
|
||||
return Err(Error::Unsupported);
|
||||
}
|
||||
let boundaries: Vec<_> = rendered
|
||||
.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(offset, _)| offset)
|
||||
.chain(std::iter::once(rendered.text.len()))
|
||||
.collect();
|
||||
for stop in rendered.stops.values() {
|
||||
let mut previous: Option<&Range<usize>> = None;
|
||||
for range in &stop.ranges {
|
||||
if boundaries.binary_search(&range.start).is_err()
|
||||
|| boundaries.binary_search(&range.end).is_err()
|
||||
|| previous.is_some_and(|old| {
|
||||
old.end > range.start
|
||||
|| (old.start == range.start && !(old.is_empty() && range.is_empty()))
|
||||
})
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
previous = Some(range);
|
||||
}
|
||||
}
|
||||
let final_stop = rendered.stops.remove(&0).unwrap();
|
||||
let mut tabstops: Vec<_> = rendered.stops.into_values().collect();
|
||||
tabstops.push(final_stop);
|
||||
Ok((rendered.text, CompletionSnippet { tabstops }))
|
||||
}
|
||||
|
||||
impl<'a, F: Fn(&str) -> Option<String>> Parser<'a, F> {
|
||||
fn node(&mut self, mut node: Node<'a>) -> Result<usize, Error> {
|
||||
if self.nodes.len() == MAX_NODES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if let Node::Value(value) = &mut node {
|
||||
if value.len() > MAX_BYTES {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
if value.contains('\r') {
|
||||
*value = value.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
self.value_bytes = self
|
||||
.value_bytes
|
||||
.checked_add(value.len())
|
||||
.filter(|size| *size <= MAX_BYTES)
|
||||
.ok_or(Error::Limit)?;
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(node);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn byte(&self) -> Option<u8> {
|
||||
self.text.as_bytes().get(self.cursor).copied()
|
||||
}
|
||||
|
||||
fn sequence(&mut self, nested: bool, depth: usize) -> Result<Vec<usize>, Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
while let Some(byte) = self.byte() {
|
||||
if nested && byte == b'}' {
|
||||
self.cursor += 1;
|
||||
return Ok(nodes);
|
||||
}
|
||||
if byte == b'$' {
|
||||
nodes.push(self.construct(depth)?);
|
||||
} else if byte == b'\\' {
|
||||
self.cursor += 1;
|
||||
let start = self.cursor;
|
||||
if !matches!(self.byte(), Some(b'$' | b'}' | b'\\')) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
} else {
|
||||
let start = self.cursor;
|
||||
while let Some(next) = self.byte() {
|
||||
if next == b'$' || next == b'\\' || (nested && next == b'}') {
|
||||
break;
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
nodes.push(self.node(Node::Text(&self.text[start..self.cursor]))?);
|
||||
}
|
||||
}
|
||||
if nested {
|
||||
Err(Error::Invalid)
|
||||
} else {
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
fn construct(&mut self, depth: usize) -> Result<usize, Error> {
|
||||
self.cursor += 1;
|
||||
let braced = self.byte() == Some(b'{');
|
||||
self.cursor += usize::from(braced);
|
||||
let start = self.cursor;
|
||||
let numbered = self.byte().is_some_and(|byte| byte.is_ascii_digit());
|
||||
if numbered {
|
||||
while self.byte().is_some_and(|byte| byte.is_ascii_digit()) {
|
||||
self.cursor += 1;
|
||||
}
|
||||
} else {
|
||||
if !self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
while self
|
||||
.byte()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
let name = &self.text[start..self.cursor];
|
||||
let mut children = None;
|
||||
if braced {
|
||||
match self.byte() {
|
||||
Some(b'}') => self.cursor += 1,
|
||||
Some(b':') => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.sequence(true, depth + 1)?);
|
||||
}
|
||||
Some(b'|') if numbered => {
|
||||
self.cursor += 1;
|
||||
children = Some(self.choice()?);
|
||||
}
|
||||
Some(b'/') => return Err(Error::Unsupported),
|
||||
_ => return Err(Error::Invalid),
|
||||
}
|
||||
}
|
||||
if numbered {
|
||||
let index = name.parse::<u32>().map_err(|_| Error::Limit)?;
|
||||
self.node(Node::Stop(index, children))
|
||||
} else {
|
||||
match (self.variable)(name) {
|
||||
Some(value) if !value.is_empty() => self.node(Node::Value(value)),
|
||||
value => match children {
|
||||
Some(children) => self.node(Node::Default(children)),
|
||||
None if value.is_some() => self.node(Node::Text("")),
|
||||
None => Err(Error::Unsupported),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choice(&mut self) -> Result<Vec<usize>, Error> {
|
||||
let mut first = String::new();
|
||||
let mut keep = true;
|
||||
loop {
|
||||
match self.byte().ok_or(Error::Invalid)? {
|
||||
b'|' => {
|
||||
self.cursor += 1;
|
||||
if self.byte() != Some(b'}') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.cursor += 1;
|
||||
return Ok(vec![self.node(Node::Value(first))?]);
|
||||
}
|
||||
b',' => {
|
||||
keep = false;
|
||||
self.cursor += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
self.cursor += 1;
|
||||
let byte = self.byte().ok_or(Error::Invalid)?;
|
||||
if !matches!(byte, b'$' | b'}' | b'\\' | b',' | b'|') {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if keep {
|
||||
first.push(byte as char);
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
_ => {
|
||||
let ch = self.text[self.cursor..].chars().next().unwrap();
|
||||
if keep {
|
||||
first.push(ch);
|
||||
}
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Render<'a> {
|
||||
nodes: &'a [Node<'a>],
|
||||
definitions: &'a BTreeMap<u32, usize>,
|
||||
text: String,
|
||||
stops: BTreeMap<u32, SnippetTabstop>,
|
||||
ancestors: Vec<u32>,
|
||||
occurrences: usize,
|
||||
work: usize,
|
||||
}
|
||||
|
||||
impl Render<'_> {
|
||||
fn sequence(&mut self, nodes: &[usize], depth: usize) -> Result<(), Error> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
let arena = self.nodes;
|
||||
for &id in nodes {
|
||||
self.work += 1;
|
||||
if self.work > MAX_WORK {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
match &arena[id] {
|
||||
Node::Text(text) => self.append(text)?,
|
||||
Node::Value(text) => self.append(text)?,
|
||||
Node::Default(children) => self.sequence(children, depth + 1)?,
|
||||
Node::Stop(index, _) => {
|
||||
if self.occurrences == MAX_STOPS {
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.occurrences += 1;
|
||||
let start = self.text.len();
|
||||
let ancestors = self.ancestors.clone();
|
||||
if let Some(&definition) = self.definitions.get(index) {
|
||||
if self.ancestors.contains(index) {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.ancestors.push(*index);
|
||||
if let Node::Stop(_, Some(children)) = &arena[definition] {
|
||||
self.sequence(children, depth + 1)?;
|
||||
}
|
||||
self.ancestors.pop();
|
||||
}
|
||||
let stop = self.stops.entry(*index).or_insert_with(|| SnippetTabstop {
|
||||
index: *index,
|
||||
ranges: Vec::new(),
|
||||
ancestors: Vec::new(),
|
||||
});
|
||||
stop.ranges.push(start..self.text.len());
|
||||
stop.ancestors.push(ancestors);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append(&mut self, text: &str) -> Result<(), Error> {
|
||||
if self
|
||||
.text
|
||||
.len()
|
||||
.checked_add(text.len())
|
||||
.is_none_or(|size| size > MAX_BYTES)
|
||||
{
|
||||
return Err(Error::Limit);
|
||||
}
|
||||
self.text.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
|
||||
let (text, snippet) = expand(
|
||||
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|
||||
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..8, 10..13],
|
||||
ancestors: vec![vec![], vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..8, 12..13],
|
||||
ancestors: vec![vec![1], vec![1]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 3,
|
||||
ranges: vec![15..18],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![23..23],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
|
||||
assert_eq!(text, "call(, )");
|
||||
assert_eq!(
|
||||
snippet.tabstops,
|
||||
vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..5],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![7..7],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![8..8],
|
||||
ancestors: vec![vec![]]
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expand("${KNOWN:${1:hidden}}$1", |name| {
|
||||
(name == "KNOWN").then(|| "shown".to_owned())
|
||||
})
|
||||
.unwrap()
|
||||
.0,
|
||||
"shown"
|
||||
);
|
||||
let (text, snippet) = expand("call(${CTX})$1", |name| {
|
||||
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(text, "call(a\nb\nc)");
|
||||
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
|
||||
assert_eq!(
|
||||
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
|
||||
"a|b}\\"
|
||||
);
|
||||
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
|
||||
if input == "${1:a}${1:b}$1" {
|
||||
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
|
||||
} else {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
|
||||
}
|
||||
}
|
||||
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
|
||||
assert!(text.is_empty());
|
||||
assert_eq!(
|
||||
snippet.tabstops[0],
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![0..0, 0..0],
|
||||
ancestors: vec![vec![], vec![]],
|
||||
}
|
||||
);
|
||||
for input in [
|
||||
"$UNKNOWN",
|
||||
"${TM_FILENAME/(.*)/$1/}",
|
||||
"${1/a/b/}",
|
||||
"${1:a$0b}",
|
||||
"$0$0",
|
||||
"${0:arg}",
|
||||
"${0:$1}",
|
||||
] {
|
||||
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
|
||||
}
|
||||
assert_eq!(
|
||||
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
assert_eq!(
|
||||
expand(
|
||||
&format!(
|
||||
"{}x{}",
|
||||
"${1:".repeat(MAX_DEPTH + 1),
|
||||
"}".repeat(MAX_DEPTH + 1)
|
||||
),
|
||||
|_| None
|
||||
),
|
||||
Err(Error::Limit)
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
-1
@@ -340,6 +340,7 @@ fn translate(key: KeyEvent) -> Option<Input> {
|
||||
KeyCode::Home => Input::Home,
|
||||
KeyCode::End => Input::End,
|
||||
KeyCode::Tab => Input::Char('\t'),
|
||||
KeyCode::BackTab => Input::BackTab,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -683,7 +684,11 @@ fn draw(
|
||||
}
|
||||
}
|
||||
}
|
||||
for selection in selections.iter().filter(|_| !editor.visual_linewise()) {
|
||||
for selection in selections
|
||||
.iter()
|
||||
.filter(|_| !editor.visual_linewise())
|
||||
.chain(editor.snippet_selections())
|
||||
{
|
||||
let first = selection.start.row.max(*top);
|
||||
let last = selection.end.row.min(top.saturating_add(height - 1));
|
||||
let style = Style::default().fg(Color::White).bg(Color::Blue);
|
||||
@@ -1583,6 +1588,8 @@ fn semantic_style(token: &SemanticToken) -> Style {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// These fixtures intentionally contain ranges, not lists of indices.
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
@@ -1643,6 +1650,7 @@ mod tests {
|
||||
detail: Some("\tTAIL".into()),
|
||||
primary_edit: 0,
|
||||
edits: vec![],
|
||||
snippet: None,
|
||||
})
|
||||
.collect(),
|
||||
selected: 20,
|
||||
@@ -1707,6 +1715,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_highlight_tracks_argument_graphemes_without_filling_the_row() {
|
||||
use crate::plugin::{
|
||||
CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition,
|
||||
};
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor
|
||||
.show_completion(vec![CompletionItem {
|
||||
label: "call".into(),
|
||||
detail: None,
|
||||
primary_edit: 0,
|
||||
edits: vec![CompletionEdit {
|
||||
start: TextPosition { row: 0, column: 0 },
|
||||
end: TextPosition { row: 0, column: 0 },
|
||||
text: "call(界e\u{301}, tail)".into(),
|
||||
}],
|
||||
snippet: Some(CompletionSnippet {
|
||||
tabstops: vec![
|
||||
SnippetTabstop {
|
||||
index: 1,
|
||||
ranges: vec![5..11],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 2,
|
||||
ranges: vec![13..17],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
SnippetTabstop {
|
||||
index: 0,
|
||||
ranges: vec![18..18],
|
||||
ancestors: vec![vec![]],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}])
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(28, 6)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
for (key, selected, caret) in [
|
||||
(None, 7..10, Position::new(7, 0)),
|
||||
(Some(Input::Char('\t')), 12..16, Position::new(12, 0)),
|
||||
] {
|
||||
if let Some(key) = key {
|
||||
editor.handle(key);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
// A wide glyph's leading cell owns its terminal style.
|
||||
for x in (0..28).filter(|x| *x != 8) {
|
||||
assert_eq!(
|
||||
buffer[(x, 0)].bg == Color::Blue,
|
||||
selected.contains(&x),
|
||||
"column {x}, selection {:?}, screen:\n{}",
|
||||
editor.snippet_selections(),
|
||||
screen_text(&terminal),
|
||||
);
|
||||
}
|
||||
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
for x in 0..28 {
|
||||
assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user