feat: add asynchronous LSP plugin diagnostics and definitions

This commit is contained in:
Valdemar Lorenzen
2026-10-09 12:01:45 +02:00
parent e5d545a4ca
commit 92679ecbe1
14 changed files with 4828 additions and 109 deletions
+347 -3
View File
@@ -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");
}
}