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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+138
-18
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user