feat: add asynchronous LSP plugin diagnostics and definitions
This commit is contained in:
+347
-3
@@ -1,5 +1,6 @@
|
||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||||
use crate::plugin::{Diagnostic, TextPosition};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -117,6 +118,9 @@ pub struct Invocation {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Context {
|
||||
pub buffer_id: usize,
|
||||
pub revision: u64,
|
||||
pub root: String,
|
||||
pub text: String,
|
||||
/// One-based row, including the empty row after a trailing newline.
|
||||
pub row: usize,
|
||||
@@ -689,11 +693,27 @@ pub struct BufferInfo<'a> {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Borrowed document state, including hidden and unnamed buffers.
|
||||
pub struct DocumentInfo<'a> {
|
||||
pub id: usize,
|
||||
pub revision: u64,
|
||||
pub path: Option<&'a Path>,
|
||||
pub lines: &'a [String],
|
||||
pub save_revision: u64,
|
||||
}
|
||||
|
||||
struct AnnotationSet {
|
||||
revision: u64,
|
||||
items: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
struct Buffer {
|
||||
id: usize,
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
text_revision: u64,
|
||||
save_revision: u64,
|
||||
diagnostics: BTreeMap<String, AnnotationSet>,
|
||||
targets: Vec<Target>,
|
||||
primary: usize,
|
||||
mode: Mode,
|
||||
@@ -733,6 +753,8 @@ impl Buffer {
|
||||
saved_lines: lines.clone(),
|
||||
lines,
|
||||
text_revision: 0,
|
||||
save_revision: 0,
|
||||
diagnostics: BTreeMap::new(),
|
||||
targets: vec![Target::caret(Cursor::default())],
|
||||
primary: 0,
|
||||
mode: Mode::Normal,
|
||||
@@ -760,6 +782,21 @@ impl Buffer {
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
fn document(&self) -> DocumentInfo<'_> {
|
||||
DocumentInfo {
|
||||
id: self.id,
|
||||
revision: self.text_revision,
|
||||
path: self.path.as_deref(),
|
||||
lines: &self.lines,
|
||||
save_revision: self.save_revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_changed(&mut self) {
|
||||
self.text_revision = self.text_revision.wrapping_add(1);
|
||||
self.diagnostics.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
@@ -937,6 +974,163 @@ impl Editor {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn documents(&self) -> impl Iterator<Item = DocumentInfo<'_>> + '_ {
|
||||
self.parked
|
||||
.range(..self.active.id)
|
||||
.map(|(_, buffer)| buffer.document())
|
||||
.chain(std::iter::once(self.active.document()))
|
||||
.chain(
|
||||
self.parked
|
||||
.range(self.active.id..)
|
||||
.map(|(_, buffer)| buffer.document()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn project_root(&self) -> &Path {
|
||||
&self.project_root
|
||||
}
|
||||
|
||||
fn buffer(&self, id: usize) -> Option<&Buffer> {
|
||||
if id == self.active.id {
|
||||
Some(&self.active)
|
||||
} else {
|
||||
self.parked.get(&id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
|
||||
self.active
|
||||
.diagnostics
|
||||
.values()
|
||||
.filter(|annotations| annotations.revision == self.active.text_revision)
|
||||
.flat_map(|annotations| &annotations.items)
|
||||
}
|
||||
|
||||
pub fn diagnostic_at_cursor(&self) -> Option<&Diagnostic> {
|
||||
let cursor = self.cursor();
|
||||
let position = TextPosition {
|
||||
row: cursor.row,
|
||||
column: cursor.column,
|
||||
};
|
||||
self.diagnostics().find(|diagnostic| {
|
||||
diagnostic.start <= position
|
||||
&& (position < diagnostic.end
|
||||
|| (diagnostic.start == diagnostic.end && position == diagnostic.start))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagnostics(
|
||||
&self,
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
items: &[Diagnostic],
|
||||
) -> Result<bool, String> {
|
||||
let Some(buffer) = self.buffer(buffer_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if buffer.text_revision != revision {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut bytes = 0usize;
|
||||
for diagnostic in items {
|
||||
for position in [diagnostic.start, diagnostic.end] {
|
||||
if buffer
|
||||
.lines
|
||||
.get(position.row)
|
||||
.is_none_or(|line| position.column > grapheme_count(line))
|
||||
{
|
||||
return Err("Diagnostic position is outside the document".into());
|
||||
}
|
||||
}
|
||||
if diagnostic.end < diagnostic.start {
|
||||
return Err("Diagnostic range is reversed".into());
|
||||
}
|
||||
if diagnostic.message.chars().any(char::is_control) {
|
||||
return Err("Diagnostic messages cannot contain controls".into());
|
||||
}
|
||||
bytes = bytes
|
||||
.checked_add(diagnostic.message.len())
|
||||
.ok_or("Diagnostics exceed 4 MiB")?;
|
||||
if bytes > MAX_EDIT_BYTES {
|
||||
return Err("Diagnostics exceed 4 MiB".into());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn replace_diagnostics(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
items: Vec<Diagnostic>,
|
||||
) -> Result<bool, String> {
|
||||
if !self.validate_diagnostics(buffer_id, revision, &items)? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(self.replace_validated_diagnostics(owner, buffer_id, revision, items))
|
||||
}
|
||||
|
||||
/// Only session's fully prevalidated batch may use this storage path.
|
||||
pub(crate) fn replace_validated_diagnostics(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
items: Vec<Diagnostic>,
|
||||
) -> bool {
|
||||
let buffer = if buffer_id == self.active.id {
|
||||
&mut self.active
|
||||
} else if let Some(buffer) = self.parked.get_mut(&buffer_id) {
|
||||
buffer
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if buffer.text_revision != revision {
|
||||
return false;
|
||||
}
|
||||
if items.is_empty() {
|
||||
return buffer.diagnostics.remove(owner).is_some();
|
||||
}
|
||||
if buffer.diagnostics.get(owner).is_some_and(|annotations| {
|
||||
annotations.revision == revision && annotations.items == items
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
buffer
|
||||
.diagnostics
|
||||
.insert(owner.to_owned(), AnnotationSet { revision, items });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear_plugin_diagnostics(&mut self, owner: &str) {
|
||||
self.active.diagnostics.remove(owner);
|
||||
for buffer in self.parked.values_mut() {
|
||||
buffer.diagnostics.remove(owner);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
|
||||
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
|
||||
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
|
||||
return Err("Navigation path must be nonempty and contain no controls".into());
|
||||
}
|
||||
self.edit_buffer(path, false)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.park_source();
|
||||
self.active.mode = Mode::Normal;
|
||||
let row = position.row.min(self.active.lines.len() - 1);
|
||||
let column = position
|
||||
.column
|
||||
.min(grapheme_count(&self.active.lines[row]).saturating_sub(1));
|
||||
self.active.targets = vec![Target::caret(Cursor { row, column })];
|
||||
self.active.primary = 0;
|
||||
self.active.search_match = None;
|
||||
self.active.visual_motions = None;
|
||||
self.normalize_targets();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lines(&self) -> &[String] {
|
||||
&self.active.lines
|
||||
}
|
||||
@@ -1103,6 +1297,9 @@ impl Editor {
|
||||
|
||||
pub fn context(&self) -> Context {
|
||||
Context {
|
||||
buffer_id: self.active.id,
|
||||
revision: self.active.text_revision,
|
||||
root: self.project_root.to_string_lossy().into_owned(),
|
||||
text: self.active.lines.join("\n"),
|
||||
row: self.cursor().row + 1,
|
||||
column: self.cursor().column + 1,
|
||||
@@ -1175,6 +1372,7 @@ impl Editor {
|
||||
self.set_message("Core features cannot be disabled");
|
||||
return;
|
||||
}
|
||||
self.clear_plugin_diagnostics(name);
|
||||
if let Some(feature) = self.features.remove(name) {
|
||||
for bindings in self.bindings.values_mut() {
|
||||
bindings.retain(|_, binding| {
|
||||
@@ -2275,7 +2473,7 @@ impl Editor {
|
||||
Effect::Replace(text) => {
|
||||
let lines = split_lines(&text);
|
||||
if lines != self.active.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
}
|
||||
self.active.lines = lines;
|
||||
self.active.search_match = None;
|
||||
@@ -2330,6 +2528,7 @@ impl Editor {
|
||||
match atomic_save(&path, &self.active.lines) {
|
||||
Ok(()) => {
|
||||
self.active.saved_lines.clone_from(&self.active.lines);
|
||||
self.active.save_revision = self.active.save_revision.wrapping_add(1);
|
||||
self.active.path = Some(path);
|
||||
self.active.identity = Some(identity);
|
||||
self.set_message("Written");
|
||||
@@ -3176,7 +3375,7 @@ impl Editor {
|
||||
|
||||
fn restore(&mut self, snapshot: Snapshot) {
|
||||
if self.active.lines != snapshot.lines {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
}
|
||||
self.active.lines = snapshot.lines;
|
||||
self.active.targets = snapshot.targets;
|
||||
@@ -3390,7 +3589,7 @@ impl Editor {
|
||||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||||
}
|
||||
if text != old {
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.active.text_changed();
|
||||
self.active.search_match = None;
|
||||
}
|
||||
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
||||
@@ -9411,4 +9610,149 @@ mod tests {
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed");
|
||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||||
}
|
||||
|
||||
fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic {
|
||||
Diagnostic {
|
||||
start: TextPosition {
|
||||
row: start.0,
|
||||
column: start.1,
|
||||
},
|
||||
end: TextPosition {
|
||||
row: end.0,
|
||||
column: end.1,
|
||||
},
|
||||
severity: crate::plugin::DiagnosticSeverity::Warning,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a😀e\u{301}\n".into())])
|
||||
.unwrap();
|
||||
let id = editor.buffer_id();
|
||||
let revision = editor.text_revision();
|
||||
let first = annotation((0, 1), (0, 3), "first");
|
||||
editor
|
||||
.replace_diagnostics("one", id, revision, vec![first.clone()])
|
||||
.unwrap();
|
||||
editor
|
||||
.replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")])
|
||||
.unwrap();
|
||||
for invalid in [
|
||||
annotation((0, 0), (0, 4), "scalar column"),
|
||||
annotation((0, 3), (0, 2), "reversed"),
|
||||
annotation((2, 0), (2, 0), "missing row"),
|
||||
annotation((0, 0), (0, 1), "control\u{1b}"),
|
||||
] {
|
||||
assert!(
|
||||
editor
|
||||
.replace_diagnostics("one", id, revision, vec![first.clone(), invalid])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.diagnostics().count(), 2);
|
||||
assert_eq!(
|
||||
editor.active.diagnostics["one"].items.as_slice(),
|
||||
std::slice::from_ref(&first)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!editor
|
||||
.replace_diagnostics(
|
||||
"one",
|
||||
id,
|
||||
revision - 1,
|
||||
vec![annotation((99, 0), (99, 0), "stale")]
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!editor
|
||||
.replace_diagnostics("one", 999, revision, vec![first.clone()])
|
||||
.unwrap()
|
||||
);
|
||||
editor.active.targets = vec![Target::caret(Cursor { row: 0, column: 1 })];
|
||||
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first");
|
||||
editor.active.targets = vec![Target::caret(Cursor { row: 1, column: 0 })];
|
||||
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end");
|
||||
editor.clear_plugin_diagnostics("one");
|
||||
assert_eq!(editor.diagnostics().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_do_not_split_typing_undo_and_clear_on_text_change() {
|
||||
let mut editor = editor();
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Char('a'));
|
||||
let revision = editor.text_revision();
|
||||
editor
|
||||
.replace_diagnostics(
|
||||
"one",
|
||||
editor.buffer_id(),
|
||||
revision,
|
||||
vec![annotation((0, 0), (0, 1), "typed")],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
assert!(editor.active.undo.is_empty());
|
||||
editor.handle(Input::Char('b'));
|
||||
assert_eq!(editor.diagnostics().count(), 0);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), [""]);
|
||||
assert!(editor.active.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source");
|
||||
let target = directory.path("target");
|
||||
fs::write(&source, "source").unwrap();
|
||||
fs::write(&target, "a😀e\u{301}\n").unwrap();
|
||||
let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor.execute(&format!("edit {}", target.display()));
|
||||
editor
|
||||
.apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())])
|
||||
.unwrap();
|
||||
let target_id = editor.buffer_id();
|
||||
editor.execute("buffer 1");
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Char('!'));
|
||||
let before = editor.context().text;
|
||||
let cursor = editor.cursor();
|
||||
let undo = editor.active.undo.len();
|
||||
assert!(
|
||||
editor
|
||||
.navigate_to(
|
||||
directory.path("missing"),
|
||||
TextPosition { row: 0, column: 0 }
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.context().text, before);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
editor
|
||||
.navigate_to(
|
||||
target,
|
||||
TextPosition {
|
||||
row: 99,
|
||||
column: 99,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(editor.buffer_id(), target_id);
|
||||
assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]);
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(fs::read_to_string(source).unwrap(), "source");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user