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
+24
View File
@@ -0,0 +1,24 @@
# vamp
A modal editor whose features are independent of the frontend used to present them.
## Language
**Editor session**:
An active editing environment containing a buffer, its modal state, and the plugins available to that environment.
_Avoid_: plugin manager (when referring to the whole editing environment)
**Feature**:
A named set of editor commands with declared frontend compatibility requirements.
**Plugin**:
An extension that contributes a feature to an editor session. A failed plugin can become unavailable without ending the session.
**Core feature**:
A protected feature that provides essential editing behavior and cannot be replaced or disabled by an extension.
**Frontend**:
The presentation and input experience through which a user interacts with an editor session.
**Capability**:
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
+9 -6
View File
@@ -29,12 +29,15 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma
## Module seams ## Module seams
One crate, three modules, no workspace or frontend-specific types in the editing backend: One crate, four modules, no workspace or frontend-specific types in the editing backend:
- `editor`: owns buffer, cursor, modes, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its interface accepts `Input`, exposes state, and returns an `Invocation` when a plugin command is selected. - `editor`: owns buffer, cursor, modes, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `tui`: translates terminal events to `Input`, renders state, and advertises `text` and `styled_text` capabilities. Another frontend can drive the same backend; the backend does not import terminal libraries. - `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.
- `ruby`: loads configuration and runs one Ruby subprocess per plugin. It returns feature manifests and edit effects; it does not own the buffer or terminal. - `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback or host storage.
- The executable wires the modules together and disables a faulted plugin without changing core editing availability. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `apply` supports frontend-originated effects and reports validation errors; plugin effects are applied internally so a rejected result also retires its owner. The TUI retains its existing Insert-only paste policy and undo behavior.
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities. Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities.
@@ -95,4 +98,4 @@ cargo fmt --check
ruby -c src/ruby_host.rb ruby -c src/ruby_host.rb
``` ```
Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. The executable was also exercised in PTY sessions for editing/saving, plugin crash and hang recovery, bracketed paste, scrolling, resize, and terminal restoration. Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, bracketed paste, undo/redo, scrolling, resize, and terminal restoration.
+1
View File
@@ -1,3 +1,4 @@
pub mod editor; pub mod editor;
pub mod ruby; pub mod ruby;
pub mod session;
pub mod tui; pub mod tui;
+5 -55
View File
@@ -1,10 +1,6 @@
use std::{collections::BTreeMap, env, path::PathBuf}; use std::{env, path::PathBuf};
use vamp::{ use vamp::{session::EditorSession, tui};
editor::Editor,
ruby::{Config, RubyPlugin, load_config},
tui,
};
const HELP: &str = "vamp — modal editor with isolated Ruby plugins const HELP: &str = "vamp — modal editor with isolated Ruby plugins
@@ -69,59 +65,13 @@ fn run() -> Result<(), String> {
let Some(options) = options()? else { let Some(options) = options()? else {
return Ok(()); return Ok(());
}; };
let mut editor = Editor::open( let mut session = EditorSession::open(
options.file, options.file,
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(), tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
options.config.as_deref(),
) )
.map_err(|error| format!("cannot open file: {error}"))?; .map_err(|error| format!("cannot open file: {error}"))?;
let mut notices = Vec::new(); tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))
let config = match options.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(" | "));
}
tui::run(&mut editor, |editor, invocation| {
let result = match plugins.get_mut(&invocation.plugin) {
Some(plugin) => plugin
.invoke(&invocation.command, &invocation.args, &editor.context())
.and_then(|effects| editor.apply(effects)),
None => Err("plugin host is unavailable".into()),
};
if let Err(error) = result {
if let Some(mut plugin) = plugins.remove(&invocation.plugin) {
plugin.disable();
}
editor.disable_plugin(&invocation.plugin, &error);
}
})
.map_err(|error| format!("terminal error: {error}"))
} }
fn main() { fn main() {
+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"
);
}
}
+13 -14
View File
@@ -20,7 +20,10 @@ use ratatui::{
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::editor::{Editor, Effect, Input, Invocation, Mode}; use crate::{
editor::{Editor, Effect, Input, Mode},
session::EditorSession,
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text"]; pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
const TAB_WIDTH: usize = 4; const TAB_WIDTH: usize = 4;
@@ -52,9 +55,9 @@ impl Drop for TerminalSession {
} }
} }
/// The frontend only translates input and renders backend state. /// Renders session state and translates terminal input; the session owns plugin
/// Plugin dispatch is supplied by the executable, not the terminal adapter. /// execution and retirement, so the adapter needs no lifecycle callback.
pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation)) -> io::Result<()> { pub fn run(session: &mut EditorSession) -> io::Result<()> {
let previous_hook = std::panic::take_hook(); let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| { std::panic::set_hook(Box::new(move |info| {
restore_terminal(); restore_terminal();
@@ -64,21 +67,17 @@ pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation))
let mut terminal: Terminal<CrosstermBackend<Stdout>> = let mut terminal: Terminal<CrosstermBackend<Stdout>> =
Terminal::new(CrosstermBackend::new(io::stdout()))?; Terminal::new(CrosstermBackend::new(io::stdout()))?;
let (mut top, mut left) = (0, 0); let (mut top, mut left) = (0, 0);
while !editor.should_quit() { while !session.editor().should_quit() {
terminal.draw(|frame| draw(frame, editor, &mut top, &mut left))?; terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?;
match event::read()? { match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => { Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) if let Some(input) = translate(key) {
&& let Some(invocation) = editor.handle(input) session.handle(input);
{
invoke(editor, invocation);
} }
} }
// Bracketed paste must never execute pasted Ex commands. // Bracketed paste must never execute pasted Ex commands.
Event::Paste(text) if editor.mode() == Mode::Insert => { Event::Paste(text) if session.editor().mode() == Mode::Insert => {
if let Err(error) = editor.apply(vec![Effect::Insert(text)]) { session.apply(vec![Effect::Insert(text)]);
editor.set_message(error);
}
} }
_ => {} _ => {}
} }