use std::{ collections::BTreeMap, io, path::{Path, PathBuf}, }; use crate::{ editor::{Editor, 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 binding in config.bindings { if let Err(error) = editor.bind(&binding.mode, &binding.key, binding.command) { notices.push(format!( "binding {} {:?}: {error}", binding.mode, binding.key )); } } if notices.is_empty() { editor.set_message("Ready • --no-config uses default keybindings"); } 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); } } 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 buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() { let fixture = Fixture::new(); let first = fixture.file("buffer.txt", "alpha"); let second = fixture.file("second.txt", "beta"); fixture.file( "shared.rb", r##" Vamp.plugin "shared", requires: ["text"] calls = 0 Vamp.command("stamp") do |editor, _| calls += 1 editor.insert "#{calls}:#{File.basename(editor.path)}:" end Vamp.command("fail") { |_, _| raise "shared host failure" } "##, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "shared.rb" config.bind "g", "stamp" end "#, ); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); session.execute(&format!("edit {}", second.display())); assert_eq!(session.editor().buffer_id(), 2); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); session.execute("bdelete"); assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().buffer_count(), 2); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["beta"]); session.execute("q"); assert!(!session.editor().should_quit()); session.execute("wq"); assert!(!session.editor().should_quit()); assert_eq!(fs::read_to_string(&first).unwrap(), "alpha"); assert_eq!(fs::read_to_string(&second).unwrap(), "beta"); session.execute("buffer 1"); assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha"]); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); session.execute("w"); assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha"); session.execute("bnext"); assert_eq!(session.editor().lines(), &["beta"]); session.execute("fail"); assert!(session.editor().message().contains("disabled")); session.execute("buffer 1"); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); session.execute("q"); assert!(session.editor().should_quit()); } #[test] fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() { let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); fixture.file( "palette.rb", r#" Vamp.plugin "palette_fixture", requires: ["text"] Vamp.command("palette_insert") { |editor, args| editor.insert args } Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "palette.rb" config.bind "ctrl+p", nil config.bind "ctrl+f", config.core.palette end "#, ); session.handle(Input::Ctrl('p')); assert!(session.editor().palette().is_none()); session.handle(Input::Ctrl('f')); for ch in "palette_insert".chars() { session.handle(Input::Char(ch)); } assert_eq!( session.editor().palette().unwrap().entries[0].owner, "palette_fixture" ); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Command); assert_eq!(session.editor().command_line(), "palette_insert "); assert_eq!(session.editor().lines(), &["base"]); for ch in "界!".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); assert_eq!(session.editor().lines(), &["界!base"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["base"]); session.handle(Input::Ctrl('f')); for ch in "palette_fail".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); session.handle(Input::Enter); assert!(session.editor().message().contains("disabled")); session.handle(Input::Ctrl('f')); assert!( !session .editor() .palette() .unwrap() .entries .iter() .any(|entry| entry.owner == "palette_fixture") ); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["base"]); } #[test] fn configured_selection_actions_edit_together_and_invoke_plugins_once() { let fixture = Fixture::new(); fixture.file("buffer.txt", " alpha\nb\n gamma"); fixture.file( "once.rb", r#" Vamp.plugin "once", requires: ["text"] Vamp.command("once") { |editor, _| editor.insert "!" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "once.rb" config.bind "r", config.core.select_lines, mode: config.modes.visual config.bind "z", config.core.clone_below config.bind "f", "once" end "#, ); session.handle(Input::Char('v')); session.handle(Input::Char('j')); session.handle(Input::Char('j')); session.handle(Input::Char('r')); assert_eq!(session.editor().selection_count(), 3); session.handle(Input::Char('A')); session.handle(Input::Char('?')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); assert_eq!(session.editor().selection_count(), 3); session.handle(Input::Char('f')); assert_eq!(session.editor().selection_count(), 1); assert_eq!( session .editor() .lines() .iter() .map(|line| line.matches('!').count()) .sum::(), 1 ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); assert_eq!(session.editor().selection_count(), 3); } #[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 configured_bindings_preserve_modal_editing_history_and_literal_paste() { let fixture = Fixture::new(); fixture.healthy(); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "healthy.rb" config.bind "n", config.core.insert_first_nonblank config.bind "i", nil config.bind "ctrl+r", config.core.redo config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "esc", nil, mode: config.modes.insert config.bind "!", config.core.newline, mode: config.modes.insert config.bind "shift+x", nil, mode: config.modes.insert config.bind ":", nil config.bind "space", config.core.command config.bind "enter", nil, mode: config.modes.command config.bind "ctrl+x", config.core.submit, mode: config.modes.command config.bind "ctrl+c", config.core.normal, mode: config.modes.command config.bind "ctrl+h", config.core.backspace, mode: config.modes.command config.bind "g", config.core.undo config.bind "g", "healthy" config.bind "n", config.core.newline config.bind "unknown", config.core.undo config.bind "ctrl+f", "healthy", mode: config.modes.insert end "#, ); session.handle(Input::Char('i')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('n')); for ch in "界Xab!".chars() { session.handle(Input::Char(ch)); } assert_eq!(session.editor().lines(), &["界ab", ""]); session.handle(Input::Escape); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); session.handle(Input::Paste("X!".into())); assert_eq!(session.editor().lines(), &["界ab", "X!"]); session.handle(Input::Ctrl('C')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["界ab", ""]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[String::new()]); session.handle(Input::Ctrl('r')); session.handle(Input::Ctrl('r')); assert_eq!(session.editor().lines(), &["界ab", "X!"]); session.handle(Input::Char(':')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char(' ')); for ch in "w界".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); assert_eq!(session.editor().command_line(), "w"); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Command); session.handle(Input::Ctrl('x')); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "界ab\nX!" ); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(session.editor().command_line().is_empty()); assert!(!session.editor().should_quit()); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["healthy "]); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('x')); assert!(!session.editor().should_quit()); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Char('!')); session.handle(Input::Ctrl('x')); assert!(session.editor().should_quit()); } #[test] fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abc DEF tail"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "c", nil, mode: config.modes.visual config.bind "ctrl+r", config.core.change, mode: config.modes.visual config.bind "ctrl+v", config.core.select config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual end "#, ); session.handle(Input::Char('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('f')); session.handle(Input::Char('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); assert_eq!(session.editor().lines(), &["abc DEF tail"]); session.handle(Input::Ctrl('r')); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); assert_eq!(session.editor().cursor().column, 0); assert_eq!(session.editor().lines(), &[" DEF tail"]); for ch in "cZ".chars() { session.handle(Input::Char(ch)); } session.execute("w"); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "cZ DEF tail" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["abc DEF tail"]); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["ababcc DEF tail"]); } #[test] fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() { let fixture = Fixture::new(); fixture.file("buffer.txt", "one two"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "v", nil config.bind "ctrl+v", config.core.select config.bind "ctrl+c", config.core.copy, mode: config.modes.visual config.bind "ctrl+x", config.core.cut, mode: config.modes.visual config.bind "ctrl+g", config.core.normal, mode: config.modes.visual config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual config.bind "ctrl+b", config.core.paste_before config.bind "ctrl+p", config.core.paste_after end "#, ); session.handle(Input::Char('v')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('v')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); session.handle(Input::Ctrl('f')); assert_eq!(session.editor().selections()[0].end.column, 3); session.handle(Input::Ctrl('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('p')); assert_eq!(session.editor().lines(), &["oonene two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["one two"]); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('f')); session.handle(Input::Ctrl('x')); assert_eq!(session.editor().lines(), &[" two"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('b')); assert_eq!(session.editor().lines(), &["one two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["one two"]); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('g')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(session.editor().selections().is_empty()); } #[test] fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() { let fixture = Fixture::new(); fixture.file("buffer.txt", "foo...bar baz"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "w", nil config.bind "ctrl+f", config.core.word_next config.bind "ctrl+p", config.core.word_previous config.bind "ctrl+e", config.core.word_end config.bind "ctrl+a", config.core.big_word_next config.bind "ctrl+z", config.core.big_word_previous config.bind "ctrl+t", config.core.big_word_end config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert end "#, ); session.handle(Input::Char('w')); assert_eq!(session.editor().cursor().column, 0); for (key, column) in [ ('f', 3), ('e', 5), ('f', 6), ('p', 3), ('z', 0), ('a', 10), ('t', 12), ('z', 10), ('z', 0), ] { session.handle(Input::Ctrl(key)); assert_eq!(session.editor().cursor().column, column, "{key}"); } assert!(!session.editor().dirty()); session.handle(Input::Char('i')); for ch in "wbeWBE".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('b')); assert_eq!(session.editor().cursor().column, 0); session.handle(Input::Char('X')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["foo...bar baz"]); assert!(!session.editor().dirty()); } #[test] fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { let fixture = Fixture::new(); fixture.file("buffer.txt", "cat\ncat\ncat"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "ctrl+f", config.core.search config.bind "ctrl+n", config.core.search_next config.bind "ctrl+p", config.core.search_previous config.bind "ctrl+x", config.core.submit, mode: config.modes.search config.bind "ctrl+c", config.core.normal, mode: config.modes.search config.bind "ctrl+h", config.core.backspace, mode: config.modes.search config.bind "enter", nil, mode: config.modes.search config.bind "/", nil end "#, ); session.handle(Input::Char('/')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('f')); assert_eq!(session.editor().mode(), crate::editor::Mode::Search); for ch in "cat界".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Search); session.handle(Input::Ctrl('x')); assert_eq!(session.editor().cursor().row, 1); session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 2); session.handle(Input::Ctrl('p')); assert_eq!(session.editor().cursor().row, 1); session.handle(Input::Ctrl('f')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('c')); session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 2); assert!(!session.editor().dirty()); assert!(!session.editor().should_quit()); } #[test] fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() { let fixture = Fixture::new(); for (expression, error) in [ ("config.core.reod", "NoMethodError"), ("config.modes.insret", "NoMethodError"), (r#"config.bind "n", "core.insert""#, "TypeError"), (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), (r#"config.bind "n", config.modes.insert"#, "TypeError"), ( r#"config.bind "n", nil, mode: config.core.insert"#, "TypeError", ), (r#"config.core.redo.name = "core.undo""#, "FrozenError"), ( r#"config.core.redo.name.replace("core.undo")"#, "FrozenError", ), (r#"config.modes.insert.name = "normal""#, "FrozenError"), ( r#"config.modes.insert.name.replace("normal")"#, "FrozenError", ), ] { let mut session = fixture.open(&format!( "Vamp.configure do |config|\n config.bind \"i\", nil\n {expression}\nend" )); assert!(session.editor().message().contains(error), "{expression}"); session.handle(Input::Char('i')); session.handle(Input::Char('界')); session.handle(Input::Escape); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(session.editor().lines(), &["界"], "{expression}"); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[String::new()], "{expression}"); } } #[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" ); } }