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
+1147 -24
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
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")
);
+3 -2
View File
@@ -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]
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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();