use crate::{editor::Input, session::EditorSession}; use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = env::temp_dir().join(format!( "vamp-ruby-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir(&path).unwrap(); Self(path) } fn file(&self, name: &str, text: &str) -> PathBuf { let path = self.0.join(name); std::fs::write(&path, text).unwrap(); path } fn plugin(&self, text: &str) -> (RubyPlugin, Feature) { self.plugin_with_options(text, &serde_json::json!({})) } fn plugin_with_options( &self, text: &str, options: &serde_json::Value, ) -> (RubyPlugin, Feature) { RubyPlugin::start(&self.file("plugin.rb", text), options).unwrap_or_else(|error| { panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}") }) } } impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn context() -> Context { Context { buffer_id: 7, revision: 11, root: "/project".into(), text: "ฮฑ\nhello".into(), row: 2, column: 3, mode: "NORMAL".into(), path: Some("buffer.txt".into()), } } #[test] fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() { let fixture = Fixture::new(); let absolute = fixture.0.join("absolute-reviews"); for (value, expected) in [ ( r#""relative-reviews""#.to_owned(), Some(fixture.0.join("relative-reviews")), ), (serde_json::to_string(&absolute).unwrap(), Some(absolute)), ("nil".to_owned(), None), ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), ); assert_eq!(load_config(&path).unwrap().review_directory, expected); let session = EditorSession::open(None, vec![], Some(&path)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); if let Some(directory) = expected { assert!( !directory.exists(), "startup created {}", directory.display() ); } } let path = fixture.file("config.rb", "Vamp.configure { |_| }"); assert_eq!(load_config(&path).unwrap().review_directory, None); for value in [ "7", "false", "[]", "{}", ":reviews", r#""""#, r#""bad\0path""#, ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!(error.contains("review_directory"), "{value}: {error}"); } } #[test] fn review_directory_and_review_symbol_wire_fields_are_required_and_validated() { let mut frame = serde_json::json!({ "version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [], "review_directory": null, "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {}, "scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true }); for value in [serde_json::Value::Null, serde_json::json!("reviews")] { frame["review_directory"] = value; assert!(serde_json::from_value::(frame.clone()).is_ok()); } for value in [ serde_json::json!(""), serde_json::json!("bad\0path"), serde_json::json!(7), serde_json::json!(false), serde_json::json!([]), serde_json::json!({}), ] { frame["review_directory"] = value; assert!(serde_json::from_value::(frame.clone()).is_err()); } frame["review_directory"] = serde_json::Value::Null; for field in [ "pull_request_review_requested_symbol", "pull_request_approved_symbol", ] { for value in ["", "๐Ÿ‘ฉโ€๐Ÿš€ R", "ok"] { frame[field] = serde_json::json!(value); assert!(serde_json::from_value::(frame.clone()).is_ok()); } for value in [ serde_json::json!(null), serde_json::json!(false), serde_json::json!(7), serde_json::json!([]), serde_json::json!({}), serde_json::json!("\0"), serde_json::json!("\n"), serde_json::json!("\t"), serde_json::json!("\u{7f}"), serde_json::json!("\u{85}"), ] { frame[field] = value; assert!(serde_json::from_value::(frame.clone()).is_err()); } frame.as_object_mut().unwrap().remove(field); assert!(serde_json::from_value::(frame.clone()).is_err()); frame[field] = serde_json::json!(""); } frame.as_object_mut().unwrap().remove("review_directory"); assert!(serde_json::from_value::(frame).is_err()); } #[test] fn pull_request_symbols_load_defaults_custom_and_empty_and_reject_invalid_ruby_values() { let fixture = Fixture::new(); let path = fixture.file("config.rb", "Vamp.configure { |_| }"); let config = load_config(&path).unwrap(); assert_eq!(config.pull_request_review_requested_symbol, "๐Ÿ™"); assert_eq!(config.pull_request_approved_symbol, "๐Ÿ‘"); for symbol in ["", "๐Ÿ‘ฉโ€๐Ÿš€ R", "ok"] { let value = serde_json::to_string(symbol).unwrap(); let path = fixture.file("config.rb", &format!( "Vamp.configure {{ |c| c.pull_request_review_requested_symbol = {value}; c.pull_request_approved_symbol = {value} }}" )); let config = load_config(&path).unwrap(); assert_eq!(config.pull_request_review_requested_symbol, symbol); assert_eq!(config.pull_request_approved_symbol, symbol); } for field in [ "pull_request_review_requested_symbol", "pull_request_approved_symbol", ] { for value in [ "nil", "false", "7", "[]", "{}", ":ok", r#""\n""#, r#""\t""#, r#""\0""#, r#""\u007f""#, r#""\u0085""#, ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| c.{field} = {value} }}"), ); assert!( load_config(&path).unwrap_err().contains(field), "{field}: {value}" ); } } } #[test] fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() { let fixture = Fixture::new(); let config = fixture.file( "config.rb", r#" Vamp.configure do |c| c.tab_spaces = 4 c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5) c.language_tab_spaces["cpp"] = 3 end "#, ); let widest = " ".repeat(255); for (name, prefix, inserted) in [ ("default.rs", "", "\t"), ("buffer.rs", "", " "), ("buffer.rb", "", " "), ("Gemfile", "", " "), ("script", "#!/usr/bin/env ruby\n", " "), ("Makefile", "", "\t"), ("plain.txt", "", " "), ("buffer.json", "", " "), ("buffer.yaml", "", widest.as_str()), ("buffer.cpp", "", " "), ("buffer.tsx", "", " "), ] { let source = format!("{prefix}ฮฑx\nฮฒy"); let path = fixture.file(name, &source); let config = (name != "default.rs").then_some(config.as_path()); let mut session = EditorSession::open(Some(path), vec![], config).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); if !prefix.is_empty() { session.handle(Input::Char('j')); } session.handle(Input::Ctrl('e')); for input in [ Input::Char('l'), Input::Char('i'), Input::Char('\t'), Input::Ctrl('i'), Input::Escape, ] { session.handle(input); } let expected = format!("{prefix}ฮฑ{inserted}{inserted}x\nฮฒ{inserted}{inserted}y"); assert_eq!(session.editor().context().text, expected, "{name}"); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, source, "{name}"); session.handle(Input::Char('U')); assert_eq!(session.editor().context().text, expected, "{name}"); session.handle(Input::Char('u')); session.handle(Input::Char('.')); assert_eq!(session.editor().context().text, expected, "{name}"); session.handle(Input::Char('i')); session.handle(Input::End); session.handle(Input::Paste("\t".into())); assert_eq!( session.editor().context().text, format!("{prefix}ฮฑ{inserted}{inserted}x\t\nฮฒ{inserted}{inserted}y\t"), "{name}" ); } } #[test] fn tab_spaces_reject_invalid_widths_and_language_maps() { let fixture = Fixture::new(); for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] { for assignment in [ format!("c.tab_spaces = {value}"), format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#), format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#), format!(r#"c.language_tab_spaces["ruby"] = {value}"#), ] { let config = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| {assignment} }}"), ); let error = load_config(&config).unwrap_err(); assert!( error.contains("tab_spaces must be nil or an Integer"), "{assignment}: {error}" ); } } for value in [ "nil", "[]", r#"{ "" => 2 }"#, r#"{ :ruby => 2 }"#, r#"{ "\n" => 2 }"#, ] { let config = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"), ); assert!(load_config(&config).is_err(), "{value}"); } } #[test] fn language_tab_space_mutations_reject_invalid_keys_before_serialization() { let fixture = Fixture::new(); for key in [r#""""#, ":ruby", r#""\n""#, "7"] { for assignment in [ format!("c.language_tab_spaces.merge!({key} => 2)"), format!("c.language_tab_spaces[{key}] = 2"), ] { let config = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| {assignment} }}"), ); let error = load_config(&config).unwrap_err(); assert!(error.contains("Tab language"), "{assignment}: {error}"); } } } #[test] fn actual_ruby_dsl_context_queued_effects_and_output_isolation() { let fixture = Fixture::new(); let (mut plugin, feature) = fixture.plugin(r#" puts "startup noise" STDOUT.write "direct startup noise\n" warn "warning" Vamp.plugin "greeting", requires: ["text"] Vamp.command "greet" do |editor, args| puts "invocation noise" STDOUT.write "direct invocation noise\n" raise "bad context" unless editor.text == "ฮฑ\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt" raise "bad source" unless editor.buffer_id == 7 && editor.revision == 11 && editor.root == "/project" && editor.request_id > 0 editor.insert "Hello #{args}!" editor.replace editor.text editor.message "Greeting inserted" editor.label :battery, "็•Œ e\u0301 ๐Ÿ‘ฉโ€๐Ÿš€\t\n\e" editor.label "battery", "" end "#); assert_eq!(feature.name, "greeting"); assert_eq!(feature.required_capabilities, ["text"]); assert_eq!(feature.commands, ["greet"]); assert!(!feature.core); assert!(!plugin.is_async()); for _ in 0..2 { let effects = plugin.invoke("greet", "friend", &context()).unwrap(); assert!( matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }] if text == "Hello friend!" && replacement == "ฮฑ\nhello" && message == "Greeting inserted" && name == "battery" && cleared_name == "battery" && label == "็•Œ e\u{301} ๐Ÿ‘ฉโ€๐Ÿš€\t\n\u{1b}" && cleared.is_empty()) ); } plugin.disable(); plugin.disable(); assert!( plugin .invoke("greet", "", &context()) .unwrap_err() .contains("disabled") ); assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); } #[test] fn generated_core_actions_execute_through_ruby_configuration() { use crate::{ editor::{Input, Mode}, session::EditorSession, }; let fixture = Fixture::new(); let config = fixture.file( "config.rb", r#"Vamp.configure do |config| config.clipboard_mode = "separate" config.bind "Q", config.core.quit config.bind "F", config.core.force_quit config.bind "W", config.core.save config.bind "X", config.core.save_and_quit config.bind "E", config.core.edit config.bind "B", config.core.buffer config.bind "P", config.core.plugins config.bind "N", config.core.buffer_next config.bind "V", config.core.buffer_previous config.bind "D", config.core.buffer_delete config.bind "Z", config.core.buffer_force_delete config.bind "R", config.core.diff config.bind "ctrl+t", config.core.diff.close config.bind "H", config.core.word_wrap.toggle raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && config.core.diff.close.frozen? && config.core.diff.name.frozen? && config.core.diff.close.name.frozen? end"#, ); let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); assert!(!review.editor().word_wrap()); review.handle(Input::Char('H')); assert!(review.editor().word_wrap()); review.execute("word_wrap.toggle unexpected"); assert!(review.editor().word_wrap()); review.execute("word_wrap.toggle"); assert!(!review.editor().word_wrap()); review.handle(Input::Char('R')); review.handle(Input::Escape); assert!(review.editor().diff().is_none()); review.handle(Input::Char('R')); assert!(review.editor().diff().is_some()); review.handle(Input::Ctrl('t')); assert!(review.editor().diff().is_none()); for (key, dirty, quit, saved) in [ ('Q', false, true, false), ('Q', true, false, false), ('F', true, true, false), ('W', true, false, true), ('X', true, true, true), ] { let buffer = fixture.file("buffer.txt", "base"); let mut session = EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); if dirty { for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { session.handle(input); } } session.handle(Input::Char(key)); assert_eq!(session.editor().should_quit(), quit, "{key}"); assert_eq!( std::fs::read_to_string(&buffer).unwrap(), if saved { "!base" } else { "base" }, "{key}" ); assert_eq!(session.editor().dirty(), dirty && !saved, "{key}"); } let buffer = fixture.file("buffer.txt", "base"); let second = fixture.file("second.txt", "second"); let mut session = EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); session.handle(Input::Char('E')); assert_eq!(session.editor().mode(), Mode::Command); assert_eq!(session.editor().command_line(), "edit "); for ch in second.to_string_lossy().chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); assert_eq!(session.editor().lines(), &["second"]); session.handle(Input::Char('B')); assert_eq!(session.editor().mode(), Mode::Command); assert_eq!(session.editor().command_line(), "buffer "); session.handle(Input::Char('1')); session.handle(Input::Enter); assert_eq!(session.editor().buffer_id(), 1); assert_eq!(session.editor().lines(), &["base"]); session.handle(Input::Char('P')); assert!(session.editor().message().contains("text")); session.handle(Input::Char('N')); assert_eq!(session.editor().buffer_id(), 2); session.handle(Input::Char('V')); assert_eq!(session.editor().buffer_id(), 1); session.handle(Input::Char('D')); assert!(!session.editor().has_buffer(1)); assert_eq!(session.editor().buffer_id(), 2); for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { session.handle(input); } session.handle(Input::Char('D')); assert!(session.editor().has_buffer(2)); session.handle(Input::Char('Z')); assert!(!session.editor().has_buffer(2)); assert_eq!(std::fs::read_to_string(second).unwrap(), "second"); } #[test] fn raw_builtin_aliases_are_not_external_binding_commands() { let fixture = Fixture::new(); for name in [ "quit", "save", "buffer_next", "q", "w", "bnext", "q!", "wq", "bprev", "bdelete", "bdelete!", "core.quit", ] { let config = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"), ); assert!(load_config(&config).is_err(), "{name}"); } } #[test] fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() { let fixture = Fixture::new(); for name in [ "quit", "save", "buffer_next", "q", "w", "bnext", "q!", "wq", "bprev", "bdelete", "bdelete!", ] { let path = fixture.file( "plugin.rb", &format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"), ); assert!( RubyPlugin::start(&path, &serde_json::json!({})).is_err(), "plugin shadowed built-in command {name}" ); } } #[test] fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() { let fixture = Fixture::new(); let config = fixture.file( "config.rb", r#" puts "configuration noise" Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" end "#, ); let loaded = load_config(&config).unwrap(); assert_eq!( loaded.plugins[0].path, fixture.0.join("plugins/greeting.rb") ); assert_eq!(loaded.plugins[0].options, serde_json::json!({})); let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }"); assert!( load_config(&invalid) .unwrap_err() .contains("must be a String") ); for target in [ "7", "{}", "config.core.lsp", "config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", "config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus", "config.core.diff.tap { |action| action.name = 'core.delete' }", "config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus", "config.core.diff.close.tap { |action| action.name = 'core.delete' }", ] { let invalid = fixture.file( "invalid_action.rb", &format!("Vamp.configure {{ |config| config.bind 'K', {target} }}"), ); assert!(load_config(&invalid).is_err(), "{target}"); } let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true"); assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); let invalid = fixture.file("syntax.rb", "this is not valid ruby ("); assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); } #[test] fn config_leader_defaults_custom_keys_validation_and_required_wire_field() { let fixture = Fixture::new(); let defaults = Config::default(); assert_eq!(defaults.leader, "space"); assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty()); assert!(defaults.lsp.is_empty()); let path = fixture.file("config.rb", "Vamp.configure { |_| }"); assert_eq!(load_config(&path).unwrap().leader, "space"); for (source, key) in [ (r#"Vamp.configure { |config| config.leader = "," }"#, ","), (r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"), ] { let path = fixture.file("config.rb", source); assert_eq!(load_config(&path).unwrap().leader, key); } for (value, error) in [ ("7", "leader key must be a String"), ("nil", "leader key must be a String"), (r#""""#, "leader key must not be empty"), ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.leader = {value} }}"), ); assert!(load_config(&path).unwrap_err().contains(error), "{value}"); } for frame in [ r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"๐Ÿ™","pull_request_approved_symbol":"๐Ÿ‘","resolve_history_layout":"columns"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"๐Ÿ™","pull_request_approved_symbol":"๐Ÿ‘","resolve_history_layout":"columns"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"๐Ÿ™","pull_request_approved_symbol":"๐Ÿ‘","resolve_history_layout":"columns"}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } } #[test] fn git_gutter_config_modes_default_and_rejection() { use crate::gutter::GutterStyle; let fixture = Fixture::new(); let path = fixture.file("config.rb", "Vamp.configure { |_config| }"); assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar); for (name, expected) in [ ("bar", GutterStyle::Bar), ("symbols", GutterStyle::Symbols), ("line_numbers", GutterStyle::LineNumbers), ("none", GutterStyle::None), ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"), ); assert_eq!(load_config(&path).unwrap().git_gutter, expected); } for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"), ); assert!(load_config(&path).is_err(), "{value}"); } } #[test] fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() { let fixture = Fixture::new(); let config = fixture.file("config.rb", "Vamp.configure { |_config| }"); assert!(load_config(&config).unwrap().git_blame); let config = fixture.file("config.rb", r#" Vamp.configure do |config| config.git_blame = false config.bind "G", config.core.git_blame.toggle raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen? end "#); let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); assert!(!session.editor().git_blame()); session.handle(Input::Char('G')); assert!(session.editor().git_blame()); session.execute("git_blame.toggle extra"); assert!(session.editor().git_blame()); session.execute("git_blame.toggle"); assert!(!session.editor().git_blame()); session.handle(Input::Ctrl('p')); for ch in "git blame".chars() { session.handle(Input::Char(ch)); } assert_eq!(session.editor().palette().unwrap().entries.len(), 1); session.handle(Input::Enter); assert!(session.editor().git_blame()); for value in ["nil", "0", r#""false""#, "[]", "{}"] { let config = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.git_blame = {value} }}"), ); assert!( load_config(&config) .unwrap_err() .contains("git_blame must be true or false") ); } let mut frame = serde_json::json!({ "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], "review_directory": null, "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true }); assert!(serde_json::from_value::(frame.clone()).is_ok()); for value in [ serde_json::Value::Null, serde_json::json!(0), serde_json::json!("false"), ] { frame["git_blame"] = value; assert!(serde_json::from_value::(frame.clone()).is_err()); } frame.as_object_mut().unwrap().remove("git_blame"); assert!(serde_json::from_value::(frame).is_err()); } #[test] fn trailing_whitespace_configuration_rejects_non_booleans() { let fixture = Fixture::new(); for value in ["nil", "0", "\"true\"", "[]", "{}"] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"), ); assert!( load_config(&path) .unwrap_err() .contains("highlight_trailing_whitespace must be true or false") ); } } #[test] fn whitespace_characters_validate_real_ruby_configuration() { let fixture = Fixture::new(); for value in [ "nil", "false", "[]", r#"{ space: "ยท" }"#, r#"{ " " => 1 }"#, r#"{ "" => "." }"#, r#"{ " " => "." }"#, r#"{ " " => ".." }"#, r#"{ "x" => "." }"#, r#"{ "\u000b" => "." }"#, r#"{ "\r" => "." }"#, r#"{ "\u000c" => "." }"#, r#"{ "\u0085" => "." }"#, r#"{ "\u200b" => "." }"#, r#"{ " " => "\t" }"#, r#"{ " " => "\n" }"#, r#"{ " " => "\u0301" }"#, r#"{ " " => "็•Œ" }"#, ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"), ); assert!(load_config(&path).is_err(), "{value}"); } let path = fixture.file( "config.rb", r#"Vamp.configure { |c| c.whitespace_characters = { " " => "ยท", "\t" => "โ†’", "\n" => "โ†ต", "\u00a0" => "โฝ", "\u3000" => "_" } }"#, ); assert_eq!( load_config(&path).unwrap().whitespace_characters, BTreeMap::from([ (' ', 'ยท'), ('\t', 'โ†’'), ('\n', 'โ†ต'), ('\u{a0}', 'โฝ'), ('\u{3000}', '_'), ]), ); } #[test] fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() { use crate::editor::WhitespaceScope; let fixture = Fixture::new(); let path = fixture.file("config.rb", "Vamp.configure {}"); assert_eq!( load_config(&path).unwrap().whitespace_scope, WhitespaceScope::All ); for (value, expected) in [ ("all", WhitespaceScope::All), ("leading", WhitespaceScope::Leading), ] { let path = fixture.file( "config.rb", &format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#), ); assert_eq!(load_config(&path).unwrap().whitespace_scope, expected); } for value in [ "nil", "false", "0", "[]", "{}", ":all", r#""""#, r#""unknown""#, ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"), ); assert!(load_config(&path).is_err(), "{value}"); let path = fixture.file( "config.rb", &format!( r#"Vamp.configure do |c| c.whitespace_scope = "leading" begin c.whitespace_scope = {value} raise "invalid scope accepted" rescue TypeError, ArgumentError end end"#, ), ); assert_eq!( load_config(&path).unwrap().whitespace_scope, WhitespaceScope::Leading ); } let mut frame = serde_json::json!({ "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], "review_directory": null, "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true }); for value in ["all", "leading"] { frame["whitespace_scope"] = serde_json::json!(value); assert!(serde_json::from_value::(frame.clone()).is_ok()); } for value in [ serde_json::Value::Null, serde_json::json!(false), serde_json::json!(0), serde_json::json!([]), serde_json::json!({}), serde_json::json!(""), serde_json::json!("unknown"), ] { frame["whitespace_scope"] = value; assert!(serde_json::from_value::(frame.clone()).is_err()); } frame.as_object_mut().unwrap().remove("whitespace_scope"); assert!(serde_json::from_value::(frame).is_err()); } #[test] fn working_diff_tracking_rejects_non_boolean_configuration() { let fixture = Fixture::new(); for value in ["nil", "0", r#""false""#, "[]", "{}"] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.diff_follow_working_copy = {value} }}"), ); assert!( load_config(&path) .unwrap_err() .contains("diff_follow_working_copy must be true or false") ); } } #[test] fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() { let fixture = Fixture::new(); let path = fixture.file( "config.rb", r#" Vamp.configure do |config| config.bar.top = {left: [:tabs], center: ["workspace"], right: [:battery], tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""}, battery: {leading_edge: "็•Œ", trailing_edge: ""}} config.bar.bottom = {"left" => [], center: [:workspace]} config.labels = {battery: "bat 38%", "workspace" => "็•Œ e\u0301 ๐Ÿ‘ฉโ€๐Ÿš€\t\n\e"} end "#, ); let loaded = load_config(&path).unwrap(); assert_eq!(loaded.top_bar.left, ["tabs"]); assert_eq!(loaded.top_bar.center, ["workspace"]); assert_eq!(loaded.top_bar.right, ["battery"]); assert_eq!( loaded.top_bar.segment("tabs").title, "T{index}:{path}{modified}" ); assert_eq!(loaded.top_bar.segment("tabs").padding, 0); assert_eq!(loaded.top_bar.segment("battery").leading_edge, "็•Œ"); assert_eq!( loaded.top_bar.segment("workspace"), &crate::chrome::SegmentConfig::default() ); assert!(loaded.bottom_bar.left.is_empty()); assert_eq!(loaded.labels["battery"], "bat 38%"); assert_eq!(loaded.labels["workspace"], "็•Œ e\u{301} ๐Ÿ‘ฉโ€๐Ÿš€\t\n\u{1b}"); } #[test] fn popup_configuration_accepts_ruby_symbol_and_string_anchors_independently() { use crate::chrome::PopupAnchor; let fixture = Fixture::new(); let anchors = [ ("top_left", PopupAnchor::TopLeft), ("top_center", PopupAnchor::TopCenter), ("top_right", PopupAnchor::TopRight), ("center_left", PopupAnchor::CenterLeft), ("center", PopupAnchor::Center), ("center_right", PopupAnchor::CenterRight), ("bottom_left", PopupAnchor::BottomLeft), ("bottom_center", PopupAnchor::BottomCenter), ("bottom_right", PopupAnchor::BottomRight), ]; for (index, (command_name, command_anchor)) in anchors.iter().enumerate() { let (search_name, search_anchor) = anchors[(index + 1) % anchors.len()]; let path = fixture.file( "config.rb", &format!( "Vamp.configure do |config|\n\ config.popup.command = {{anchor: :{command_name}}}\n\ config.popup.search = {{'anchor' => '{search_name}'}}\n\ end" ), ); let loaded = load_config(&path).unwrap(); assert_eq!(loaded.popup.command.anchor, *command_anchor); assert_eq!(loaded.popup.search.anchor, search_anchor); } } #[test] fn popup_configuration_rejects_unknown_options_anchors_callbacks_and_types() { let fixture = Fixture::new(); for (value, expected) in [ ("[]", "must be a Hash"), ("nil", "must be a Hash"), ("7", "must be a Hash"), ("{width: 72}", "unknown popup option"), ("{callback: -> { 'center' }}", "unknown popup option"), ("{anchor: :middle}", "unknown popup anchor"), ("{anchor: 'TopLeft'}", "unknown popup anchor"), ("{anchor: nil}", "must be a String"), ("{anchor: 7}", "must be a String"), ("{anchor: true}", "must be a String"), ("{anchor: []}", "must be a String"), ("{anchor: {}}", "must be a String"), ("{anchor: -> { :center }}", "must be a String"), ( "{anchor: :center, 'anchor' => :top_left}", "duplicate chrome key", ), ("{7 => :center}", "chrome key must be a String"), ] { for mode in ["command", "search"] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.popup.{mode} = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!(error.contains(expected), "{mode}={value}: {error}"); } } let path = fixture.file("config.rb", "Vamp.configure { |config| config.popup = {} }"); assert!(load_config(&path).unwrap_err().contains("undefined method")); } #[test] fn bar_namespace_is_stable_and_accepts_swapped_builtins() { let fixture = Fixture::new(); let path = fixture.file( "config.rb", r#" Vamp.configure do |config| bar = config.bar raise "bar namespace must be stable" unless bar.equal?(config.bar) bar.top = {left: [:mode, :language], right: [], mode: {padding: 0}} config.bar.bottom = {left: [:tabs], right: [], tabs: {title: "{name}{modified}"}} end "#, ); let loaded = load_config(&path).unwrap(); assert_eq!(loaded.top_bar.left, ["mode", "language"]); assert_eq!(loaded.top_bar.segment("mode").padding, 0); assert_eq!(loaded.bottom_bar.left, ["tabs"]); assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}"); for setter in ["bar", "tabline", "statusline"] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.{setter} = {{}} }}"), ); let error = load_config(&path).unwrap_err(); assert!(error.contains("undefined method"), "{setter}: {error}"); } } #[test] fn chrome_configuration_rejects_invalid_types_names_and_templates() { let fixture = Fixture::new(); for (setting, value, expected) in [ ("bar.top", "[]", "must be a Hash"), ("bar.bottom", "[]", "must be a Hash"), ("bar.top", "{left: :tabs}", "must be an Array"), ("bar.top", "{left: [7]}", "must be a String"), ("bar.top", "{left: [:bad_name!]}", "must match"), ( "bar.bottom", "{tabs: {title: '{unknown}'}}", "unknown title placeholder", ), ("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"), ("bar.top", "{language: {padding: 65}}", "between 0 and 64"), ("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"), ("bar.top", "{lsp: {margin: 65}}", "between 0 and 64"), ("bar.bottom", "{lsp: {margin: -1}}", "between 0 and 64"), ("bar.top", "{lsp: {margin: '1'}}", "must be an Integer"), ("bar.bottom", "{lsp: {margin: 1.5}}", "must be an Integer"), ("bar.top", "{lsp: {margin: nil}}", "must be an Integer"), ("bar.top", "{mode: {title: nil}}", "must be a String"), ( "bar.bottom", "{tabs: {color: 'red'}}", "unknown chrome style option", ), ( "bar.top", "{left: [], 'left' => []}", "duplicate chrome key", ), ("labels", "[]", "must be a Hash"), ("labels", "{battery: 7}", "label text must be a String"), ("labels", "{'bad-name' => ''}", "must match"), ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!(error.contains(expected), "{setting}={value}: {error}"); } } #[test] fn label_wire_effects_require_name_and_string_text() { for frame in [ serde_json::json!({"type": "label", "text": ""}), serde_json::json!({"type": "label", "name": 7, "text": ""}), serde_json::json!({"type": "label", "name": "battery"}), serde_json::json!({"type": "label", "name": "battery", "text": null}), serde_json::json!({"type": "label", "name": "battery", "text": 7}), serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}), ] { assert!(serde_json::from_value::(frame.clone()).is_err()); assert!(serde_json::from_value::(frame).is_err()); } } #[test] fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() { let fixture = Fixture::new(); for (context, expand) in [(0, 7), (3, 1), (usize::MAX, usize::MAX)] { let path = fixture.file( "config.rb", &format!( "Vamp.configure {{ |config| config.diff_context_lines = {context}; config.diff_expand_lines = {expand} }}" ), ); let loaded = load_config(&path).unwrap(); assert_eq!( (loaded.diff_context_lines, loaded.diff_expand_lines), (context, expand) ); } let frame = serde_json::json!({ "version": 1, "type": "config", "plugins": [], "lsp": {}, "review_directory": null, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "top_bar": {}, "bottom_bar": {}, "labels": {}, "scroll_distance": 0, "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true }); for setting in ["diff_context_lines", "diff_expand_lines"] { for value in [ "-1", "1.5", "\"3\"", "true", "false", "nil", "18446744073709551616", "0", ] { if setting == "diff_context_lines" && value == "0" { continue; } let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!( error.contains(setting) && error.contains("must be"), "{value}: {error}" ); let mut invalid_frame = frame.clone(); invalid_frame[setting] = serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap(); let error = serde_json::from_value::(invalid_frame) .err() .unwrap(); assert!(error.to_string().contains(setting), "{value}: {error}"); } } } #[test] fn config_scroll_step_lines_controls_counted_scrolling_and_rejects_invalid_values() { let fixture = Fixture::new(); for (value, expected) in [ ("nil", 30), ("1", 3), ("7", 21), ("18446744073709551615", isize::MAX), ] { let path = fixture.file( "config.rb", &format!( "Vamp.configure do |config| config.scroll_step_lines = {value} config.bind \"alt+j\", config.core.scroll_step_down config.bind \"alt+k\", config.core.scroll_step_up end" ), ); let mut session = crate::session::EditorSession::open(None, vec![], Some(&path)).unwrap(); session.handle(crate::editor::Input::Char('3')); session.handle(crate::editor::Input::Alt('j')); assert_eq!( session.editor().take_viewport_scroll(20), expected, "{value}" ); session.handle(crate::editor::Input::Char('3')); session.handle(crate::editor::Input::Alt('k')); let up = if expected == isize::MAX { isize::MIN } else { -expected }; assert_eq!(session.editor().take_viewport_scroll(20), up, "{value}"); } for value in [ "0", "-1", "1.5", "\"3\"", "true", "false", "18446744073709551616", ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.scroll_step_lines = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!( error.contains("scroll_step_lines") && error.contains("must be"), "{value}: {error}" ); } } #[test] fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() { let fixture = Fixture::new(); let frame = serde_json::json!({ "version": 1, "type": "config", "plugins": [], "lsp": {}, "review_directory": null, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true, "scroll_distance": 0 }); for distance in [None, Some(0), Some(7), Some(usize::MAX)] { let value = distance.map_or_else(|| "nil".into(), |value| value.to_string()); let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), ); assert_eq!(load_config(&path).unwrap().scroll_distance, distance); let mut valid_frame = frame.clone(); valid_frame["scroll_distance"] = serde_json::json!(distance); let response = serde_json::from_value::(valid_frame).unwrap(); let Response::Config(config) = response else { panic!("expected a configuration frame"); }; assert_eq!(config.scroll_distance, distance); } for value in [ "-1", "1.5", "\"3\"", "true", "false", "18446744073709551616", ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), ); let error = load_config(&path).unwrap_err(); assert!( error.contains("scroll_distance") && error.contains("must be"), "{value}: {error}" ); let mut invalid_frame = frame.clone(); invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap(); let error = serde_json::from_value::(invalid_frame) .err() .unwrap(); assert!( error.to_string().contains("scroll_distance"), "{value}: {error}" ); } } #[test] fn lsp_configuration_is_disabled_unless_explicitly_enabled() { let fixture = Fixture::new(); for source in [ "Vamp.configure { |_| }", r#"Vamp.configure do |config| config.lsp["rust"] = false config.lsp["python"] = false config.lsp["not-a-preset"] = false end"#, ] { let path = fixture.file("config.rb", source); let config = load_config(&path).unwrap(); assert!(config.lsp.is_empty()); assert!(config.plugins.is_empty()); } } #[test] fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() { let fixture = Fixture::new(); let path = fixture.file( "config.rb", r#"Vamp.configure do |config| config.lsp["rust"] = true config.lsp["python"] = true config.lsp["ruby"] = true end"#, ); let config = load_config(&path).unwrap(); assert_eq!(config.lsp.len(), 4); for (language, command, extension) in [ ("rust", &["rust-analyzer"][..], "rs"), ("ruby", &["solargraph", "stdio"][..], "rb"), ] { let server = config .lsp .iter() .find(|server| server.language_id == language) .unwrap(); assert_eq!(server.command, command); assert_eq!(server.extensions, [extension]); assert_eq!(server.initialization_options, serde_json::json!({})); assert_eq!(server.settings, serde_json::json!({})); } let python: Vec<_> = config .lsp .iter() .filter(|server| server.language_id == "python") .collect(); assert_eq!(python.len(), 2); for (server, command, initialization_options) in [ ( python[0], ["ty", "server"], serde_json::json!({"experimental": {"useUv": "off"}}), ), (python[1], ["ruff", "server"], serde_json::json!({})), ] { assert_eq!(server.command, command); assert_eq!(server.extensions, ["py"]); assert_eq!(server.initialization_options, initialization_options); assert_eq!(server.settings, serde_json::json!({})); } let path = fixture.file( "config.rb", r#"Vamp.configure do |config| config.lsp["rust"] = { extensions: ["rs", "ron"], initialization_options: {cargo: {features: ["ฮฑ"]}}, settings: {"rust-analyzer" => {check: {command: "clippy"}}} } config.lsp["python"] = {command: ["custom-python-server", "--stdio"]} config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]} config.lsp["my-language.c++"] = { command: ["custom server", "--flag", ""], extensions: ["custom", "ู„ุบุฉ"], settings: {nested: {enabled: true, limit: 3, unset: nil}} } end"#, ); let config = load_config(&path).unwrap(); let rust = config .lsp .iter() .find(|server| server.language_id == "rust") .unwrap(); assert_eq!(rust.command, ["rust-analyzer"]); assert_eq!(rust.extensions, ["rs", "ron"]); assert_eq!( rust.initialization_options, serde_json::json!({"cargo": {"features": ["ฮฑ"]}}) ); assert_eq!( rust.settings, serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}}) ); let python = config .lsp .iter() .find(|server| server.language_id == "python") .unwrap(); assert_eq!(python.command, ["custom-python-server", "--stdio"]); assert_eq!(python.extensions, ["py"]); assert_eq!(python.initialization_options, serde_json::json!({})); assert_eq!(python.settings, serde_json::json!({})); assert_eq!( config .lsp .iter() .filter(|server| server.language_id == "python") .count(), 1 ); let ruby = config .lsp .iter() .find(|server| server.language_id == "ruby") .unwrap(); assert_eq!(ruby.command, ["bundle", "exec", "solargraph", "stdio"]); assert_eq!(ruby.extensions, ["rb"]); let custom = config .lsp .iter() .find(|server| server.language_id == "my-language.c++") .unwrap(); assert_eq!(custom.command, ["custom server", "--flag", ""]); assert_eq!(custom.extensions, ["custom", "ู„ุบุฉ"]); assert_eq!(custom.initialization_options, serde_json::json!({})); assert_eq!( custom.settings, serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}}) ); } #[test] fn lsp_python_common_options_and_ordered_server_overrides() { let fixture = Fixture::new(); let path = fixture.file( "config.rb", r#"Vamp.configure do |config| config.lsp["python"] = { extensions: ["py", "pyi"], initialization_options: {diagnosticMode: "workspace"}, settings: {shared: true} } end"#, ); let config = load_config(&path).unwrap(); assert_eq!(config.lsp.len(), 2); for (server, command) in config .lsp .iter() .zip([["ty", "server"], ["ruff", "server"]]) { assert_eq!(server.language_id, "python"); assert_eq!(server.command, command); assert_eq!(server.extensions, ["py", "pyi"]); assert_eq!( server.initialization_options, serde_json::json!({"diagnosticMode": "workspace"}) ); assert_eq!(server.settings, serde_json::json!({"shared": true})); } let path = fixture.file( "config.rb", r#"Vamp.configure do |config| config.lsp["python"] = {servers: [ {command: ["type-server", "--stdio"], initialization_options: {experimental: {useUv: "off"}}, settings: {first: true}}, {command: ["lint-server"], initialization_options: {settings: {lint: {enable: true}}}} ]} config.lsp["custom"] = {extensions: ["custom"], servers: [ {command: ["custom-first"]}, {command: ["custom-second"], settings: {second: true}} ]} end"#, ); let config = load_config(&path).unwrap(); for (language, commands, extensions) in [ ( "python", [&["type-server", "--stdio"][..], &["lint-server"][..]], ["py"], ), ( "custom", [&["custom-first"][..], &["custom-second"][..]], ["custom"], ), ] { let servers: Vec<_> = config .lsp .iter() .filter(|server| server.language_id == language) .collect(); assert_eq!(servers.len(), 2); for (server, command) in servers.iter().zip(commands) { assert_eq!(server.command, command); assert_eq!(server.extensions, extensions); } } let python: Vec<_> = config .lsp .iter() .filter(|server| server.language_id == "python") .collect(); assert_eq!( python[0].initialization_options, serde_json::json!({"experimental": {"useUv": "off"}}) ); assert_eq!(python[0].settings, serde_json::json!({"first": true})); assert_eq!( python[1].initialization_options, serde_json::json!({"settings": {"lint": {"enable": true}}}) ); assert_eq!(python[1].settings, serde_json::json!({})); let custom: Vec<_> = config .lsp .iter() .filter(|server| server.language_id == "custom") .collect(); assert_eq!(custom[0].initialization_options, serde_json::json!({})); assert_eq!(custom[1].settings, serde_json::json!({"second": true})); let path = fixture.file( "config.rb", r#"Vamp.configure { |config| config.lsp["python"] = {settings: {shared: true}} }"#, ); let config = load_config(&path).unwrap(); assert_eq!( config.lsp[0].initialization_options, serde_json::json!({"experimental": {"useUv": "off"}}) ); assert_eq!(config.lsp[1].initialization_options, serde_json::json!({})); } #[test] fn lsp_server_lists_reject_invalid_shapes_and_ambiguous_options_locally() { let fixture = Fixture::new(); for (options, error) in [ ("{servers: nil}", "nonempty array"), ("{servers: {command: ['server']}}", "nonempty array"), ("{servers: []}", "nonempty array"), ( "{servers: [true]}", "servers[0] must be a configuration object", ), ("{servers: [{}]}", "servers[0] requires command argv"), ("{servers: [{command: []}]}", "servers[0] command must be"), ( "{servers: [{command: ['server'], extensions: ['py']}]}", "servers[0] has unknown option", ), ( "{servers: [{command: ['server'], servers: []}]}", "servers[0] has unknown option", ), ( "{servers: [{command: ['server'], initialization_options: []}]}", "servers[0] initialization_options must be", ), ( "{servers: [{command: ['server']}, {command: ['other'], settings: nil}]}", "servers[1] settings must be", ), ( "{servers: [{command: ['server']}], command: ['other']}", "cannot be combined", ), ( "{servers: [{command: ['server']}], initialization_options: {}}", "cannot be combined", ), ( "{servers: [{command: ['server']}], settings: {}}", "cannot be combined", ), ] { let path = fixture.file( "config.rb", &format!("Vamp.configure {{ |config| config.lsp[\"python\"] = {options} }}"), ); let actual = load_config(&path).unwrap_err(); assert!( actual.starts_with("Ruby config: LSP language \"python\""), "{actual}" ); assert!(actual.contains(error), "{options}: {actual}"); } } #[test] fn lsp_server_limit_is_enforced_at_configuration_startup() { let fixture = Fixture::new(); for count in [16, 17] { let path = fixture.file( "config.rb", &format!( "Vamp.configure {{ |config| config.lsp[\"python\"] = {{servers: Array.new({count}) {{ {{command: [\"server\"]}} }} }} }}" ), ); let result = load_config(&path); if count == 16 { assert_eq!(result.unwrap().lsp.len(), 16); } else { assert_eq!( result.unwrap_err(), "Ruby config: LSP configuration supports at most 16 configured servers" ); } } } #[test] fn lsp_configuration_rejects_invalid_keys_values_and_json_options() { let fixture = Fixture::new(); for assignment in [ r#"config.lsp[:rust] = true"#, r#"config.lsp[7] = false"#, r#"config.lsp[""] = false"#, r#"config.lsp["bad\nkey"] = false"#, r#"config.lsp["\xFF".b] = false"#, r#"config.lsp["rust"] = nil"#, r#"config.lsp["rust"] = []"#, r#"config.lsp["rust"] = "yes""#, r#"config.lsp["custom"] = {}"#, r#"config.lsp["custom"] = {command: ["server"]}"#, r#"config.lsp["custom"] = {servers: [{command: ["server"]}]}"#, r#"config.lsp["custom"] = {extensions: ["custom"]}"#, r#"config.lsp["rust"] = {command: "server"}"#, r#"config.lsp["rust"] = {command: []}"#, r#"config.lsp["rust"] = {command: [""]}"#, r#"config.lsp["rust"] = {command: ["server", 7]}"#, r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#, r#"config.lsp["rust"] = {extensions: "rs"}"#, r#"config.lsp["rust"] = {extensions: []}"#, r#"config.lsp["rust"] = {extensions: [""]}"#, r#"config.lsp["rust"] = {extensions: [7]}"#, r#"config.lsp["rust"] = {extensions: [".rs"]}"#, r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#, r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#, r#"config.lsp["rust"] = {extensions: ["r s"]}"#, r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#, r#"config.lsp["rust"] = {initialization_options: []}"#, r#"config.lsp["rust"] = {settings: nil}"#, r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#, r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#, r#"config.lsp["rust"] = {settings: {7 => true}}"#, r#"config.lsp["rust"] = {unknown: true}"#, ] { let path = fixture.file( "invalid.rb", &format!("Vamp.configure {{ |config| {assignment} }}"), ); assert!(load_config(&path).is_err(), "{assignment}"); } let path = fixture.file( "invalid.rb", r#"Vamp.configure { |config| config.lsp["unsupported-language"] = true }"#, ); let error = load_config(&path).unwrap_err(); assert!(!error.contains("Malformed Ruby protocol frame"), "{error}"); assert!( error.contains("\"unsupported-language\"") && error.contains("command") && error.contains("extensions"), "{error}" ); } #[test] fn lsp_wire_configuration_is_resolved_after_decoding() { let mut frame = serde_json::json!({ "version": 1, "type": "config", "plugins": [], "lsp": {}, "review_directory": null, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "git_blame": true, "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, "whitespace_characters": {}, "whitespace_scope": "all", "tab_spaces": null, "language_tab_spaces": {}, "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "resolve_history_layout": "columns", "pull_request_review_requested_symbol": "๐Ÿ™", "pull_request_approved_symbol": "๐Ÿ‘", "diff_follow_working_copy": true }); let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() else { panic!("expected config frame"); }; assert!(crate::lsp_config::resolve(config.lsp).unwrap().is_empty()); frame["lsp"] = serde_json::json!({"rust": true}); let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() else { panic!("expected config frame"); }; assert_eq!( crate::lsp_config::resolve(config.lsp).unwrap()[0].command, ["rust-analyzer"] ); for value in [ serde_json::json!(null), serde_json::json!([]), serde_json::json!({"": false}), serde_json::json!({"bad\u{0085}key": false}), serde_json::json!({"rust": null}), serde_json::json!({"python": []}), serde_json::json!({"unknown": true}), serde_json::json!({"custom": {}}), serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}), serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}), serde_json::json!({"rust": {"initialization_options": []}}), serde_json::json!({"rust": {"settings": "not-an-object"}}), serde_json::json!({"rust": {"unexpected": true}}), serde_json::json!({"python": {"servers": []}}), serde_json::json!({"python": {"servers": [null]}}), serde_json::json!({"python": {"servers": [{}]}}), serde_json::json!({"python": {"servers": [{"command": ["server"], "extensions": ["py"]}]}}), serde_json::json!({"python": {"servers": [{"command": ["server"]}], "settings": {}}}), ] { frame["lsp"] = value; let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() else { panic!("expected config frame"); }; assert!(crate::lsp_config::resolve(config.lsp).is_err(), "{frame}"); } let mut languages: serde_json::Map = (0..14) .map(|index| { ( format!("custom-{index}"), serde_json::json!({"command": ["server"], "extensions": ["custom"]}), ) }) .collect(); languages.insert("python".into(), serde_json::json!(true)); frame["lsp"] = serde_json::Value::Object(languages); let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() else { panic!("expected config frame"); }; assert_eq!(crate::lsp_config::resolve(config.lsp).unwrap().len(), 16); frame["lsp"]["ruby"] = serde_json::json!(true); let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() else { panic!("expected config frame"); }; assert_eq!( crate::lsp_config::resolve(config.lsp).unwrap_err(), "LSP configuration supports at most 16 configured servers" ); frame.as_object_mut().unwrap().remove("lsp"); assert!(serde_json::from_value::(frame).is_err()); } #[test] fn malformed_config_wire_remains_a_protocol_error() { let fixture = Fixture::new(); for mutation in [ "return '['", "frame.delete('lsp')", "frame.delete('top_bar')", "frame.delete('bottom_bar')", "frame.delete('labels')", "frame.delete('popup')", "frame['popup'] = nil", "frame['popup'] = []", "frame['popup'] = {'selector' => {}}", "frame['popup'] = {'command' => nil}", "frame['popup'] = {'search' => 7}", "frame['popup'] = {'command' => {'anchor' => nil}}", "frame['popup'] = {'search' => {'anchor' => 'middle'}}", "frame['popup'] = {'command' => {'width' => 72}}", "frame['top_bar'] = 7", "frame['bottom_bar'] = []", "frame['labels'] = {'battery' => 7}", "frame['bottom_bar'] = {'tabs' => {'title' => '{unknown}'}}", "frame['top_bar'] = {'mode' => {'leading_edge' => \"\\n\"}}", "frame['top_bar'] = {'left' => nil}", "frame['bottom_bar'] = {'left' => ['bad-name']}", "frame['bottom_bar'] = {'tabs' => {'padding' => '1'}}", "frame['top_bar'] = {'language' => {'color' => 'red'}}", "frame['labels'] = {'bad-name' => ''}", "frame['leader'] = 7", "frame['unexpected'] = true", ] { let source = format!( r#" Vamp.configure {{ |config| config.lsp["ruby"] = true }} module JSON class << self alias_method :original_generate, :generate def generate(frame) if frame['type'] == 'config' {mutation} end original_generate(frame) end end end "# ); let path = fixture.file("config.rb", &source); let error = load_config(&path).unwrap_err(); assert!( error.starts_with("Malformed Ruby protocol frame: "), "{mutation}: {error}" ); } } #[test] fn crash_hang_callback_failure_and_fresh_host_recovery() { let fixture = Fixture::new(); for callback in [ "exit! 19", "sleep 10", "editor.insert 'must not escape'; raise 'callback failed'", "editor.insert 7", ] { let (mut plugin, _) = fixture.plugin(&format!( "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, args| {callback} }}" )); let error = plugin.invoke("fail", "", &context()).unwrap_err(); if callback == "sleep 10" { assert!(error.contains("timeout"), "{error}"); } assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); assert!( plugin .invoke("fail", "", &context()) .unwrap_err() .contains("disabled") ); let (mut recovered, _) = fixture.plugin( "Vamp.plugin 'recovered'\nVamp.command('ok') { |editor, args| editor.message 'alive' }", ); assert!( matches!(&recovered.invoke("ok", "", &context()).unwrap()[..], [Effect::Message(message)] if message == "alive") ); } } #[test] fn nonstandard_callback_exceptions_are_reported_without_effects() { let fixture = Fixture::new(); for (callback, expected) in [ ("exit 19", "SystemExit: exit"), ("raise Interrupt, 'interrupted'", "Interrupt: interrupted"), ] { let (mut plugin, _) = fixture.plugin(&format!( "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, _| editor.insert 'must not escape'; {callback} }}" )); let error = plugin.invoke("fail", "", &context()).unwrap_err(); assert!(error.contains(expected), "{error}"); assert!( plugin .invoke("fail", "", &context()) .unwrap_err() .contains("disabled") ); } } #[test] fn startup_and_config_hangs_are_bounded() { let fixture = Fixture::new(); let path = fixture.file("hang.rb", "sleep 10"); assert!( RubyPlugin::start(&path, &serde_json::json!({})) .err() .unwrap() .contains("timeout") ); assert!(load_config(&path).unwrap_err().contains("timeout")); } #[test] fn invalid_protocol_version_shape_effect_and_request_id_shutdown() { let fixture = Fixture::new(); for mutation in [ "frame['version'] = 2", "frame['id'] += 1", "frame['effects'] = [{'type' => 'delete', 'text' => 'x'}]", "frame['effects'] = [{'type' => 'insert', 'text' => 7}]", "frame['unexpected'] = true", ] { let source = format!( r#" Vamp.plugin 'protocol' Vamp.command('ok') {{ |editor, args| editor.insert 'x' }} module JSON class << self alias_method :original_generate, :generate def generate(frame) if frame['type'] == 'result' {mutation} end original_generate(frame) end end end "# ); let (mut plugin, _) = fixture.plugin(&source); assert!(plugin.invoke("ok", "", &context()).is_err(), "{mutation}"); assert!(plugin.host.child.is_none()); } } #[test] fn bounded_frames_and_stalled_writes_shutdown() { let fixture = Fixture::new(); let (mut plugin, _) = fixture.plugin( "Vamp.plugin 'large'\nVamp.command('large') { |editor, args| editor.insert 'x' * (4 * 1024 * 1024 + 1) }"); assert!(plugin.invoke("large", "", &context()).is_err()); assert!(plugin.host.child.is_none()); // Stop Ruby reading the request pipe after it sends the manifest. let (mut plugin, _) = fixture.plugin( r#" Vamp.plugin 'blocked' Vamp.command('ok') { |editor, args| editor.message 'ok' } class IO def gets(*args) sleep 10 end end "#, ); let mut big_context = context(); big_context.text = "x".repeat(2 * 1024 * 1024); assert!( plugin .invoke("ok", "", &big_context) .unwrap_err() .contains("timeout") ); assert!(plugin.host.child.is_none()); let (mut plugin, _) = fixture .plugin("Vamp.plugin 'request'\nVamp.command('ok') { |editor, args| editor.message 'ok' }"); big_context.text = "x".repeat(MAX_FRAME + 1); assert!( plugin .invoke("ok", "", &big_context) .unwrap_err() .contains("4 MiB") ); assert!(plugin.host.child.is_none()); for payload in ["'not json'", "'x' * (4 * 1024 * 1024 + 1)"] { let source = format!( r#" Vamp.plugin 'bad_frame' Vamp.command('ok') {{ |editor, args| editor.message 'ok' }} class IO alias_method :original_write, :write def write(payload) if payload.include?('"type":"result"') original_write({payload}) else original_write(payload) end end end "# ); let (mut plugin, _) = fixture.plugin(&source); assert!(plugin.invoke("ok", "", &context()).is_err()); assert!(plugin.host.child.is_none()); } } #[cfg(unix)] #[test] fn inherited_pipes_do_not_prevent_drop_or_timeout_cleanup() { let fixture = Fixture::new(); let (mut plugin, _) = fixture.plugin( r#" Vamp.plugin 'forked' Vamp.command('hang') do |editor, args| fork { sleep 10 } sleep 10 end "#, ); assert!( plugin .invoke("hang", "", &context()) .unwrap_err() .contains("timeout") ); assert!(plugin.host.reader.is_none() && plugin.host.writer.is_none()); let (plugin, _) = fixture.plugin("Vamp.plugin 'drop'\nfork { sleep 10 }"); drop(plugin); } fn document(event: crate::plugin::DocumentEventKind) -> DocumentEvent { DocumentEvent { event, document: crate::plugin::DocumentSnapshot { id: 7, revision: 11, path: Some("buffer.txt".into()), root: "/project".into(), text: "ฮฑ\nhello".into(), }, } } fn collect_until( plugin: &mut RubyPlugin, ready: impl Fn(&[PluginEvent]) -> bool, ) -> Vec { let deadline = Instant::now() + STARTUP_TIMEOUT; let mut events = Vec::new(); loop { events.extend(plugin.poll().unwrap()); if ready(&events) { return events; } assert!( Instant::now() < deadline, "Ruby event fixture did not respond" ); thread::sleep(Duration::from_millis(1)); } } #[test] fn asynchronous_documents_background_updates_and_results_share_framed_transport() { use crate::plugin::{DiagnosticSeverity, DocumentEventKind}; let fixture = Fixture::new(); let (mut plugin, feature) = fixture.plugin_with_options( r##" Vamp.plugin "async", requires: ["text", "diagnostics"], async: true raise "options" unless Vamp.options["nested"]["items"] == ["ฮฑ", 3, true] raise "mutable options" unless Vamp.options.frozen? && Vamp.options["nested"].frozen? && Vamp.options["nested"]["items"].frozen? && Vamp.options["nested"]["items"][0].frozen? begin Vamp.options["nested"]["items"] << "bad" raise "mutation escaped" rescue FrozenError end events = [] Vamp.on_document do |event| doc = event["document"] raise "bad snapshot" unless doc["id"] == 7 && doc["revision"] == 11 && doc["root"] == "/project" && doc["text"] == "ฮฑ\nhello" && doc["path"] == "buffer.txt" raise "mutable document" unless event.frozen? && doc.frozen? && doc["text"].frozen? events << event["event"] end Vamp.command "ask" do |editor, _| raise "document ordering" unless events == %w[open change save close] raise "wrong request" unless editor.request_id > 0 Thread.new do puts "background output is not protocol" Vamp.publish [ {type: "diagnostics", buffer_id: editor.buffer_id, revision: editor.revision, items: [{start: {row: 1, column: 0}, end: {row: 1, column: 2}, severity: "warning", message: "actual background diagnostic"}]}, {type: "navigate", buffer_id: editor.buffer_id, revision: editor.revision, path: "/project/target ฮฑ.txt", row: 1, column: 2} ], request_id: editor.request_id end workers = 4.times.map do |worker| Thread.new { 3.times { |item| Vamp.publish [{type: "message", text: "#{worker}:#{item}:ฮฑ"}] } } end workers.each(&:join) editor.message "queued result" end "##, &serde_json::json!({"nested": {"items": ["ฮฑ", 3, true]}}), ); assert!(plugin.is_async()); assert_eq!(feature.required_capabilities, ["text", "diagnostics"]); for event in [ DocumentEventKind::Open, DocumentEventKind::Change, DocumentEventKind::Save, DocumentEventKind::Close, ] { plugin.notify_document(&document(event)).unwrap(); } let id = plugin.queue_invoke("ask", "", &context()).unwrap(); let events = collect_until(&mut plugin, |events| { events.iter().any(|event| matches!(event, PluginEvent::Response { id: response_id, .. } if *response_id == id)) && events.iter().filter(|event| matches!(event, PluginEvent::Update(_))).count() == 13 }); assert!(events.iter().any(|event| matches!(event, PluginEvent::Response { id: response_id, effects } if *response_id == id && matches!(&effects[..], [Effect::Message(text)] if text == "queued result")))); let update = events .iter() .find_map(|event| match event { PluginEvent::Update(update) if update.request_id == Some(id) => Some(update), _ => None, }) .unwrap(); assert!(matches!(&update.effects[..], [UpdateEffect::Diagnostics { buffer_id: 7, revision: 11, items }, UpdateEffect::Navigate { buffer_id: 7, revision: 11, path, row: 1, column: 2 }] if items.len() == 1 && items[0].severity == DiagnosticSeverity::Warning && items[0].message == "actual background diagnostic" && path == Path::new("/project/target ฮฑ.txt"))); assert!(plugin.pending.is_empty()); } #[test] fn asynchronous_invocation_does_not_wait_for_callback_or_result() { let fixture = Fixture::new(); let (mut plugin, _) = fixture.plugin( r#" Vamp.plugin "blocked", async: true Vamp.command "block" do |editor, _| Vamp.publish [{type: "message", text: "callback entered"}], request_id: editor.request_id sleep 10 end "#, ); let id = plugin.queue_invoke("block", "", &context()).unwrap(); let events = collect_until(&mut plugin, |events| !events.is_empty()); assert!(matches!(&events[..], [PluginEvent::Update(update)] if update.request_id == Some(id))); assert!(plugin.pending.contains(&id)); assert!(plugin.poll().unwrap().is_empty()); plugin.disable(); assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); } #[test] fn plugin_options_config_transport_validation_and_required_wire_fields() { let fixture = Fixture::new(); let path = fixture.file("config.rb", r#"Vamp.configure { |config| config.plugin "plugin.rb", options: {nested: {argv: ["tool", "ฮฑ space"]}} }"#); let config = load_config(&path).unwrap(); assert_eq!( config.plugins[0].options, serde_json::json!({"nested": {"argv": ["tool", "ฮฑ space"]}}) ); for value in [ "[]", "nil", "{bad: Object.new}", "{bad: Float::NAN}", "{7 => 'bad'}", ] { let path = fixture.file( "invalid.rb", &format!("Vamp.configure {{ |config| config.plugin 'plugin.rb', options: {value} }}"), ); assert!(load_config(&path).is_err(), "{value}"); } let plugin_path = fixture.file("plugin.rb", "Vamp.plugin 'options'"); assert!(RubyPlugin::start(&plugin_path, &serde_json::json!([])).is_err()); for frame in [ r#"{"path":"p.rb"}"#, r#"{"path":"p.rb","options":null}"#, r#"{"path":"p.rb","options":[]}"#, r#"{"path":7,"options":{}}"#, r#"{"path":"p.rb","options":{},"extra":true}"#, ] { assert!( serde_json::from_str::(frame).is_err(), "{frame}" ); } for frame in [ r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[]}"#, r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[],"async":"yes"}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } } #[test] fn malformed_async_updates_and_unknown_response_ids_retire_host() { let fixture = Fixture::new(); for mutation in [ "frame['version'] = 2", "frame['request_id'] = 'bad'", "frame['request_id'] = 0", "frame['request_id'] = 999", "frame['effects'] = [{'type' => 'message', 'text' => 7}]", "frame['effects'] = [{'type' => 'navigate', 'buffer_id' => 7, 'revision' => 11, 'path' => '/x', 'row' => -1, 'column' => 0}]", "frame['effects'] = [{'type' => 'diagnostics', 'buffer_id' => 7, 'revision' => 11, 'items' => [{'start' => {'row' => 0, 'column' => 0}, 'end' => {'row' => 0, 'column' => 1}, 'severity' => 'fatal', 'message' => 'x'}]}]", "frame['unexpected'] = true", "frame.replace({'type' => 'result', 'version' => 1, 'id' => 999, 'effects' => []})", ] { let (mut plugin, _) = fixture.plugin(&format!(r#" Vamp.plugin 'protocol', async: true Vamp.command('ask') {{ |editor, _| Vamp.publish [{{type: 'message', text: 'valid'}}], request_id: editor.request_id }} module JSON class << self alias_method :original_generate, :generate def generate(frame) {mutation} if frame['type'] == 'update' original_generate(frame) end end end "#)); plugin.queue_invoke("ask", "", &context()).unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; loop { if plugin.poll().is_err() { break; } assert!(Instant::now() < deadline, "{mutation}"); thread::sleep(Duration::from_millis(1)); } assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none(), "{mutation}" ); } } #[test] fn asynchronous_queue_overflow_and_crash_are_explicit_and_cleaned_up() { let fixture = Fixture::new(); let (mut plugin, _) = fixture.plugin( r#" Vamp.plugin 'blocked', async: true class IO def gets(*args) sleep 10 end end "#, ); let mut event = document(crate::plugin::DocumentEventKind::Open); event.document.text = "x".repeat(128 * 1024); let error = (0..MAX_QUEUE + 2) .find_map(|_| plugin.notify_document(&event).err()) .unwrap(); assert!(error.contains("queue is full"), "{error}"); assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); let (mut plugin, _) = fixture.plugin( r#" Vamp.plugin 'flood', async: true Vamp.command('flood') { |_, _| 256.times { Vamp.publish [{type: 'message', text: 'flood'}] } } "#, ); plugin.queue_invoke("flood", "", &context()).unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; while plugin.host.failure.get().is_none() { assert!( Instant::now() < deadline, "flood did not overflow bounded reader" ); thread::sleep(Duration::from_millis(1)); } assert!(plugin.poll().unwrap_err().contains("queue is full")); assert!(plugin.host.child.is_none()); let (mut plugin, _) = fixture .plugin("Vamp.plugin 'crash', async: true\nVamp.command('crash') { |_, _| exit! 19 }"); plugin.queue_invoke("crash", "", &context()).unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; loop { if plugin.poll().is_err() { break; } assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(1)); } assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); } #[test] fn shutdown_hook_runs_for_shutdown_frame_without_protocol_corruption() { let fixture = Fixture::new(); let marker = fixture.0.join("shutdown"); let (mut plugin, _) = fixture.plugin_with_options( "Vamp.plugin 'shutdown', async: true\nVamp.on_shutdown { File.write(Vamp.options['marker'], 'stopped') }", &serde_json::json!({"marker": marker}), ); plugin .host .queue(&serde_json::json!({"type": "shutdown", "version": 1})) .unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; while plugin .host .child .as_mut() .unwrap() .try_wait() .unwrap() .is_none() { assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(1)); } assert_eq!(std::fs::read_to_string(marker).unwrap(), "stopped"); plugin.disable(); assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); } #[test] fn ruby_rejects_invalid_document_context_and_sync_async_dsl_usage() { let fixture = Fixture::new(); for declaration in [ "Vamp.plugin 'bad', async: 'yes'", "Vamp.plugin 'bad'\nVamp.on_document { |_| }", "Vamp.plugin 'bad'\nVamp.publish []", ] { let path = fixture.file("bad.rb", declaration); assert!(RubyPlugin::start(&path, &serde_json::json!({})).is_err()); } for (field, value) in [ ("root", serde_json::json!(7)), ("buffer_id", serde_json::json!(-1)), ("revision", serde_json::json!("old")), ] { let (mut plugin, _) = fixture.plugin( "Vamp.plugin 'context', async: true\nVamp.command('ask') { |editor, _| editor.message 'must not escape' }"); let mut frame = serde_json::json!({ "version": 1, "type": "invoke", "id": 1, "command": "ask", "args": "", "context": context(), }); frame["context"][field] = value; plugin.pending.insert(1); plugin.next_id = 2; plugin.host.queue(&frame).unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; loop { match plugin.poll() { Err(error) => { assert!(error.contains("context"), "{error}"); break; } Ok(events) => assert!(events.is_empty()), } assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(1)); } assert!(plugin.host.child.is_none()); } let (mut plugin, _) = fixture.plugin( "Vamp.plugin 'document', async: true\nVamp.on_document { |_| raise 'must not invoke invalid snapshot' }"); plugin.host.queue(&serde_json::json!({ "version": 1, "type": "document", "event": "open", "document": {"id": 7, "revision": "old", "path": null, "root": "/project", "text": "text"}, })).unwrap(); let deadline = Instant::now() + STARTUP_TIMEOUT; loop { match plugin.poll() { Err(error) => { assert!(error.contains("invalid document snapshot"), "{error}"); break; } Ok(events) => assert!(events.is_empty()), } assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(1)); } assert!( plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() ); }