refactor: centralize editor plugin lifecycle ownership

This commit is contained in:
Valdemar Lorenzen
2026-10-05 14:22:32 +02:00
parent 396ffc4f3a
commit 73b15f5134
6 changed files with 402 additions and 75 deletions
+350
View File
@@ -0,0 +1,350 @@
use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
};
use crate::{
editor::{Editor, Effect, Input, Invocation},
ruby::{Config, RubyPlugin, load_config},
};
/// 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>,
}
impl EditorSession {
/// File errors fail opening; config and plugin errors leave core editing
/// available and are reported in the editor's message.
pub fn open(
file: Option<PathBuf>,
capabilities: Vec<String>,
config: Option<&Path>,
) -> io::Result<Self> {
let mut editor = Editor::open(file, capabilities)?;
let mut notices = Vec::new();
let config = match config {
Some(path) => load_config(path).unwrap_or_else(|error| {
notices.push(format!("config disabled: {error}"));
Config::default()
}),
None => Config::default(),
};
let mut plugins = BTreeMap::new();
for path in config.plugins {
match RubyPlugin::start(&path) {
Ok((plugin, feature)) => {
let name = feature.name.clone();
match editor.register(feature) {
Ok(()) => {
plugins.insert(name, plugin);
}
Err(error) => notices.push(format!("{}: {error}", path.display())),
}
}
Err(error) => notices.push(format!("{}: {error}", path.display())),
}
}
for (key, command) in config.bindings {
if let Err(error) = editor.bind(key, command) {
notices.push(format!("binding {key:?}: {error}"));
}
}
if notices.is_empty() {
editor.set_message("i: insert • Esc: normal • :w save • :q quit • :plugins");
} else {
editor.set_message(notices.join(" | "));
}
Ok(Self { editor, plugins })
}
pub fn editor(&self) -> &Editor {
&self.editor
}
pub fn handle(&mut self, input: Input) {
if let Some(invocation) = self.editor.handle(input) {
self.dispatch(invocation);
}
}
pub fn execute(&mut self, command: &str) {
if let Some(invocation) = self.editor.execute(command) {
self.dispatch(invocation);
}
}
/// Applies frontend effects, such as the TUI's existing Insert-mode paste.
/// Plugin results are applied internally by dispatch, with owner retirement
/// on rejection; frontend effects have no plugin owner to retire.
pub fn apply(&mut self, effects: Vec<Effect>) {
if let Err(error) = self.editor.apply(effects) {
self.editor.set_message(error);
}
}
fn dispatch(&mut self, invocation: Invocation) {
let result = match self.plugins.get_mut(&invocation.plugin) {
Some(plugin) => plugin
.invoke(
&invocation.command,
&invocation.args,
&self.editor.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);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-session-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, text).unwrap();
path
}
fn open(&self, config: &str) -> EditorSession {
let config = self.file("config.rb", config);
EditorSession::open(
Some(self.0.join("buffer.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap()
}
fn healthy(&self) {
self.file(
"healthy.rb",
r#"
Vamp.plugin "healthy", requires: ["text"]
Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
"#,
);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"graphics.rb",
r#"
Vamp.plugin "graphics", requires: ["graphics"]
Vamp.command("draw") { |editor, args| editor.insert "incompatible" }
"#,
);
fixture.file(
"collision.rb",
r#"
Vamp.plugin "collision", requires: ["text"]
Vamp.command("leaked") { |editor, args| editor.insert "collision" }
Vamp.command("q") { |editor, args| editor.insert "override" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "graphics.rb"
config.plugin "collision.rb"
config.plugin "missing.rb"
config.plugin "healthy.rb"
config.bind "g", "healthy"
end
"#,
);
assert!(
session
.editor()
.message()
.contains("unavailable capability: graphics")
);
assert!(
session
.editor()
.message()
.contains("Command already registered: q")
);
for command in ["draw", "leaked"] {
session.execute(command);
assert_eq!(
session.editor().message(),
format!("Unknown command: {command}")
);
assert_eq!(session.editor().lines(), &[String::new()]);
}
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &["healthy "]);
session.execute("w");
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"healthy "
);
session.execute("q");
assert!(session.editor().should_quit());
}
#[test]
fn rejected_effects_discard_entire_transaction_and_retire_commands_and_bindings() {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"invalid.rb",
r#"
Vamp.plugin "invalid", requires: ["text"]
Vamp.command "invalid" do |editor, args|
editor.insert "partial"
editor.message "\e"
end
Vamp.command("sibling") { |editor, args| editor.insert "must be retired too" }
"#,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "invalid.rb"
config.plugin "healthy.rb"
config.bind "g", "invalid"
end
"#,
);
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &[String::new()]);
assert!(!session.editor().dirty());
assert!(
session
.editor()
.message()
.contains("Plugin invalid disabled: Unsupported control character")
);
let message = session.editor().message().to_owned();
session.handle(Input::Char('g'));
assert_eq!(session.editor().message(), message);
for command in ["invalid", "sibling"] {
session.execute(command);
assert_eq!(
session.editor().message(),
format!("Unknown command: {command}")
);
}
session.execute("healthy survivor");
assert_eq!(session.editor().lines(), &["healthy survivor"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[String::new()]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().message(), "Nothing to undo");
}
#[test]
fn callback_crash_and_timeout_retire_plugin_without_losing_core_editing() {
for failure in ["raise 'callback failed'", "Process.exit!(7)", "sleep 30"] {
let fixture = Fixture::new();
fixture.healthy();
fixture.file(
"failing.rb",
&format!(
r#"
Vamp.plugin "failing", requires: ["text"]
Vamp.command "fail" do |editor, args|
editor.insert "never commit"
{failure}
end
"#
),
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "failing.rb"
config.plugin "healthy.rb"
config.bind "g", "fail"
end
"#,
);
session.execute("fail");
assert_eq!(session.editor().lines(), &[String::new()]);
assert!(
session
.editor()
.message()
.contains("Plugin failing disabled:")
);
session.execute("fail");
assert_eq!(session.editor().message(), "Unknown command: fail");
session.handle(Input::Char('g'));
assert_eq!(session.editor().lines(), &[String::new()]);
session.execute("healthy survivor");
session.execute("w");
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("q");
assert!(!session.editor().should_quit());
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"xhealthy survivor"
);
}
}
#[test]
fn broken_config_falls_back_to_core_without_failing_the_session() {
let fixture = Fixture::new();
let mut session = fixture.open("raise 'broken config'");
assert!(
session
.editor()
.message()
.contains("config disabled: Ruby config: RuntimeError: broken config")
);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"x"
);
}
}