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"));
}
}
+423 -4
View File
@@ -18,9 +18,9 @@ use unicode_segmentation::UnicodeSegmentation;
use crate::{
editor::Context,
plugin::{
Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot,
PluginUpdate, SemanticToken, SemanticTokenKind, SemanticTokenModifier, TextPosition,
UpdateEffect,
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
SemanticTokenModifier, TextPosition, UpdateEffect,
},
};
@@ -94,6 +94,7 @@ enum Input {
enum PositionKind {
Definition,
Hover,
Completion,
}
impl PositionKind {
@@ -101,6 +102,7 @@ impl PositionKind {
match self {
Self::Definition => "definition",
Self::Hover => "hover",
Self::Completion => "completion",
}
}
}
@@ -265,6 +267,10 @@ impl Lsp {
self.position(context, request_id, PositionKind::Hover)
}
pub fn completion(&mut self, context: &Context, request_id: u64) -> Result<(), String> {
self.position(context, request_id, PositionKind::Completion)
}
fn position(
&mut self,
context: &Context,
@@ -600,6 +606,7 @@ enum Request {
Initialize,
Definition(Source),
Hover(Source),
Completion(Source, Arc<DocumentSnapshot>),
SemanticTokens(Arc<DocumentSnapshot>),
Shutdown,
}
@@ -620,6 +627,7 @@ struct Runtime {
ready: bool,
definition: bool,
hover: bool,
completion: bool,
semantic: Option<SemanticOptions>,
sync: SyncOptions,
}
@@ -649,6 +657,7 @@ impl Runtime {
ready: false,
definition: false,
hover: false,
completion: false,
semantic: None,
sync: SyncOptions::default(),
};
@@ -670,6 +679,19 @@ impl Runtime {
"definition": {"dynamicRegistration": false, "linkSupport": true},
"hover": {"dynamicRegistration": false,
"contentFormat": ["plaintext", "markdown"]},
"completion": {
"dynamicRegistration": false, "contextSupport": true,
"insertTextMode": 1,
"completionItem": {
"snippetSupport": false, "commitCharactersSupport": false,
"insertReplaceSupport": true,
"insertTextModeSupport": {"valueSet": [1]},
"resolveSupport": {"properties": []}
},
"completionList": {
"itemDefaults": ["editRange", "insertTextFormat", "insertTextMode"]
}
},
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true},
"semanticTokens": {
"dynamicRegistration": false,
@@ -942,17 +964,26 @@ impl Runtime {
"textDocument/hover",
"server does not support hover",
),
PositionKind::Completion => (
self.completion,
"textDocument/completion",
"server does not support completion",
),
};
if !supported {
return self.emit(message(unsupported, Some(source.request_id)));
}
let position = outbound_position(&doc.snapshot.text, source.row, source.column)?;
let params = json!({
let mut params = json!({
"textDocument": {"uri": doc.uri}, "position": position
});
if matches!(kind, PositionKind::Completion) {
params["context"] = json!({"triggerKind": 1});
}
let request = match kind {
PositionKind::Definition => Request::Definition(source),
PositionKind::Hover => Request::Hover(source),
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
};
self.request(method, Some(params), request)
}
@@ -986,6 +1017,35 @@ impl Runtime {
Ok(())
}
fn completion_result(
&self,
source: Source,
snapshot: Arc<DocumentSnapshot>,
result: Result<&Value, String>,
) -> Result<(), String> {
if !self
.documents
.get(&source.buffer_id)
.is_some_and(|doc| doc.primary && Arc::ptr_eq(&doc.snapshot, &snapshot))
{
return Ok(());
}
match result.and_then(|value| decode_completion(&snapshot.text, value, &source)) {
Ok(items) => self.emit(PluginUpdate {
request_id: Some(source.request_id),
effects: vec![UpdateEffect::Completion {
buffer_id: source.buffer_id,
revision: source.revision,
items,
}],
}),
Err(reason) => self.emit(message(
&format!("completion failed: {reason}"),
Some(source.request_id),
)),
}
}
fn semantic_result(
&mut self,
snapshot: Arc<DocumentSnapshot>,
@@ -1074,6 +1134,11 @@ impl Runtime {
Request::Hover(source) => {
self.hover_result(source, Err("unexpected LSP response".into()))
}
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("unexpected LSP response".into()),
),
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("unexpected LSP response".into()))
}
@@ -1089,6 +1154,11 @@ impl Runtime {
Request::Hover(source) => {
self.hover_result(source, Err("invalid LSP error response".into()))
}
Request::Completion(source, snapshot) => self.completion_result(
source,
snapshot,
Err("invalid LSP error response".into()),
),
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err("invalid LSP error response".into()))
}
@@ -1102,6 +1172,9 @@ impl Runtime {
Some(source.request_id),
)),
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Err(text.to_owned()))
}
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Err(text.to_owned()))
}
@@ -1112,6 +1185,9 @@ impl Runtime {
Request::Initialize => self.initialized(&frame["result"]),
Request::Definition(source) => self.definition_result(source, &frame["result"]),
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
Request::Completion(source, snapshot) => {
self.completion_result(source, snapshot, Ok(&frame["result"]))
}
Request::SemanticTokens(snapshot) => {
self.semantic_result(snapshot, Ok(&frame["result"]))
}
@@ -1138,6 +1214,9 @@ impl Runtime {
self.hover = capabilities
.get("hoverProvider")
.is_some_and(|value| value == true || value.is_object());
self.completion = capabilities
.get("completionProvider")
.is_some_and(Value::is_object);
match semantic_options(capabilities.get("semanticTokensProvider")) {
Ok(options) => self.semantic = options,
Err(reason) => {
@@ -2364,6 +2443,319 @@ fn normalized(text: &str) -> std::borrow::Cow<'_, str> {
}
}
fn completion_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
value
.get(field)
.map(|value| {
value
.as_str()
.ok_or_else(|| format!("invalid completion {field}"))
})
.transpose()
}
fn completion_text(text: &str, multiline: bool) -> Result<(), String> {
if text
.chars()
.any(|ch| ch.is_control() && !(multiline && matches!(ch, '\n' | '\r' | '\t')))
{
return Err("unsupported control character in completion".into());
}
Ok(())
}
fn completion_position(lines: &[&str], value: &Value) -> Result<TextPosition, String> {
let (row, units) = coordinates(value)?;
let line = lines
.get(row)
.ok_or("completion line is outside the document")?;
let mut offset = 0;
let mut column = 0;
for cluster in line.graphemes(true) {
if units == offset {
return Ok(TextPosition { row, column });
}
offset += cluster.encode_utf16().count();
if units < offset {
return Err("completion position splits a grapheme".into());
}
column += 1;
}
if units != offset {
return Err("completion position is outside the line".into());
}
Ok(TextPosition { row, column })
}
fn completion_range(lines: &[&str], value: &Value) -> Result<(TextPosition, TextPosition), String> {
let start = completion_position(lines, &value["start"])?;
let end = completion_position(lines, &value["end"])?;
if (start.row, start.column) > (end.row, end.column) {
return Err("reversed completion range".into());
}
Ok((start, end))
}
fn completion_primary_range(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
let (start, end) = completion_range(lines, value)?;
if start.row != source.row
|| end.row != source.row
|| start.column > source.column
|| end.column < source.column
{
return Err(
"completion primary range must be single-line and contain the request position".into(),
);
}
Ok((start, end))
}
fn completion_replacement(
lines: &[&str],
value: &Value,
source: &Source,
) -> Result<(TextPosition, TextPosition), String> {
if value.get("insert").is_some() || value.get("replace").is_some() {
if value.get("range").is_some() || value.get("start").is_some() {
return Err("ambiguous completion edit range".into());
}
let (start, end) = completion_primary_range(lines, &value["replace"], source)?;
let (insert_start, insert_end) = completion_primary_range(lines, &value["insert"], source)?;
if insert_start != start || insert_end.column > end.column {
return Err("completion insert range is not a prefix of replace range".into());
}
Ok((start, end))
} else {
completion_primary_range(lines, value.get("range").unwrap_or(value), source)
}
}
fn decode_completion(
text: &str,
result: &Value,
source: &Source,
) -> Result<Vec<CompletionItem>, String> {
if result.is_null() {
return Ok(Vec::new());
}
let (items, defaults) = if let Some(items) = result.as_array() {
(items, None)
} else {
if !result["isIncomplete"].is_boolean() {
return Err("invalid completion list isIncomplete".into());
}
let defaults = result.get("itemDefaults");
if defaults.is_some_and(|value| !value.is_object()) {
return Err("invalid completion itemDefaults".into());
}
if let Some(defaults) = defaults {
for key in defaults.as_object().unwrap().keys() {
if !matches!(
key.as_str(),
"editRange" | "insertTextFormat" | "insertTextMode"
) {
return Err("unsupported completion item default".into());
}
}
}
(
result["items"]
.as_array()
.ok_or("invalid completion items")?,
defaults,
)
};
let text = normalized(text);
let lines: Vec<_> = text.split('\n').collect();
let line = lines
.get(source.row)
.ok_or("completion request line is outside the document")?;
let mut prefix_start = 0;
let mut prefix_byte = 0;
let mut caret_byte = line.len();
let mut columns = 0;
for (column, (byte, cluster)) in line.grapheme_indices(true).enumerate() {
if column == source.column {
caret_byte = byte;
break;
}
if !cluster
.chars()
.next()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_')
{
prefix_start = column + 1;
prefix_byte = byte + cluster.len();
}
columns = column + 1;
}
if source.column > columns {
return Err("completion request column is outside the line".into());
}
let prefix = &line[prefix_byte..caret_byte];
// The prefix is the only guessed range; explicit server edits remain authoritative.
if let Some(defaults) = defaults {
if let Some(range) = defaults.get("editRange") {
completion_replacement(&lines, range, source)?;
}
for name in ["insertTextFormat", "insertTextMode"] {
if defaults
.get(name)
.is_some_and(|value| !matches!(value.as_u64(), Some(1 | 2)))
{
return Err(format!("invalid completion default {name}"));
}
}
}
let mut decoded = Vec::with_capacity(items.len());
let mut bytes = 0usize;
for item in items {
if !item.is_object() {
return Err("invalid completion item".into());
}
let label = completion_string(item, "label")?.ok_or("missing completion label")?;
completion_text(label, false)?;
let detail = completion_string(item, "detail")?;
let sort = completion_string(item, "sortText")?.unwrap_or(label);
let filter = completion_string(item, "filterText")?;
let insert = completion_string(item, "insertText")?;
let edit_text = completion_string(item, "textEditText")?;
if let Some(detail) = detail {
completion_text(detail, true)?;
}
completion_text(sort, false)?;
if let Some(filter) = filter {
completion_text(filter, false)?;
}
let property = |name: &str| {
item.get(name)
.or_else(|| defaults.and_then(|value| value.get(name)))
};
let format = property("insertTextFormat")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextFormat")
})
.transpose()?
.unwrap_or(1);
let mode = property("insertTextMode")
.map(|value| {
value
.as_u64()
.filter(|value| matches!(value, 1 | 2))
.ok_or("invalid completion insertTextMode")
})
.transpose()?
.unwrap_or(1);
// No snippets, indentation adjustment, server commands or lazy edit resolution.
if format == 2 || mode == 2 || item.get("command").is_some() {
continue;
}
let explicit = item.get("textEdit");
let default_range = defaults.and_then(|value| value.get("editRange"));
let (start, end, new_text) = if let Some(edit) = explicit {
if !edit.is_object() {
return Err("invalid completion textEdit".into());
}
let (start, end) = completion_replacement(&lines, edit, source)?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing completion newText")?;
(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))
} else {
(
TextPosition {
row: source.row,
column: prefix_start,
},
TextPosition {
row: source.row,
column: source.column,
},
insert.unwrap_or(label),
)
};
let filter_prefix = if explicit.is_some() || default_range.is_some() {
let byte = line
.grapheme_indices(true)
.nth(start.column)
.map_or(line.len(), |(byte, _)| byte);
&line[byte..caret_byte]
} else {
prefix
};
completion_text(new_text, true)?;
let mut edits = vec![CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
}];
if let Some(additional) = item.get("additionalTextEdits") {
for edit in additional
.as_array()
.ok_or("invalid completion additionalTextEdits")?
{
let (start, end) = completion_range(&lines, &edit["range"])?;
let new_text =
completion_string(edit, "newText")?.ok_or("missing additional edit newText")?;
completion_text(new_text, true)?;
edits.push(CompletionEdit {
start,
end,
text: normalized(new_text).into_owned(),
});
}
}
edits.sort_unstable_by_key(|edit| {
(
(edit.start.row, edit.start.column),
(edit.end.row, edit.end.column),
)
});
if edits.windows(2).any(|pair| {
(pair[0].end.row, pair[0].end.column) > (pair[1].start.row, pair[1].start.column)
|| pair[0].start == pair[1].start
}) {
return Err("overlapping completion edits".into());
}
let size = label.len()
+ detail.map_or(0, str::len)
+ edits.iter().map(|edit| edit.text.len()).sum::<usize>();
bytes = bytes
.checked_add(size)
.filter(|bytes| *bytes <= MAX_BODY)
.ok_or("completion text exceeds 4 MiB")?;
if (filter.is_some() || (explicit.is_none() && default_range.is_none()))
&& !filter.unwrap_or(label).starts_with(filter_prefix)
{
continue;
}
let primary_edit = edits
.iter()
.position(|edit| edit.start == start && edit.end == end)
.unwrap();
decoded.push((
sort,
CompletionItem {
label: label.to_owned(),
detail: detail.map(str::to_owned),
primary_edit,
edits,
},
));
}
decoded.sort_by(|a, b| a.0.cmp(b.0));
Ok(decoded.into_iter().map(|(_, item)| item).collect())
}
fn coordinates(value: &Value) -> Result<(usize, usize), String> {
let line = value["line"]
.as_u64()
@@ -2673,4 +3065,31 @@ mod tests {
assert!(hover_text(&value, source).is_err());
}
}
#[test]
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
let source = Source {
buffer_id: 1,
revision: 0,
row: 0,
column: 0,
request_id: 1,
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
);
let over_limit = json!([
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source)
.unwrap_err()
.contains("4 MiB")
);
}
}
+3 -1
View File
@@ -30,7 +30,9 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
I/A insert at each selected line's first nonblank/end
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
v/V switches selection shape; same selector or Esc cancels retaining carets
Insert: Esc returns to Normal 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
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]
+24
View File
@@ -105,6 +105,24 @@ pub struct SemanticToken {
pub modifiers: u16,
}
/// A completion replacement in zero-based, end-exclusive grapheme coordinates.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletionEdit {
pub start: TextPosition,
pub end: TextPosition,
pub text: String,
}
/// Protocol-independent completion text and its atomic document edits.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletionItem {
pub label: String,
pub detail: Option<String>,
/// Index of the primary replacement; other edits never determine caret placement.
pub primary_edit: usize,
pub edits: Vec<CompletionEdit>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum UpdateEffect {
@@ -132,6 +150,12 @@ pub enum UpdateEffect {
revision: u64,
text: String,
},
#[serde(skip_deserializing)]
Completion {
buffer_id: usize,
revision: u64,
items: Vec<CompletionItem>,
},
Message {
text: String,
},
+75 -11
View File
@@ -41,7 +41,9 @@ pub struct EditorSession {
lsp: Lsp,
lsp_request_id: u64,
lsp_source_cursor: Option<Cursor>,
lsp_source_mode: Mode,
pending_hover: bool,
pending_completion: bool,
hover_owner: Option<String>,
}
@@ -129,7 +131,9 @@ impl EditorSession {
lsp: Lsp::new(config.lsp),
lsp_request_id: 0,
lsp_source_cursor: None,
lsp_source_mode: Mode::Normal,
pending_hover: false,
pending_completion: false,
hover_owner: None,
};
session.synchronize_documents();
@@ -141,7 +145,7 @@ impl EditorSession {
}
pub fn handle(&mut self, input: Input) {
self.cancel_pending_hover();
self.cancel_pending_popup_request();
let invocation = self.editor.handle(input);
self.synchronize_documents();
if let Some(invocation) = invocation {
@@ -151,7 +155,7 @@ impl EditorSession {
}
pub fn execute(&mut self, command: &str) {
self.cancel_pending_hover();
self.cancel_pending_popup_request();
let invocation = self.editor.execute(command);
self.synchronize_documents();
if let Some(invocation) = invocation {
@@ -163,7 +167,7 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" {
let result = match invocation.command.as_str() {
"lsp.definition" | "lsp.hover" => {
"lsp.definition" | "lsp.hover" | "lsp.completion" => {
if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id;
let context = self.editor.context();
@@ -173,11 +177,13 @@ impl EditorSession {
};
self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor());
self.lsp_source_mode = self.editor.mode();
self.pending_hover = invocation.command == "lsp.hover";
if self.pending_hover {
self.lsp.hover(&context, id)
} else {
self.lsp.definition(&context, id)
self.pending_completion = invocation.command == "lsp.completion";
match invocation.command.as_str() {
"lsp.hover" => self.lsp.hover(&context, id),
"lsp.completion" => self.lsp.completion(&context, id),
_ => self.lsp.definition(&context, id),
}
} else {
Err("LSP request IDs exhausted".into())
@@ -187,6 +193,7 @@ impl EditorSession {
_ => unreachable!("session-dispatched core command"),
};
if let Err(error) = result {
self.cancel_pending_popup_request();
self.editor.set_message(format!("LSP: {error}"));
}
return;
@@ -216,12 +223,13 @@ impl EditorSession {
}
}
fn cancel_pending_hover(&mut self) {
if self.pending_hover {
fn cancel_pending_popup_request(&mut self) {
if self.pending_hover || self.pending_completion {
if let Some(requests) = self.requests.get_mut("core") {
requests.latest = None;
}
self.pending_hover = false;
self.pending_completion = false;
}
}
@@ -243,12 +251,13 @@ impl EditorSession {
source.buffer_id != self.editor.buffer_id()
|| source.revision != self.editor.text_revision()
|| self.lsp_source_cursor != Some(self.editor.cursor())
|| self.editor.mode() != Mode::Normal
|| self.editor.mode() != self.lsp_source_mode
})
{
requests.latest = None;
self.lsp_source_cursor = None;
self.pending_hover = false;
self.pending_completion = false;
}
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
@@ -363,7 +372,7 @@ impl EditorSession {
&& self.source_is_current(source)
&& (owner != "core"
|| (self.lsp_source_cursor == Some(self.editor.cursor())
&& self.editor.mode() == Mode::Normal))
&& self.editor.mode() == self.lsp_source_mode))
&& self
.requests
.get(owner)
@@ -390,6 +399,18 @@ impl EditorSession {
.is_none_or(|picker| !picker.focused)
}
fn completion_is_current(&self, request_id: Option<u64>, source: InvocationSource) -> bool {
self.pending_completion
&& self.request_is_current("core", request_id, source)
&& self.editor.mode() == Mode::Insert
&& self.editor.palette().is_none()
&& self
.editor
.file_picker()
.is_none_or(|picker| !picker.focused)
&& self.editor.startup_diagnostic().is_none()
}
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
if owner == "core"
&& let Some(id) = update.request_id
@@ -402,6 +423,30 @@ impl EditorSession {
{
return Ok(false);
}
for effect in &update.effects {
if let UpdateEffect::Completion {
buffer_id,
revision,
items,
} = effect
{
if owner != "core" {
return Err("Completion is a native LSP update only".into());
}
let source = InvocationSource {
buffer_id: *buffer_id,
revision: *revision,
};
if self.completion_is_current(update.request_id, source)
&& let Err(error) = self.editor.validate_completion(items)
{
self.pending_completion = false;
self.editor
.set_message(format!("LSP completion failed: {error}"));
return Ok(true);
}
}
}
// Validate the whole applicable annotation batch before changing any buffer.
let validation = (|| -> Result<(), String> {
for effect in &update.effects {
@@ -460,6 +505,7 @@ impl EditorSession {
crate::editor::validate_hover_text(text)?;
}
}
UpdateEffect::Completion { .. } => {}
UpdateEffect::Message { .. } => {}
}
}
@@ -531,6 +577,24 @@ impl EditorSession {
changed = true;
}
}
UpdateEffect::Completion {
buffer_id,
revision,
items,
} => {
let source = InvocationSource {
buffer_id,
revision,
};
if self.completion_is_current(update.request_id, source) {
self.pending_completion = false;
if let Err(error) = self.editor.show_completion(items) {
self.editor
.set_message(format!("LSP completion failed: {error}"));
}
changed = true;
}
}
UpdateEffect::Message { text } => {
changed |= self.editor.message() != text;
self.editor.set_message(text);
+163 -4
View File
@@ -23,7 +23,7 @@ use crossterm::{
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
@@ -32,7 +32,9 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
@@ -446,6 +448,7 @@ fn draw(
let height = usize::from(viewport.height);
let width = usize::from(viewport.width);
let cursor = editor.cursor();
let mut document_caret = None;
let carets = editor.carets();
let selections = editor.selections();
if document.manual.as_ref().is_some_and(|manual| {
@@ -820,13 +823,20 @@ fn draw(
&& column >= *left
&& column < left.saturating_add(width)
{
frame.set_cursor_position((
let position = Position::new(
viewport.x + (column - *left) as u16,
viewport.y + (cursor.row - *top) as u16,
));
);
frame.set_cursor_position(position);
document_caret = Some(position);
}
}
}
if let Some(completion) = editor.completion()
&& let Some(caret) = document_caret
{
panels.completion = draw_completion(frame, completion, viewport, caret);
}
if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(area);
@@ -916,6 +926,7 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
#[derive(Default)]
struct WheelPanels {
completion: Option<Rect>,
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
@@ -952,6 +963,7 @@ impl WheelPanels {
self.manual_files = None;
}
self.hover = None;
self.completion = None;
self.files = None;
}
@@ -992,6 +1004,13 @@ impl WheelPanels {
return true;
}
let position = ratatui::layout::Position::new(mouse.column, mouse.row);
if editor.completion().is_some()
&& self
.completion
.is_some_and(|panel| panel.contains(position))
{
return true;
}
if let Some(hover) = editor.hover()
&& let Some((panel, height)) = self.hover
&& panel.contains(position)
@@ -1042,6 +1061,69 @@ impl WheelPanels {
}
}
fn draw_completion(
frame: &mut Frame,
completion: &Completion,
area: Rect,
caret: Position,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 || !area.contains(caret) {
return None;
}
let count = completion.items.len().saturating_add(1);
let selected = completion.selected.min(count - 1);
let desired_height = count.min(8) as u16;
let below = area.bottom() - caret.y - 1;
let above = caret.y - area.y;
let downward = below >= desired_height || below >= above;
let height = desired_height.min(if downward { below } else { above }.max(1));
let first = selected.saturating_sub(usize::from(height) - 1);
let lines: Vec<Line> = (first..count)
.take(usize::from(height))
.map(|index| {
let label = if index == 0 {
"Original text".to_owned()
} else {
let item = &completion.items[index - 1];
let mut label = display_text(&item.label);
if let Some(detail) = &item.detail {
label.push_str(" · ");
label.push_str(&display_text(detail));
}
label
};
let style = if index == selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().fg(Color::Gray).bg(Color::Black)
};
Line::styled(
visible_line(&label, 0, usize::from(area.width.min(60))),
style,
)
})
.collect();
let width = lines.iter().map(Line::width).max().unwrap_or(1).max(1) as u16;
let panel = Rect::new(
caret.x.min(area.right() - width),
if below == 0 && above == 0 {
caret.y
} else if downward {
caret.y + 1
} else {
caret.y - height
},
width,
height,
);
frame.render_widget(Clear, panel);
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(Color::Gray).bg(Color::Black)),
panel,
);
Some(panel)
}
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) {
if area.width == 0 || area.height == 0 {
return;
@@ -1548,6 +1630,83 @@ mod tests {
.join("\n")
}
#[test]
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
let mut completion = Completion {
items: (0..20)
.map(|index| crate::plugin::CompletionItem {
label: if index == 19 {
"界e\u{301}👩‍🚀\u{1b}".into()
} else {
format!("item-{index:02}")
},
detail: Some("\tTAIL".into()),
primary_edit: 0,
edits: vec![],
})
.collect(),
selected: 20,
};
for (width, height) in [(14, 6), (9, 1), (5, 1)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let workspace = Rect::new(2, 0, width - 4, height);
let caret = Position::new(2, 0);
let mut panel = None;
completion.selected = 20;
terminal
.draw(|frame| {
let rows: Vec<Line> = (0..height)
.map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4)))))
.collect();
frame.render_widget(Paragraph::new(rows), frame.area());
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret);
})
.unwrap();
let mut panel = panel.unwrap();
let selected_y = panel.bottom() - 1;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan);
assert!(!screen_text(&terminal).contains("item-00"));
for y in 0..height {
for x in [0, 1] {
assert_eq!(buffer[(x, y)].symbol(), "G");
}
for x in [width - 2, width - 1] {
assert_eq!(buffer[(x, y)].symbol(), "E");
}
}
if workspace.width >= 5 {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界");
assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}");
assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀");
} else {
assert_eq!(buffer[(panel.x, selected_y)].symbol(), " ");
}
if workspace.width >= 6 {
assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�");
}
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
completion.selected = 0;
terminal
.draw(|frame| {
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret).unwrap();
})
.unwrap();
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].symbol(),
"O"
);
assert_eq!(
terminal.backend().buffer()[(panel.x, panel.y)].bg,
Color::Cyan
);
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
}
}
#[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap();