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