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"));
|
||||
}
|
||||
}
|
||||
|
||||
+423
-4
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user