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");
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod clipboard;
pub mod editor;
pub mod files;
pub mod plugin;
pub mod ruby;
pub mod session;
pub mod syntax;
+4 -1
View File
@@ -34,7 +34,10 @@ Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.";
Set VAMP_RUBY to choose the Ruby executable.
Optional LSP Ruby plugin: config.plugin PATH, options: { servers: [...] }
LSP commands when enabled: :lsp_definition, :lsp_diagnostics";
struct Options {
file: Option<PathBuf>,
+88
View File
@@ -0,0 +1,88 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
pub struct DocumentSnapshot {
pub id: usize,
pub revision: u64,
pub path: Option<String>,
pub root: String,
pub text: String,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DocumentEventKind {
Open,
Change,
Save,
Close,
}
#[derive(Clone, Debug, Serialize)]
pub struct DocumentEvent {
pub event: DocumentEventKind,
pub document: DocumentSnapshot,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity {
Error,
Warning,
Information,
Hint,
}
/// Zero-based extended-grapheme positions, independent of any server encoding.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
#[serde(deny_unknown_fields)]
pub struct TextPosition {
pub row: usize,
pub column: usize,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Diagnostic {
pub start: TextPosition,
pub end: TextPosition,
pub severity: DiagnosticSeverity,
pub message: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum UpdateEffect {
Diagnostics {
buffer_id: usize,
revision: u64,
items: Vec<Diagnostic>,
},
Navigate {
buffer_id: usize,
revision: u64,
path: PathBuf,
row: usize,
column: usize,
},
Message {
text: String,
},
}
#[derive(Debug)]
pub struct PluginUpdate {
pub request_id: Option<u64>,
pub effects: Vec<UpdateEffect>,
}
#[derive(Debug)]
pub enum PluginEvent {
Response {
id: u64,
effects: Vec<crate::editor::Effect>,
},
Update(PluginUpdate),
}
+724 -51
View File
File diff suppressed because it is too large Load Diff
+138 -18
View File
@@ -8,6 +8,7 @@ protocol_in = STDIN.dup
protocol_in.binmode
STDOUT.reopen(STDERR)
$stdout = STDERR
Thread.abort_on_exception = true
module Vamp
MAX_FRAME = 4 * 1024 * 1024
@@ -18,6 +19,37 @@ module Vamp
value.dup.force_encoding(Encoding::UTF_8)
end
def self.json_value!(value)
case value
when Hash
value.to_h do |key, item|
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
[string!(key.to_s, "option key"), json_value!(item)]
end
when Array
value.map { |item| json_value!(item) }
when String
string!(value, "option value")
when Integer, TrueClass, FalseClass, NilClass
value
when Float
raise ArgumentError, "option numbers must be finite" unless value.finite?
value
else
raise TypeError, "options must contain only JSON values"
end
end
def self.deep_freeze(value)
case value
when Hash
value.each { |key, item| deep_freeze(key); deep_freeze(item) }
when Array
value.each { |item| deep_freeze(item) }
end
value.freeze
end
Action = Struct.new(:name)
Mode = Struct.new(:name)
@@ -59,10 +91,11 @@ module Vamp
@clipboard_mode = "system"
end
def plugin(path)
def plugin(path, options: {})
path = Vamp.string!(path, "plugin path")
raise ArgumentError, "plugin path must not be empty" if path.empty?
@plugins << path
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
nil
end
@@ -136,20 +169,27 @@ module Vamp
end
class Editor
attr_reader :text, :row, :column, :mode, :path, :effects
attr_reader :text, :row, :column, :mode, :path, :effects, :buffer_id, :revision, :root, :request_id
def initialize(context)
unless context.is_a?(Hash) && context.keys.sort == %w[column mode path row text]
def initialize(context, request_id)
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
raise ArgumentError, "invalid editor context"
end
@text = Vamp.string!(context["text"], "context text").freeze
@mode = Vamp.string!(context["mode"], "context mode").freeze
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
@root = Vamp.string!(context["root"], "context root").freeze
@buffer_id = context["buffer_id"]
@revision = context["revision"]
@request_id = request_id
@row = context["row"]
@column = context["column"]
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
raise ArgumentError, "context row and column must be positive integers"
end
unless @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
end
@effects = []
@effect_bytes = 0
end
@@ -177,12 +217,22 @@ module Vamp
end
end
def self.setup(mode)
def self.setup(mode, options, sender)
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
@mode = mode
@config = Config.new
@name = nil
@requires = []
@commands = {}
@async = false
@document_callback = nil
@shutdown_callback = nil
@options = deep_freeze(json_value!(options))
@sender = sender
end
def self.options
@options
end
def self.configure
@@ -191,7 +241,7 @@ module Vamp
yield @config
end
def self.plugin(name, requires: [])
def self.plugin(name, requires: [], async: false)
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
raise ArgumentError, "plugin already declared" unless @name.nil?
name = string!(name, "plugin name")
@@ -199,6 +249,8 @@ module Vamp
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
@requires = requires.map { |value| string!(value, "capability") }
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
raise TypeError, "async must be true or false" unless async == true || async == false
@async = async
@name = name
nil
end
@@ -213,11 +265,62 @@ module Vamp
nil
end
def self.on_document(&callback)
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
raise ArgumentError, "on_document requires a block" unless callback
raise ArgumentError, "document callback already registered" if @document_callback
@document_callback = callback
nil
end
def self.on_shutdown(&callback)
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
raise ArgumentError, "on_shutdown requires a block" unless callback
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
@shutdown_callback = callback
nil
end
def self.shutdown
callback, @shutdown_callback = @shutdown_callback, nil
callback&.call
end
def self.publish(effects, request_id: nil)
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async
raise TypeError, "published effects must be an Array of Hashes" unless effects.is_a?(Array) && effects.all? { |effect| effect.is_a?(Hash) }
unless request_id.nil? || (request_id.is_a?(Integer) && request_id > 0 && request_id <= 18_446_744_073_709_551_615)
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
end
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
"effects" => json_value!(effects) })
nil
end
def self.document(request)
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] &&
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
%w[open change save close].include?(request["event"])
raise ArgumentError, "invalid document event frame"
end
document = request["document"]
unless document.is_a?(Hash) && document.keys.sort == %w[id path revision root text] &&
document["id"].is_a?(Integer) && document["id"] >= 0 &&
document["revision"].is_a?(Integer) && document["revision"] >= 0
raise ArgumentError, "invalid document snapshot"
end
document["text"] = string!(document["text"], "document text")
document["root"] = string!(document["root"], "document root")
document["path"] = string!(document["path"], "document path") unless document["path"].nil?
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
nil
end
def self.startup
return @config.response if @mode == "config"
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
{ "version" => 1, "type" => "manifest", "name" => @name,
"requires" => @requires, "commands" => @commands.keys }
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
end
def self.invoke(request)
@@ -230,24 +333,28 @@ module Vamp
args = string!(request["args"], "args")
callback = @commands[command]
raise ArgumentError, "unknown command #{command}" unless callback
editor = Editor.new(request["context"])
editor = Editor.new(request["context"], request["id"])
callback.call(editor, args)
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
end
end
protocol_mutex = Mutex.new
send_frame = lambda do |frame|
payload = JSON.generate(frame)
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
protocol_out.write(payload)
protocol_out.write("\n")
protocol_mutex.synchronize do
payload = JSON.generate(frame)
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
protocol_out.write(payload)
protocol_out.write("\n")
end
end
request_id = nil
begin
mode, path = ARGV
raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path
Vamp.setup(mode)
mode, path, encoded_options = ARGV
raise ArgumentError, "expected config or plugin mode, path, and options" unless %w[config plugin].include?(mode) && path && encoded_options
raise ArgumentError, "plugin options exceed 4 MiB" if encoded_options.bytesize > Vamp::MAX_FRAME
Vamp.setup(mode, JSON.parse(encoded_options), send_frame)
load path
send_frame.call(Vamp.startup)
if mode == "plugin"
@@ -260,9 +367,22 @@ begin
end
request = JSON.parse(line)
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
send_frame.call(Vamp.invoke(request))
case request.is_a?(Hash) && request["type"]
when "invoke"
send_frame.call(Vamp.invoke(request))
when "document"
Vamp.document(request)
when "shutdown"
unless request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
raise ArgumentError, "invalid shutdown frame"
end
break
else
raise ArgumentError, "unknown protocol frame"
end
end
end
Vamp.shutdown
rescue Exception => error
# No callback effects are emitted when loading or invoking raises.
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
+834 -14
View File
@@ -5,15 +5,38 @@ use std::{
};
use crate::{
editor::{Editor, Input, Invocation},
editor::{Editor, Effect, Input, Invocation},
plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
TextPosition, UpdateEffect,
},
ruby::{Config, RubyPlugin, load_config},
};
struct TrackedDocument {
snapshot: DocumentSnapshot,
save_revision: u64,
}
#[derive(Clone, Copy)]
struct InvocationSource {
buffer_id: usize,
revision: u64,
}
#[derive(Default)]
struct AsyncRequests {
pending: BTreeMap<u64, InvocationSource>,
latest: Option<(u64, InvocationSource)>,
}
/// Owns command eligibility and execution hosts together. Frontends can inspect
/// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession {
editor: Editor,
plugins: BTreeMap<String, RubyPlugin>,
documents: BTreeMap<usize, TrackedDocument>,
requests: BTreeMap<String, AsyncRequests>,
}
impl EditorSession {
@@ -61,8 +84,9 @@ impl EditorSession {
));
}
let mut plugins = BTreeMap::new();
for path in config.plugins {
match RubyPlugin::start(&path) {
for configured in config.plugins {
let path = configured.path;
match RubyPlugin::start(&path, &configured.options) {
Ok((plugin, feature)) => {
let name = feature.name.clone();
match editor.register(feature) {
@@ -88,7 +112,14 @@ impl EditorSession {
} else {
editor.set_message(notices.join(" | "));
}
Ok(Self { editor, plugins })
let mut session = Self {
editor,
plugins,
documents: BTreeMap::new(),
requests: BTreeMap::new(),
};
session.synchronize_documents();
Ok(session)
}
pub fn editor(&self) -> &Editor {
@@ -96,34 +127,326 @@ impl EditorSession {
}
pub fn handle(&mut self, input: Input) {
if let Some(invocation) = self.editor.handle(input) {
let invocation = self.editor.handle(input);
self.synchronize_documents();
if let Some(invocation) = invocation {
self.dispatch(invocation);
self.synchronize_documents();
}
}
pub fn execute(&mut self, command: &str) {
if let Some(invocation) = self.editor.execute(command) {
let invocation = self.editor.execute(command);
self.synchronize_documents();
if let Some(invocation) = invocation {
self.dispatch(invocation);
self.synchronize_documents();
}
}
fn dispatch(&mut self, invocation: Invocation) {
let context = self.editor.context();
let result = match self.plugins.get_mut(&invocation.plugin) {
Some(plugin) if plugin.is_async() => {
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
plugin
.queue_invoke(&invocation.command, &invocation.args, &context)
.map(|id| {
let source = InvocationSource {
buffer_id: context.buffer_id,
revision: context.revision,
};
requests.pending.insert(id, source);
requests.latest = Some((id, source));
})
}
Some(plugin) => plugin
.invoke(
&invocation.command,
&invocation.args,
&self.editor.context(),
)
.invoke(&invocation.command, &invocation.args, &context)
.and_then(|effects| self.editor.apply(effects)),
None => Err("plugin host is unavailable".into()),
};
if let Err(error) = result {
// Drop also stops a live host whose protocol-valid edits were rejected.
drop(self.plugins.remove(&invocation.plugin));
self.editor.disable_plugin(&invocation.plugin, &error);
self.retire(&invocation.plugin, &error);
}
}
fn retire(&mut self, owner: &str, error: &str) {
// Drop also stops a live host whose protocol-valid effects were rejected.
drop(self.plugins.remove(owner));
self.requests.remove(owner);
self.editor.disable_plugin(owner, error);
}
fn synchronize_documents(&mut self) -> bool {
if !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return false;
}
let mut events = Vec::new();
if !self.editor.should_quit() {
for document in self.editor.documents() {
let path = document.path.map(Path::to_string_lossy);
let previous = self.documents.get(&document.id);
let renamed = previous
.is_some_and(|previous| previous.snapshot.path.as_deref() != path.as_deref());
if renamed {
events.push(DocumentEvent {
event: DocumentEventKind::Close,
document: previous.expect("renamed document existed").snapshot.clone(),
});
}
let changed = previous.is_none_or(|previous| {
previous.snapshot.revision != document.revision || renamed
});
if changed {
let snapshot = DocumentSnapshot {
id: document.id,
revision: document.revision,
path: path.map(|path| path.into_owned()),
root: self.editor.project_root().to_string_lossy().into_owned(),
text: document.lines.join("\n"),
};
events.push(DocumentEvent {
event: if previous.is_none() || renamed {
DocumentEventKind::Open
} else {
DocumentEventKind::Change
},
document: snapshot.clone(),
});
let save_revision = previous.map_or(0, |previous| previous.save_revision);
self.documents.insert(
document.id,
TrackedDocument {
snapshot,
save_revision,
},
);
}
let tracked = self
.documents
.get_mut(&document.id)
.expect("document tracked");
if tracked.save_revision != document.save_revision {
events.push(DocumentEvent {
event: DocumentEventKind::Save,
document: tracked.snapshot.clone(),
});
tracked.save_revision = document.save_revision;
}
}
}
let closed: Vec<_> = self
.documents
.keys()
.filter(|id| self.editor.should_quit() || !self.editor.has_buffer(**id))
.copied()
.collect();
for id in closed {
let document = self.documents.remove(&id).expect("closed document existed");
events.push(DocumentEvent {
event: DocumentEventKind::Close,
document: document.snapshot,
});
}
let mut failures = Vec::new();
for (owner, plugin) in &mut self.plugins {
if !plugin.is_async() {
continue;
}
for event in &events {
if let Err(error) = plugin.notify_document(event) {
failures.push((owner.clone(), error));
break;
}
}
}
let changed = !failures.is_empty();
for (owner, error) in failures {
self.retire(&owner, &error);
}
changed
}
fn source_is_current(&self, source: InvocationSource) -> bool {
self.editor
.documents()
.any(|document| document.id == source.buffer_id && document.revision == source.revision)
}
fn navigation_is_current(
&self,
owner: &str,
request_id: Option<u64>,
source: InvocationSource,
) -> bool {
self.editor.buffer_id() == source.buffer_id
&& self.source_is_current(source)
&& self
.requests
.get(owner)
.and_then(|requests| requests.latest)
.is_some_and(|(id, latest)| {
request_id == Some(id)
&& latest.buffer_id == source.buffer_id
&& latest.revision == source.revision
})
}
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
// Validate the whole applicable annotation batch before changing any buffer.
for effect in &update.effects {
match effect {
UpdateEffect::Diagnostics {
buffer_id,
revision,
items,
} => {
self.editor
.validate_diagnostics(*buffer_id, *revision, items)?;
}
UpdateEffect::Navigate {
buffer_id,
revision,
path,
..
} => {
let source = InvocationSource {
buffer_id: *buffer_id,
revision: *revision,
};
if self.navigation_is_current(owner, update.request_id, source)
&& (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()
);
}
}
UpdateEffect::Message { .. } => {}
}
}
let mut changed = false;
for effect in update.effects {
match effect {
UpdateEffect::Diagnostics {
buffer_id,
revision,
items,
} => {
changed |= self
.editor
.replace_validated_diagnostics(owner, buffer_id, revision, items);
}
UpdateEffect::Navigate {
buffer_id,
revision,
path,
row,
column,
} => {
let source = InvocationSource {
buffer_id,
revision,
};
if self.navigation_is_current(owner, update.request_id, source) {
if let Err(error) =
self.editor.navigate_to(path, TextPosition { row, column })
{
self.editor
.set_message(format!("Navigation failed: {error}"));
}
changed = true;
}
}
UpdateEffect::Message { text } => {
changed |= self.editor.message() != text;
self.editor.set_message(text);
}
}
}
Ok(changed)
}
fn apply_response(
&mut self,
owner: &str,
id: u64,
effects: Vec<Effect>,
) -> Result<bool, String> {
let source = self
.requests
.get_mut(owner)
.and_then(|requests| requests.pending.remove(&id));
let Some(source) = source else {
return Err("plugin response has an unknown invocation ID".into());
};
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
return Ok(false);
}
if effects
.iter()
.all(|effect| matches!(effect, Effect::Message(_)))
{
let mut changed = false;
for effect in effects {
if let Effect::Message(text) = effect {
changed |= self.editor.message() != text;
self.editor.set_message(text);
}
}
return Ok(changed);
}
let changed = !effects.is_empty();
self.editor.apply(effects)?;
Ok(changed)
}
/// Drains ready plugin work without waiting for command or server responses.
pub fn poll(&mut self) -> bool {
let mut changed = self.synchronize_documents();
let mut ready = Vec::new();
for (owner, plugin) in &mut self.plugins {
if plugin.is_async() {
let result = plugin.poll();
if match &result {
Ok(events) => !events.is_empty(),
Err(_) => true,
} {
ready.push((owner.clone(), result));
}
}
}
for (owner, events) in ready {
let events = match events {
Ok(events) => events,
Err(error) => {
self.retire(&owner, &error);
changed = true;
continue;
}
};
for event in events {
let result = match event {
PluginEvent::Response { id, effects } => {
self.apply_response(&owner, id, effects)
}
PluginEvent::Update(update) => self.apply_update(&owner, update),
};
match result {
Ok(applied) => changed |= applied,
Err(error) => {
self.retire(&owner, &error);
changed = true;
break;
}
}
}
}
changed |= self.synchronize_documents();
changed
}
}
#[cfg(test)]
@@ -200,6 +523,65 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
"#,
);
}
fn asynchronous(&self, file: Option<PathBuf>) -> EditorSession {
self.file(
"generic.rb",
r#"
Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true
log_mutex = Mutex.new
record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } }
Vamp.on_document { |event| record.call(event) }
Vamp.command("fixture_barrier") { |editor, args| editor.message args }
Vamp.command("fixture_replace") { |editor, args| editor.replace args }
Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) }
["fixture_publish", "fixture_hold"].each do |name|
Vamp.command(name) do |editor, args|
payload = JSON.parse(args)
effects = payload.fetch("effects")
effects.each do |effect|
next unless ["diagnostics", "navigate"].include?(effect["type"])
effect["buffer_id"] ||= editor.buffer_id
effect["revision"] ||= editor.revision
end
record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision,
"row" => editor.row, "column" => editor.column, "request_id" => editor.request_id})
if name == "fixture_hold"
Thread.new do
gate = payload.fetch("gate")
sleep 0.002 until File.exist?(gate)
Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id)
end
else
Vamp.publish(effects, request_id: editor.request_id)
end
end
end
"#,
);
let log = self.0.join("events.jsonl");
self.file("events.jsonl", "");
let config = self.file(
"async-config.rb",
&format!(
"Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}",
serde_json::to_string(&log.to_string_lossy()).unwrap(),
),
);
EditorSession::open(
file,
vec!["text".into(), "diagnostics".into()],
Some(&config),
)
.unwrap()
}
fn events(&self) -> Vec<serde_json::Value> {
fs::read_to_string(self.0.join("events.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
}
impl Drop for Fixture {
@@ -1842,4 +2224,442 @@ end
"x"
);
}
fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
// Process-I/O timeout, not a date-dependent assertion.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
session.poll();
if ready(session) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"plugin did not settle: {}",
session.editor().message()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn barrier(session: &mut EditorSession, message: &str) {
session.execute(&format!("fixture_barrier {message}"));
drain_until(session, |session| session.editor().message() == message);
}
fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value {
serde_json::json!({
"type": "diagnostics", "buffer_id": id, "revision": revision,
"items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1},
"severity": "warning", "message": message}]
})
}
fn publish(session: &mut EditorSession, effects: Vec<serde_json::Value>) {
session.execute(&format!(
"fixture_publish {}",
serde_json::json!({"effects": effects})
));
}
fn hold(session: &mut EditorSession, gate: &Path, effects: Vec<serde_json::Value>) {
session.execute(&format!(
"fixture_hold {}",
serde_json::json!({"gate": gate, "effects": effects})
));
}
fn release(session: &mut EditorSession, gate: &Path) {
fs::write(gate, "").unwrap();
let delivered = format!("delivered {}", gate.display());
drain_until(session, |session| session.editor().message() == delivered);
}
#[test]
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
let fixture = Fixture::new();
let mut session = fixture.asynchronous(None);
barrier(&mut session, "initial");
let initial = fixture.events();
assert_eq!(initial[0]["event"], "open");
assert_eq!(initial[0]["document"]["id"], 1);
assert_eq!(initial[0]["document"]["revision"], 0);
assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null);
assert_eq!(initial[0]["document"]["text"], "");
assert_eq!(
initial[0]["document"]["root"],
session.editor().project_root().to_string_lossy().as_ref()
);
session.handle(Input::Char('i'));
for input in [
Input::Char('😀'),
Input::Char('e'),
Input::Char('\u{301}'),
Input::Enter,
] {
session.handle(input);
}
session.handle(Input::Escape);
let revision = session.editor().text_revision();
session.handle(Input::Char('h'));
session.handle(Input::Char('l'));
let renamed = fixture.0.join("saved unicode 😀.txt");
session.execute(&format!("w {}", renamed.display()));
session.execute("w");
session.execute(&format!("w {}", fixture.0.join("missing/failed").display()));
barrier(&mut session, "saved");
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(
session.editor().documents().next().unwrap().save_revision,
2
);
assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n");
let events = fixture.events();
assert_eq!(
events
.iter()
.filter(|event| event["event"] == "change")
.count(),
revision as usize
);
let kinds: Vec<_> = events
.iter()
.filter_map(|event| event["event"].as_str())
.collect();
assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]);
let reopened = events
.iter()
.find(|event| event["event"] == "open" && !event["document"]["path"].is_null())
.unwrap();
assert_eq!(reopened["document"]["revision"], revision);
assert_eq!(reopened["document"]["text"], "😀e\u{301}\n");
let target = fixture.file("target.txt", "target");
session.execute(&format!("edit {}", target.display()));
session.handle(Input::Char('x'));
session.execute("buffer 1");
let documents: Vec<_> = session.editor().documents().collect();
assert_eq!(
documents
.iter()
.map(|document| document.id)
.collect::<Vec<_>>(),
[1, 2]
);
assert_eq!(documents[1].lines, ["arget"]);
assert_eq!(documents[1].revision, 1);
assert_eq!(documents[1].save_revision, 0);
session.execute("bdelete! 2");
barrier(&mut session, "closed");
let events = fixture.events();
let closed = events
.iter()
.find(|event| event["event"] == "close" && event["document"]["id"] == 2)
.unwrap();
assert_eq!(closed["document"]["text"], "arget");
assert_eq!(closed["document"]["revision"], 1);
session.execute("q!");
barrier(&mut session, "quit flushed");
assert!(session.editor().should_quit());
let events = fixture.events();
let final_close = events
.iter()
.rev()
.find(|event| event["event"] == "close")
.unwrap();
assert_eq!(final_close["document"]["id"], 1);
assert_eq!(
final_close["document"]["path"],
renamed.to_string_lossy().as_ref()
);
}
#[test]
fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "abc");
let mut session = fixture.asynchronous(Some(file));
let gate = fixture.0.join("annotations-ready");
hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
release(&mut session, &gate);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
assert_eq!(
session
.editor()
.diagnostic_at_cursor()
.map(|diagnostic| diagnostic.message.as_str()),
None
);
assert_eq!(session.editor().diagnostics().count(), 1);
session.handle(Input::Char('y'));
assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["abc"]);
let current = session.editor().text_revision();
let mut stale = diagnostic_effect(1, 0, "stale");
stale["items"][0]["end"]["row"] = serde_json::json!(999);
publish(
&mut session,
vec![
stale,
diagnostic_effect(999, current, "closed"),
serde_json::json!({"type":"message","text":"stale checked"}),
],
);
drain_until(&mut session, |session| {
session.editor().message() == "stale checked"
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(session.plugins.contains_key("generic_fixture"));
let target = fixture.file("closed.txt", "closed");
session.execute(&format!("edit {}", target.display()));
let gate = fixture.0.join("closed-ready");
hold(
&mut session,
&gate,
vec![diagnostic_effect(2, 0, "late close")],
);
session.execute("bdelete 2");
release(&mut session, &gate);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(session.plugins.contains_key("generic_fixture"));
}
#[test]
fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(file));
session.execute(&format!("edit {}", target.display()));
session.execute("buffer 1");
publish(
&mut session,
vec![
diagnostic_effect(1, 0, "source"),
diagnostic_effect(2, 0, "hidden"),
],
);
drain_until(&mut session, |session| {
session.editor().diagnostics().count() == 1
});
let other = crate::plugin::Diagnostic {
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
severity: crate::plugin::DiagnosticSeverity::Hint,
message: "other owner".into(),
};
session
.editor
.replace_diagnostics("other", 1, 0, vec![other])
.unwrap();
let mut invalid = diagnostic_effect(1, 0, "bad range");
invalid["items"][0]["end"]["column"] = serde_json::json!(999);
publish(
&mut session,
vec![diagnostic_effect(2, 0, "must not commit"), invalid],
);
drain_until(&mut session, |session| {
!session.plugins.contains_key("generic_fixture")
});
assert!(
session
.editor()
.message()
.contains("Diagnostic position is outside")
);
assert_eq!(session.editor().diagnostics().count(), 1);
assert_eq!(
session.editor().diagnostic_at_cursor().unwrap().message,
"other owner"
);
session.execute("buffer 2");
assert_eq!(session.editor().diagnostics().count(), 0);
session.execute("fixture_publish {}");
assert!(session.editor().message().contains("Unknown command"));
session.handle(Input::Char('x'));
assert_eq!(session.editor().lines(), ["arget"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["target"]);
}
fn navigation_effect(path: &Path) -> serde_json::Value {
serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1})
}
#[test]
fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}");
let mut session = fixture.asynchronous(Some(source));
session.execute(&format!("edit {}", target.display()));
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute("buffer 1");
publish(&mut session, vec![navigation_effect(&target)]);
drain_until(&mut session, |session| session.editor().buffer_id() == 2);
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 0, column: 1 }
);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().buffer_count(), 2);
assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}");
let gate = fixture.0.join("missing-ready");
hold(
&mut session,
&gate,
vec![navigation_effect(&fixture.0.join("missing"))],
);
fs::write(&gate, "").unwrap();
drain_until(&mut session, |session| {
session
.requests
.get("generic_fixture")
.is_some_and(|requests| requests.pending.is_empty())
&& session.editor().message().starts_with("delivered ")
});
assert_eq!(session.editor().buffer_id(), 2);
assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]);
assert!(session.plugins.contains_key("generic_fixture"));
// A standalone failure message remains visible when no later plugin message overwrites it.
publish(
&mut session,
vec![navigation_effect(&fixture.0.join("missing"))],
);
drain_until(&mut session, |session| {
session.editor().message().starts_with("Navigation failed:")
});
assert_eq!(session.editor().buffer_id(), 2);
assert_eq!(session.editor().buffer_count(), 2);
assert!(session.plugins.contains_key("generic_fixture"));
}
#[test]
fn async_navigation_drops_superseded_changed_or_inactive_source_replies() {
for guard in ["superseded", "changed", "inactive", "closed"] {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(source));
let gate = fixture.0.join("navigation-ready");
hold(&mut session, &gate, vec![navigation_effect(&target)]);
match guard {
"superseded" => barrier(&mut session, "new request"),
"changed" => {
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
}
"inactive" => {
let other = fixture.file("other.txt", "other");
session.execute(&format!("edit {}", other.display()));
}
"closed" => session.execute("bdelete 1"),
_ => unreachable!(),
}
let active = session.editor().buffer_id();
let text = session.editor().context().text;
release(&mut session, &gate);
assert_eq!(session.editor().buffer_id(), active, "{guard}");
assert_eq!(session.editor().context().text, text, "{guard}");
assert!(session.plugins.contains_key("generic_fixture"), "{guard}");
assert!(
!session
.editor()
.buffers()
.any(|buffer| buffer.path == Some(target.as_path())),
"{guard}"
);
let invokes: Vec<_> = fixture
.events()
.into_iter()
.filter(|event| event["invoke"] == "fixture_hold")
.collect();
assert_eq!(invokes.len(), 1);
assert_eq!(invokes[0]["buffer_id"], 1);
assert_eq!(invokes[0]["revision"], 0);
assert_eq!(invokes[0]["row"], 1);
assert_eq!(invokes[0]["column"], 1);
}
}
#[test]
fn stale_async_text_response_and_dead_host_never_break_native_typing() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "source");
let mut session = fixture.asynchronous(Some(file));
session.execute("fixture_replace BAD");
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
drain_until(&mut session, |session| {
session
.requests
.get("generic_fixture")
.is_some_and(|requests| requests.pending.is_empty())
});
assert_eq!(session.editor().lines(), ["xsource"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
session.handle(Input::Char('y'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["source"]);
let revision = session.editor().text_revision();
publish(
&mut session,
vec![diagnostic_effect(1, revision, "before crash")],
);
drain_until(&mut session, |session| {
session.editor().diagnostics().count() == 1
});
session.execute("fixture_crash");
session.handle(Input::Char('i'));
session.handle(Input::Char('z'));
drain_until(&mut session, |session| {
!session.plugins.contains_key("generic_fixture")
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
session.handle(Input::Char('q'));
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["source"]);
assert!(!session.editor().should_quit());
session.execute("plugins");
assert!(session.editor().message().contains("core [core]"));
}
#[test]
fn async_navigation_invokes_once_at_primary_with_multiple_carets() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "abc\nabc");
let target = fixture.file("target.txt", "target");
let mut session = fixture.asynchronous(Some(source));
session.handle(Input::Char('l'));
session.handle(Input::Ctrl('e'));
assert_eq!(session.editor().selection_count(), 2);
assert_eq!(
session.editor().cursor(),
crate::editor::Cursor { row: 1, column: 1 }
);
let gate = fixture.0.join("primary-ready");
hold(&mut session, &gate, vec![navigation_effect(&target)]);
release(&mut session, &gate);
assert_eq!(session.editor().path(), Some(target.as_path()));
assert_eq!(session.editor().selection_count(), 1);
let invokes: Vec<_> = fixture
.events()
.into_iter()
.filter(|event| event["invoke"] == "fixture_hold")
.collect();
assert_eq!(invokes.len(), 1);
assert_eq!(invokes[0]["row"], 2);
assert_eq!(invokes[0]["column"], 2);
assert_eq!(invokes[0]["revision"], 0);
}
}
+493 -4
View File
@@ -1,9 +1,11 @@
use std::{
borrow::Cow,
collections::BTreeMap,
fmt::Write as _,
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, Mutex},
time::Duration,
};
use crossterm::{
@@ -29,11 +31,12 @@ use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Input, Mode, Palette},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{Diagnostic, DiagnosticSeverity},
session::EditorSession,
syntax::{SyntaxCache, SyntaxKind},
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
const TAB_WIDTH: usize = 4;
type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
@@ -136,15 +139,28 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
Terminal::new(CrosstermBackend::new(io::stdout()))?;
let mut viewports = BufferViewports::default();
let mut redraw = true;
while !session.editor().should_quit() {
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
redraw |= session.poll();
if redraw {
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
redraw = false;
}
if !event::poll(Duration::from_millis(25))? {
continue;
}
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) {
session.handle(input);
redraw = true;
}
}
Event::Paste(text) => session.handle(Input::Paste(text)),
Event::Paste(text) => {
session.handle(Input::Paste(text));
redraw = true;
}
Event::Resize(_, _) => redraw = true,
_ => {}
}
}
@@ -220,6 +236,23 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
visible
}
fn diagnostic_severity(severity: DiagnosticSeverity) -> (usize, Color, &'static str) {
match severity {
DiagnosticSeverity::Error => (0, Color::Red, "E"),
DiagnosticSeverity::Warning => (1, Color::Yellow, "W"),
DiagnosticSeverity::Information => (2, Color::Cyan, "I"),
DiagnosticSeverity::Hint => (3, Color::DarkGray, "H"),
}
}
fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
diagnostic.start.row <= row
&& row <= diagnostic.end.row
&& (row < diagnostic.end.row
|| diagnostic.end.column > 0
|| diagnostic.start == diagnostic.end)
}
fn draw(
frame: &mut Frame,
editor: &Editor,
@@ -284,6 +317,15 @@ fn draw(
} else {
Style::default().fg(Color::DarkGray)
};
let style = match editor
.diagnostics()
.filter(|diagnostic| diagnostic_on_row(diagnostic, row))
.map(|diagnostic| diagnostic_severity(diagnostic.severity))
.min_by_key(|(severity, _, _)| *severity)
{
Some((_, color, _)) => style.fg(color),
None => style,
};
let number = if row == cursor.row {
row + 1
} else {
@@ -345,6 +387,76 @@ fn draw(
column = end;
}
}
for severity in [
DiagnosticSeverity::Hint,
DiagnosticSeverity::Information,
DiagnosticSeverity::Warning,
DiagnosticSeverity::Error,
] {
let style = Style::default()
.underline_color(diagnostic_severity(severity).1)
.add_modifier(Modifier::UNDERLINED);
for diagnostic in editor
.diagnostics()
.filter(|item| item.severity == severity)
{
let first = diagnostic.start.row.max(*top);
let last = diagnostic.end.row.min(top.saturating_add(height - 1));
for row in first..=last {
if !diagnostic_on_row(diagnostic, row) {
continue;
}
let start = if row == diagnostic.start.row {
diagnostic.start.column
} else {
0
};
let end = if row == diagnostic.end.row {
diagnostic
.end
.column
.saturating_add(usize::from(diagnostic.start == diagnostic.end))
} else {
usize::MAX
};
let line = &editor.lines()[row];
let y = viewport.y + (row - *top) as u16;
let right = left.saturating_add(width);
let mut column = 0;
let mut grapheme_count = 0;
for (index, grapheme) in line.graphemes(true).enumerate() {
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let next = column + cells;
if index >= start
&& index < end
&& (grapheme == "\t" || (column >= *left && next <= right))
{
for cell in column.max(*left)..next.min(right) {
frame.buffer_mut()[(viewport.x + (cell - *left) as u16, y)]
.set_style(style);
}
}
column = next;
grapheme_count = index + 1;
if column >= right {
break;
}
}
if start <= grapheme_count
&& end > grapheme_count
&& column >= *left
&& column < right
{
frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)]
.set_style(style);
}
}
}
}
if let Some(found) = editor.search_match()
&& found.start.row >= *top
&& found.start.row < top.saturating_add(height)
@@ -419,6 +531,15 @@ fn draw(
)
.unwrap();
}
let mut diagnostic_counts = [0; 4];
for diagnostic in editor.diagnostics() {
diagnostic_counts[diagnostic_severity(diagnostic.severity).0] += 1;
}
for (label, count) in ["E", "W", "I", "H"].into_iter().zip(diagnostic_counts) {
if count > 0 {
write!(status, " {label}:{count}").unwrap();
}
}
write!(
status,
" {}{} {}:{}",
@@ -462,7 +583,18 @@ fn draw(
prompt_area.y,
));
} else {
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() {
let (_, _, label) = diagnostic_severity(diagnostic.severity);
let diagnostic = display_text(&diagnostic.message);
Cow::Owned(if editor.message().is_empty() {
format!("{label}: {diagnostic}")
} else {
format!("{} | {label}: {diagnostic}", editor.message())
})
} else {
Cow::Borrowed(editor.message())
};
frame.render_widget(Paragraph::new(message), prompt_area);
if height > 0 && width > 0 {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
frame.set_cursor_position((
@@ -845,6 +977,339 @@ mod tests {
.join("\n")
}
fn annotation(
start: (usize, usize),
end: (usize, usize),
severity: DiagnosticSeverity,
message: &str,
) -> Diagnostic {
Diagnostic {
start: crate::plugin::TextPosition {
row: start.0,
column: start.1,
},
end: crate::plugin::TextPosition {
row: end.0,
column: end.1,
},
severity,
message: message.into(),
}
}
#[test]
fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() {
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![
annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"),
annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"),
],
)
.unwrap();
let mut syntax = SyntaxCache::new();
for (offset, width, caret, underlines) in [
(
2,
5,
4,
vec![
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Red),
],
),
(5, 2, 4, vec![None, Some(Color::Red)]),
(7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]),
(8, 2, 6, vec![None, None]),
(
0,
5,
2,
vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None],
),
] {
editor.handle(Input::Home);
for _ in 0..caret {
editor.handle(Input::Right);
}
let area = Rect::new(3, 2, width + 2, 4);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 8),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, offset);
let rendered = terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
.unwrap()
.buffer
.clone();
assert_eq!(left, offset);
for (cell, underline) in underlines.into_iter().enumerate() {
let position = (area.x + 2 + cell as u16, area.y);
assert_eq!(
rendered[position].modifier.contains(Modifier::UNDERLINED),
underline.is_some(),
"left={offset}, cell={cell}",
);
if let Some(color) = underline {
assert_eq!(rendered[position].underline_color, color);
assert_eq!(rendered[position].fg, Color::DarkGray);
}
assert_eq!(rendered[position].bg, Color::Reset);
}
assert_eq!(rendered[(area.x, area.y)].fg, Color::Red);
assert_eq!(
terminal.backend().buffer()[(0, 0)].modifier,
Modifier::empty()
);
assert_eq!(editor.lines(), &[source]);
}
}
#[test]
fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"abc\n\nxy\nlast".into(),
)])
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![
annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"),
annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"),
annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"),
annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"),
],
)
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Right);
editor.handle(Input::Right);
editor.set_message("write failed: permission denied");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let buffer = terminal.backend().buffer();
for (position, color) in [
((3, 0), Color::Yellow),
((4, 0), Color::Red),
((5, 0), Color::Yellow),
((2, 1), Color::Yellow),
((2, 2), Color::Yellow),
((3, 2), Color::Yellow),
((4, 2), Color::Yellow),
((2, 3), Color::DarkGray),
((6, 3), Color::Cyan),
] {
assert!(buffer[position].modifier.contains(Modifier::UNDERLINED));
assert_eq!(buffer[position].underline_color, color);
}
for (row, color) in [
(0, Color::Red),
(1, Color::Yellow),
(2, Color::Yellow),
(3, Color::Cyan),
] {
assert_eq!(buffer[(0, row)].fg, color);
}
assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED));
let text = screen_text(&terminal);
assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}");
assert!(
text.contains("write failed: permission denied | E: point"),
"{text}"
);
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 0),
);
editor.handle(Input::Char('i'));
editor.handle(Input::Char('!'));
assert_eq!(editor.mode(), Mode::Insert);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.diagnostics().count(), 0);
assert!(!screen_text(&terminal).contains("E:1"));
assert!(
!terminal.backend().buffer()[(4, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() {
let directory = Directory::new();
let source = directory.file("source.txt", "broken TARGET");
let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40));
let target = directory.file("target.py", &target_text);
directory.file(
"semantic.rb",
r#"
Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true
work = Queue.new
Vamp.on_document { |event| work << ["document", event] }
Vamp.command "fixture_definition" do |editor, _|
raise "No target reference" unless editor.text.include?("TARGET")
work << ["definition", editor.buffer_id, editor.revision, editor.request_id]
end
Thread.new do
documents = {}
loop do
kind, *args = work.pop
if kind == "document"
event = args[0]
document = event.fetch("document")
if event.fetch("event") == "close"
documents.delete(document.fetch("id"))
next
end
documents[document.fetch("id")] = document
next unless ["open", "change"].include?(event.fetch("event"))
items = []
document.fetch("text").split("\n", -1).each_with_index do |line, row|
graphemes = line.each_grapheme_cluster.to_a
graphemes.each_cons(6).with_index do |token, column|
next unless token.join == "broken"
items << {start: {row: row, column: column},
end: {row: row, column: column + 6},
severity: "error", message: "Unresolved token"}
end
end
Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"),
revision: document.fetch("revision"), items: items}])
else
target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") }
raise "Target document is not open" unless target
lines = target.fetch("text").split("\n", -1)
row = lines.index { |line| line.include?("TARGET") }
raise "Target definition missing" unless row
column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" }
Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1],
path: target.fetch("path"), row: row, column: column}],
request_id: args[2])
end
end
end
"#,
);
let config = directory.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n",
serde_json::to_string(&target.to_string_lossy()).unwrap(),
),
);
let mut session = EditorSession::open(
Some(source),
CAPABILITIES
.iter()
.map(|capability| (*capability).into())
.collect(),
Some(&config),
)
.unwrap();
let source_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
fn poll_until(
session: &mut EditorSession,
terminal: &mut Terminal<ratatui::backend::TestBackend>,
viewports: &mut BufferViewports,
ready: impl Fn(&Editor) -> bool,
) {
for _ in 0..2_000 {
if session.poll() {
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
}
if ready(session.editor()) {
return;
}
std::thread::sleep(Duration::from_millis(1));
}
panic!(
"async fixture did not update editor: {}",
session.editor().message()
);
}
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
assert_eq!(session.editor().mode(), Mode::Insert);
assert_eq!(session.editor().lines(), &["broken TARGET!"]);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.diagnostics().count() == 1
});
assert!(screen_text(&terminal).contains("E:1"));
assert!(
terminal.backend().buffer()[(2, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
assert_eq!(session.editor().mode(), Mode::Insert);
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["broken TARGET"]);
assert_eq!(session.editor().diagnostics().count(), 0);
session.execute(&format!("edit {}", target.display()));
let target_id = session.editor().buffer_id();
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute(&format!("buffer {source_id}"));
session.execute("fixture_definition");
session.handle(Input::Char('l'));
assert_eq!(session.editor().buffer_id(), source_id);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.buffer_id() == target_id
});
assert_eq!(session.editor().buffer_count(), 2);
assert!(session.editor().dirty());
assert_eq!(session.editor().lines()[0], "!# line");
assert_eq!(session.editor().cursor().row, 15);
assert_eq!(session.editor().cursor().column, 40);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(viewports.offsets[&target_id], (12, 26));
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"T"
);
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].fg,
Color::DarkGray
);
assert_eq!(fs::read_to_string(target).unwrap(), target_text);
}
#[test]
fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() {
let directory = Directory::new();
@@ -1029,6 +1494,19 @@ mod tests {
vec![],
)
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation(
(0, 2),
(2, 6),
DiagnosticSeverity::Error,
"overlays",
)],
)
.unwrap();
editor.handle(Input::Char('/'));
for ch in "word".chars() {
editor.handle(Input::Char(ch));
@@ -1044,6 +1522,11 @@ mod tests {
for x in 4..8 {
assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black);
assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow);
assert!(
terminal.backend().buffer()[(x, y)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
editor.handle(Input::Char('v'));
editor.handle(Input::Char('e'));
@@ -1066,6 +1549,7 @@ mod tests {
assert_eq!(buffer[(x, y)].fg, Color::White);
assert_eq!(buffer[(x, y)].bg, Color::Blue);
}
assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED));
}
assert_eq!(buffer[(2, y)].fg, Color::DarkGray);
}
@@ -1077,6 +1561,11 @@ mod tests {
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black);
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
assert!(
buffer[(x, caret.row as u16)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
}