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, } 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, capabilities: Vec, config: Option<&Path>, ) -> io::Result { 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) { 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" ); } }