refactor: centralize editor plugin lifecycle ownership
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
pub mod editor;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
pub mod tui;
|
||||
|
||||
+5
-55
@@ -1,10 +1,6 @@
|
||||
use std::{collections::BTreeMap, env, path::PathBuf};
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use vamp::{
|
||||
editor::Editor,
|
||||
ruby::{Config, RubyPlugin, load_config},
|
||||
tui,
|
||||
};
|
||||
use vamp::{session::EditorSession, tui};
|
||||
|
||||
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
|
||||
@@ -69,59 +65,13 @@ fn run() -> Result<(), String> {
|
||||
let Some(options) = options()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut editor = Editor::open(
|
||||
let mut session = EditorSession::open(
|
||||
options.file,
|
||||
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
|
||||
options.config.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("cannot open file: {error}"))?;
|
||||
let mut notices = Vec::new();
|
||||
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}"))
|
||||
tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
+350
@@ -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
@@ -20,7 +20,10 @@ use ratatui::{
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
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"];
|
||||
const TAB_WIDTH: usize = 4;
|
||||
@@ -52,9 +55,9 @@ impl Drop for TerminalSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frontend only translates input and renders backend state.
|
||||
/// Plugin dispatch is supplied by the executable, not the terminal adapter.
|
||||
pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation)) -> io::Result<()> {
|
||||
/// Renders session state and translates terminal input; the session owns plugin
|
||||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
let previous_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
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>> =
|
||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let (mut top, mut left) = (0, 0);
|
||||
while !editor.should_quit() {
|
||||
terminal.draw(|frame| draw(frame, editor, &mut top, &mut left))?;
|
||||
while !session.editor().should_quit() {
|
||||
terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?;
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if let Some(input) = translate(key)
|
||||
&& let Some(invocation) = editor.handle(input)
|
||||
{
|
||||
invoke(editor, invocation);
|
||||
if let Some(input) = translate(key) {
|
||||
session.handle(input);
|
||||
}
|
||||
}
|
||||
// Bracketed paste must never execute pasted Ex commands.
|
||||
Event::Paste(text) if editor.mode() == Mode::Insert => {
|
||||
if let Err(error) = editor.apply(vec![Effect::Insert(text)]) {
|
||||
editor.set_message(error);
|
||||
}
|
||||
Event::Paste(text) if session.editor().mode() == Mode::Insert => {
|
||||
session.apply(vec![Effect::Insert(text)]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user