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
+723 -23
View File
@@ -1,6 +1,6 @@
use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{Diagnostic, SemanticToken, TextPosition};
use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, TextPosition};
use serde::Serialize;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -83,6 +83,26 @@ pub struct Hover {
caret: Cursor,
}
/// A live preview; zero selects the immutable original document.
pub struct Completion {
pub items: Vec<CompletionItem>,
pub selected: usize,
}
struct CompletionState {
popup: Completion,
original: Snapshot,
}
#[cfg_attr(test, derive(Debug))]
struct RelativeCompletionEdit {
start: isize,
end: isize,
text: String,
// Extra edits are replayed only against the same surrounding logical lines.
context: Option<Vec<String>>,
}
impl Hover {
pub fn text(&self) -> &str {
&self.text
@@ -193,7 +213,7 @@ struct Snapshot {
}
struct Edit<'a> {
target: usize,
target: Option<usize>,
start: usize,
end: usize,
text: Cow<'a, str>,
@@ -239,6 +259,10 @@ enum InsertStep {
Move(Action, usize),
Delete(Vec<bool>),
Backspace(Vec<bool>),
Completion {
primary_edit: usize,
edits: Vec<RelativeCompletionEdit>,
},
}
#[cfg_attr(test, derive(Debug))]
@@ -302,6 +326,7 @@ enum Action {
LspDefinition,
LspHover,
LspDiagnostics,
LspCompletion,
}
// Names and mode eligibility are shared by configured bindings and the palette.
@@ -503,6 +528,11 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
&[Mode::Normal],
),
("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
(
"core.lsp.completion",
Action::LspCompletion,
&[Mode::Insert],
),
(
"core.lsp.diagnostics",
Action::LspDiagnostics,
@@ -739,6 +769,10 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.or_default()
.insert(key, Binding::Action(action));
}
bindings
.entry(Mode::Insert)
.or_default()
.insert(Input::Ctrl('n'), Binding::Action(LspCompletion));
for mode in [Mode::Insert, Mode::Command, Mode::Search] {
for (key, action) in [
(Input::Escape, Normal),
@@ -938,6 +972,7 @@ pub struct Editor {
g_pending: bool,
palette: Option<Palette>,
hover: Option<Hover>,
completion: Option<CompletionState>,
startup_diagnostic: Option<StartupDiagnostic>,
project_root: PathBuf,
file_picker: Option<FilePicker>,
@@ -1002,6 +1037,7 @@ impl Editor {
g_pending: false,
palette: None,
hover: None,
completion: None,
startup_diagnostic: None,
project_root: std::env::current_dir()?,
file_picker: None,
@@ -1342,6 +1378,7 @@ impl Editor {
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
self.finish_completion();
let source = self.jump_location();
self.cancel_leader();
self.clear_hover();
@@ -1471,6 +1508,7 @@ impl Editor {
}
pub(crate) fn show_startup_diagnostic(&mut self, text: String) {
self.finish_completion();
let lines = split_lines(&text);
let max_column = lines
.iter()
@@ -1485,6 +1523,129 @@ impl Editor {
});
}
/// Borrowed live-completion state; original document bookkeeping stays private.
pub fn completion(&self) -> Option<&Completion> {
self.completion.as_ref().map(|state| &state.popup)
}
/// Validates the entire response before changing text, history, or popup state.
pub fn validate_completion(&self, items: &[CompletionItem]) -> Result<(), String> {
validate_completion_items(&self.active.lines, items)
}
pub fn show_completion(&mut self, mut items: Vec<CompletionItem>) -> Result<(), String> {
if self.mode() != Mode::Insert
|| self.palette.is_some()
|| self.startup_diagnostic.is_some()
|| self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
{
return Err("Completion requires Insert editor focus".into());
}
self.validate_completion(&items)?;
self.finish_completion();
if items.is_empty() {
self.set_message("No completions");
return Ok(());
}
for item in &mut items {
for edit in &mut item.edits {
if let Cow::Owned(normalized) = normalize_newlines(&edit.text) {
edit.text = normalized;
}
}
}
let original = self.snapshot();
self.preview_completion(&original, &items[0]);
self.completion = Some(CompletionState {
popup: Completion { items, selected: 1 },
original,
});
self.message.clear();
Ok(())
}
fn preview_completion(&mut self, original: &Snapshot, item: &CompletionItem) {
let offsets = line_offsets(&original.lines);
let edits = completion_edits(&original.lines, &offsets, original.primary, item);
self.apply_edits_from(edits, Some(original));
}
fn cycle_completion(&mut self, next: bool) {
let mut state = self.completion.take().expect("completion is active");
let count = state.popup.items.len() + 1;
state.popup.selected = if next {
(state.popup.selected + 1) % count
} else {
(state.popup.selected + count - 1) % count
};
if state.popup.selected == 0 {
if self.active.lines != state.original.lines {
self.active.text_changed();
self.active.search_match = None;
}
self.active.lines.clone_from(&state.original.lines);
self.active.targets.clone_from(&state.original.targets);
self.active.primary = state.original.primary;
} else {
self.preview_completion(
&state.original,
&state.popup.items[state.popup.selected - 1],
);
}
self.completion = Some(state);
}
/// Retains the displayed candidate and records only its resolved native edits.
fn finish_completion(&mut self) {
let Some(state) = self.completion.take() else {
return;
};
if state.popup.selected == 0 || state.original.lines == self.active.lines {
return;
}
let item = state
.popup
.items
.into_iter()
.nth(state.popup.selected - 1)
.expect("selected completion exists");
let caret = state.original.targets[state.original.primary].active;
let origin = grapheme_offset(&state.original.lines, caret) as isize;
let edits = item
.edits
.into_iter()
.enumerate()
.map(|(index, edit)| RelativeCompletionEdit {
start: grapheme_offset(
&state.original.lines,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
) as isize
- origin,
end: grapheme_offset(
&state.original.lines,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
) as isize
- origin,
text: edit.text,
context: (index != item.primary_edit)
.then(|| state.original.lines[edit.start.row..=edit.end.row].to_vec()),
})
.collect();
self.push_insert_step(InsertStep::Completion {
primary_edit: item.primary_edit,
edits,
});
}
/// Borrowed popup state, visible only while its source remains current.
pub fn hover(&self) -> Option<&Hover> {
self.hover.as_ref().filter(|hover| {
@@ -2298,6 +2459,25 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if self.completion.is_some() {
let request = matches!(
self.bindings
.get(&self.active.mode)
.and_then(|keys| keys.get(&input)),
Some(Binding::Action(Action::LspCompletion))
);
if request || input == Input::Ctrl('j') {
self.cycle_completion(true);
return None;
}
match input {
Input::Ctrl('k') => {
self.cycle_completion(false);
return None;
}
_ => self.finish_completion(),
}
}
if std::mem::take(&mut self.g_pending) {
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
&& self.startup_diagnostic.is_none()
@@ -2480,6 +2660,7 @@ impl Editor {
}
fn act(&mut self, action: Action) -> Option<Invocation> {
self.finish_completion();
if !matches!(action, Action::JumpBack | Action::JumpForward) {
self.clear_hover();
}
@@ -2507,6 +2688,15 @@ impl Editor {
Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
Action::LspCompletion => {
if self.active.mode == Mode::Insert {
return Some(Invocation {
plugin: "core".into(),
command: "lsp.completion".into(),
args: String::new(),
});
}
}
Action::Insert
| Action::InsertFirstNonblank
| Action::AppendLineEnd
@@ -2803,6 +2993,7 @@ impl Editor {
}
fn park_source(&mut self) {
self.finish_completion();
self.clear_hover();
if self.active.mode == Mode::Insert {
self.finish_insert();
@@ -2990,6 +3181,7 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.finish_completion();
self.clear_hover();
self.count = None;
self.clear_leader();
@@ -3109,6 +3301,7 @@ impl Editor {
if inserting {
self.finish_insert();
}
self.finish_completion();
let before = effects
.iter()
.any(|effect| {
@@ -3815,7 +4008,7 @@ impl Editor {
start -= 1;
}
Edit {
target,
target: Some(target),
start,
end,
text: Cow::Borrowed(if changing && last < last_row {
@@ -3851,7 +4044,7 @@ impl Editor {
.iter()
.enumerate()
.map(|(target, range)| Edit {
target,
target: Some(target),
start: cursor_offset(&self.active.lines, &offsets, range.start),
end: cursor_offset(&self.active.lines, &offsets, range.end),
text: Cow::Borrowed(""),
@@ -3981,7 +4174,7 @@ impl Editor {
};
let first = fragment.split('\n').next().unwrap();
Edit {
target,
target: Some(target),
start,
end: start,
caret: prefix + grapheme_byte(first, first_nonblank(first)),
@@ -4011,7 +4204,7 @@ impl Editor {
let start = cursor_offset(&self.active.lines, &offsets, cursor);
let text = &fragments[if fragments.len() == 1 { 0 } else { target }];
Edit {
target,
target: Some(target),
start,
end: start,
text: Cow::Borrowed(text),
@@ -4041,6 +4234,7 @@ impl Editor {
}
fn finish_insert(&mut self) {
self.finish_completion();
let recipe = self.active.pending_edit.take();
if let Some(before) = self.active.insert_start.take() {
if let Some(mut recipe) = recipe {
@@ -4297,6 +4491,40 @@ impl Editor {
self.backspace(mask);
}
}
InsertStep::Completion {
primary_edit,
edits,
} => {
let origin = grapheme_offset(&self.active.lines, self.cursor());
let resolve = |distance: isize| {
origin
.checked_add_signed(distance)
.and_then(|index| grapheme_cursor(&self.active.lines, index))
.ok_or("Repeat completion range is outside the document")
};
let offsets = line_offsets(&self.active.lines);
let mut batch = Vec::with_capacity(edits.len());
for (index, edit) in edits.iter().enumerate() {
let start = resolve(edit.start)?;
let end = resolve(edit.end)?;
if edit.context.as_ref().is_some_and(|context| {
*context != self.active.lines[start.row..=end.row]
}) {
return Err("Repeat completion additional-edit context changed");
}
// Translating every boundary by the same origin preserves
// the validated ordering and nonoverlap of the original batch.
batch.push(Edit {
target: (index == *primary_edit).then_some(self.active.primary),
start: cursor_offset(&self.active.lines, &offsets, start),
end: cursor_offset(&self.active.lines, &offsets, end),
text: Cow::Borrowed(&edit.text),
caret: edit.text.len(),
round_up: true,
});
}
self.apply_edits(batch);
}
}
}
if self.active.mode == Mode::Insert {
@@ -4548,7 +4776,11 @@ impl Editor {
/// Every edit in a batch refers to this one pre-edit buffer. Duplicate row
/// operations share an edit; caret destinations are remapped only afterwards.
// ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers.
fn apply_edits(&mut self, mut edits: Vec<Edit<'_>>) {
fn apply_edits(&mut self, edits: Vec<Edit<'_>>) {
self.apply_edits_from(edits, None);
}
fn apply_edits_from(&mut self, mut edits: Vec<Edit<'_>>, original: Option<&Snapshot>) {
if edits.is_empty() {
return;
}
@@ -4565,8 +4797,10 @@ impl Editor {
}
unique.push(index);
}
let old = self.active.lines.join("\n");
let offsets = line_offsets(&self.active.lines);
let lines = original.map_or(&self.active.lines, |before| &before.lines);
let targets = original.map_or(&self.active.targets, |before| &before.targets);
let old = lines.join("\n");
let offsets = line_offsets(lines);
let removed: usize = unique
.iter()
.map(|index| edits[*index].end - edits[*index].start)
@@ -4598,27 +4832,31 @@ impl Editor {
}
position - removed + added
};
let mut destinations: Vec<_> = self
.active
.targets
let mut destinations: Vec<_> = targets
.iter()
.map(|target| {
(
remap(
cursor_offset(&self.active.lines, &offsets, target.active),
true,
),
remap(cursor_offset(lines, &offsets, target.active), true),
true,
)
})
.collect();
for edit in &edits {
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
if let Some(target) = edit.target {
destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up);
}
}
if text != old {
if !text
.split('\n')
.eq(self.active.lines.iter().map(String::as_str))
{
self.active.text_changed();
self.active.search_match = None;
}
if let Some(original) = original {
self.active.targets.clone_from(&original.targets);
self.active.primary = original.primary;
}
self.active.lines = text.split('\n').map(str::to_owned).collect();
let offsets = line_offsets(&self.active.lines);
for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) {
@@ -4642,7 +4880,7 @@ impl Editor {
.map(|(target, item)| {
let start = cursor_offset(&self.active.lines, &offsets, item.active);
Edit {
target,
target: Some(target),
start,
end: start,
text: Cow::Borrowed(text),
@@ -4669,7 +4907,7 @@ impl Editor {
0
};
Edit {
target,
target: Some(target),
start,
end: start,
text: Cow::Borrowed("\n"),
@@ -4704,7 +4942,7 @@ impl Editor {
return None;
};
Some(Edit {
target,
target: Some(target),
start,
end,
text: Cow::Borrowed(""),
@@ -4743,7 +4981,7 @@ impl Editor {
};
let start = offsets[row] + line.len();
Some(Edit {
target,
target: Some(target),
start,
end: start + 1 + next.len() - trimmed.len(),
text: Cow::Borrowed(separator),
@@ -4774,7 +5012,7 @@ impl Editor {
cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor));
let end = cursor_offset(&self.active.lines, &offsets, cursor);
Some(Edit {
target,
target: Some(target),
start,
end,
text: Cow::Borrowed(""),
@@ -4913,6 +5151,120 @@ fn valid_command(command: &str) -> bool {
})
}
fn grapheme_offset(lines: &[String], cursor: Cursor) -> usize {
lines[..cursor.row]
.iter()
.map(|line| grapheme_count(line) + 1)
.sum::<usize>()
+ cursor.column
}
fn grapheme_cursor(lines: &[String], mut index: usize) -> Option<Cursor> {
for (row, line) in lines.iter().enumerate() {
let columns = grapheme_count(line);
if index <= columns {
return Some(Cursor { row, column: index });
}
index -= columns + 1;
}
None
}
fn validate_completion_items(lines: &[String], items: &[CompletionItem]) -> Result<(), String> {
let mut total = 0usize;
let offsets = line_offsets(lines);
for item in items {
if item.primary_edit >= item.edits.len() {
return Err("Completion has no primary edit".into());
}
for text in std::iter::once(item.label.as_str())
.chain(item.detail.as_deref())
.chain(item.edits.iter().map(|edit| edit.text.as_str()))
{
total = total
.checked_add(text.len())
.ok_or("Completions exceed 4 MiB")?;
if total > MAX_EDIT_BYTES {
return Err("Completions exceed 4 MiB".into());
}
validate_text(text)?;
}
let mut ranges = Vec::with_capacity(item.edits.len());
for edit in &item.edits {
for position in [edit.start, edit.end] {
if lines
.get(position.row)
.is_none_or(|line| position.column > grapheme_count(line))
{
return Err("Completion range is outside the document".into());
}
}
let start = cursor_offset(
lines,
&offsets,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
);
let end = cursor_offset(
lines,
&offsets,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
);
if start > end {
return Err("Completion range is reversed".into());
}
ranges.push((start, end));
}
ranges.sort_unstable();
if ranges
.windows(2)
.any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0)
{
return Err("Completion edits overlap".into());
}
}
Ok(())
}
fn completion_edits<'a>(
lines: &[String],
offsets: &[usize],
primary: usize,
item: &'a CompletionItem,
) -> Vec<Edit<'a>> {
item.edits
.iter()
.enumerate()
.map(|(index, edit)| Edit {
target: (index == item.primary_edit).then_some(primary),
start: cursor_offset(
lines,
offsets,
Cursor {
row: edit.start.row,
column: edit.start.column,
},
),
end: cursor_offset(
lines,
offsets,
Cursor {
row: edit.end.row,
column: edit.end.column,
},
),
text: Cow::Borrowed(&edit.text),
caret: edit.text.len(),
round_up: true,
})
.collect()
}
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
if text.len() > MAX_EDIT_BYTES {
Err("Hover exceeds 4 MiB".into())
@@ -5084,6 +5436,7 @@ fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::CompletionEdit;
struct Directory(PathBuf);
impl Directory {
@@ -12331,4 +12684,351 @@ mod tests {
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(fs::read_to_string(source).unwrap(), "source");
}
fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem {
CompletionItem {
label: text.into(),
detail: None,
primary_edit: 0,
edits: vec![CompletionEdit {
start: TextPosition {
row: start.0,
column: start.1,
},
end: TextPosition {
row: end.0,
column: end.1,
},
text: text.into(),
}],
}
}
#[test]
fn completion_cycles_from_original_spans_and_restores_pending_typing() {
let mut editor = editor();
type_text(&mut editor, "iabcd");
let revision = editor.text_revision();
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
editor
.show_completion(vec![
completion_item((0, 2), (0, 4), "X"),
completion_item((0, 0), (0, 1), "Q\nZ"),
])
.unwrap();
assert_eq!(editor.context().text, "abX");
assert_eq!(editor.completion().unwrap().selected, 1);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "Q\nZbcd");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.context().text, "abcd");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
assert_eq!(editor.completion().unwrap().selected, 0);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.text_revision() > revision);
editor.handle(Input::Ctrl('k'));
assert_eq!(editor.context().text, "Q\nZbcd");
editor.handle(Input::Char('!'));
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, "Q\nZ!bcd");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
#[test]
fn completion_same_span_noop_preview_and_original_preserve_redo() {
let mut editor = editor();
type_text(&mut editor, "iold");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.active.redo.len(), 1);
editor.handle(Input::Char('i'));
editor
.show_completion(vec![
completion_item((0, 0), (0, 0), "one"),
completion_item((0, 0), (0, 0), "one"),
])
.unwrap();
let revision = editor.text_revision();
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "one");
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Escape);
assert_eq!(editor.active.redo.len(), 1);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "old");
}
#[test]
fn completion_noncycle_keys_keep_standard_insert_semantics() {
for (key, expected, mode) in [
(Input::Char('!'), "completed!", Mode::Insert),
(Input::Enter, "completed\n", Mode::Insert),
(Input::Char('\t'), "completed\t", Mode::Insert),
(Input::Escape, "completed", Mode::Normal),
(Input::Left, "completed", Mode::Insert),
(Input::Ctrl('z'), "completed", Mode::Insert),
] {
let mut editor = editor();
type_text(&mut editor, "iprefix");
editor
.show_completion(vec![completion_item((0, 0), (0, 6), "completed")])
.unwrap();
editor.handle(key);
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, expected);
assert_eq!(editor.mode(), mode);
if mode == Mode::Insert {
editor.handle(Input::Escape);
}
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
}
#[test]
fn completion_request_and_cycle_use_effective_insert_binding() {
let mut editor = editor();
type_text(&mut editor, "iab");
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
assert_eq!(
editor.handle(Input::Ctrl('n')),
Some(Invocation {
plugin: "core".into(),
command: "lsp.completion".into(),
args: String::new(),
})
);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.active.undo.is_empty());
editor
.show_completion(vec![
completion_item((0, 0), (0, 2), "first"),
completion_item((0, 0), (0, 2), "second"),
])
.unwrap();
editor.bind("insert", "Ctrl+n", None).unwrap();
editor
.bind("insert", "Ctrl+x", Some("core.lsp.completion".into()))
.unwrap();
editor.handle(Input::Ctrl('x'));
assert_eq!(editor.context().text, "second");
editor.handle(Input::Ctrl('n'));
assert!(editor.completion().is_none());
assert_eq!(editor.context().text, "second");
}
#[test]
fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("left ab".into())])
.unwrap();
editor.handle(Input::Char('A'));
editor
.show_completion(vec![
completion_item((0, 5), (0, 7), "wrong"),
completion_item((0, 5), (0, 7), "final"),
])
.unwrap();
editor.handle(Input::Ctrl('n'));
editor.handle(Input::Escape);
editor
.apply(vec![Effect::Replace("different zz".into())])
.unwrap();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "different final");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "different zz");
editor.apply(vec![Effect::Replace("x".into())]).unwrap();
let revision = editor.text_revision();
let cursor = editor.cursor();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "x");
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.text_revision(), revision);
assert!(editor.message().contains("outside"));
editor
.apply(vec![Effect::Replace("new yy".into())])
.unwrap();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "new final");
}
#[test]
fn completion_additional_edits_translate_unicode_secondary_carets() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("e\u{301}x\n👩‍🚀z".into())])
.unwrap();
editor.handle(Input::Ctrl('e'));
let undo_carets = editor.carets();
editor.handle(Input::Char('A'));
let original = editor.carets();
let mut item = completion_item((1, 0), (1, 2), "界\n好");
item.edits.push(CompletionEdit {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
text: "prefix".into(),
});
editor.show_completion(vec![item]).unwrap();
assert_eq!(editor.context().text, "prefixx\n界\n好");
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
assert_eq!(
editor.carets(),
[Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },]
);
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z");
assert_eq!(editor.carets(), original);
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z");
assert_eq!(editor.carets(), undo_carets);
}
#[test]
fn completion_validation_rejects_entire_set_before_any_mutation() {
let mut editor = editor();
type_text(&mut editor, "iabc");
let good = completion_item((0, 0), (0, 3), "valid");
let mut bad_primary = good.clone();
bad_primary.primary_edit = 1;
let mut overlap = good.clone();
overlap.edits.push(overlap.edits[0].clone());
for invalid in [
bad_primary,
overlap,
completion_item((1, 0), (1, 0), "bad"),
completion_item((0, 4), (0, 4), "bad"),
completion_item((0, 2), (0, 1), "bad"),
completion_item((0, 0), (0, 3), "\0"),
completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)),
] {
let revision = editor.text_revision();
let steps = editor.active.pending_edit.as_ref().unwrap().steps.len();
assert!(editor.show_completion(vec![good.clone(), invalid]).is_err());
assert_eq!(editor.context().text, "abc");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 });
assert_eq!(editor.text_revision(), revision);
assert_eq!(
editor.active.pending_edit.as_ref().unwrap().steps.len(),
steps
);
assert!(editor.completion().is_none());
assert!(editor.active.undo.is_empty());
}
let revision = editor.text_revision();
editor.show_completion(Vec::new()).unwrap();
assert_eq!(editor.context().text, "abc");
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.mode(), Mode::Insert);
assert!(editor.completion().is_none());
assert_eq!(editor.message(), "No completions");
}
#[test]
fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() {
for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] {
let mut editor = editor();
type_text(&mut editor, "iab");
editor
.show_completion(vec![completion_item((0, 0), (0, 2), "chosen")])
.unwrap();
match boundary {
"palette" => {
editor.handle(Input::Ctrl('p'));
}
"startup" => editor.show_startup_diagnostic("notice".into()),
"command" => {
editor.execute("buffers");
}
"paste" => {
editor.handle(Input::Paste("!".into()));
}
_ => {
editor.handle(Input::Paste("\0".into()));
}
}
assert!(editor.completion().is_none());
assert_eq!(
editor.context().text,
if boundary == "paste" {
"chosen!"
} else {
"chosen"
}
);
if boundary == "palette" || boundary == "startup" {
editor.handle(Input::Escape);
}
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
if boundary == "paste" {
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "chosen");
}
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
let directory = Directory::new();
let other = directory.path("other");
fs::write(&other, "other").unwrap();
let mut editor = editor();
type_text(&mut editor, "iab");
editor
.show_completion(vec![completion_item((0, 0), (0, 2), "chosen")])
.unwrap();
editor.execute(&format!("edit {}", other.display()));
assert!(editor.completion().is_none());
editor.execute("buffer 1");
assert_eq!(editor.context().text, "chosen");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
#[test]
fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("use\nab".into())])
.unwrap();
type_text(&mut editor, "jA");
let mut item = completion_item((1, 0), (1, 2), "done");
item.edits.push(CompletionEdit {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 3 },
text: "import".into(),
});
editor.show_completion(vec![item]).unwrap();
editor.handle(Input::Escape);
editor
.apply(vec![Effect::Replace("use\nzz".into())])
.unwrap();
type_text(&mut editor, "j.");
assert_eq!(editor.context().text, "import\ndone");
editor
.apply(vec![Effect::Replace("bad\nzz".into())])
.unwrap();
type_text(&mut editor, "j");
let before = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, before);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert!(editor.message().contains("context changed"));
}
}