use crate::chrome::{ChromeLine, PopupConfig}; use crate::editor::{Context, Effect, Feature}; use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect}; use serde::{Deserialize, Serialize}; use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; use std::sync::{Arc, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use std::{ collections::{BTreeMap, HashSet}, env, }; const RUNNER: &str = concat!( include_str!("ruby_values.rb"), "\n", include_str!("ruby_config.rb"), "\n", include_str!("ruby_plugin.rb"), "\n", include_str!("ruby_host.rb"), ); const MAX_FRAME: usize = 4 * 1024 * 1024; const MAX_QUEUE: usize = 64; const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); const INVOKE_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Debug)] pub struct Config { pub plugins: Vec, pub lsp: Vec, pub bindings: Vec, pub leader: String, pub file_explorer_side: String, pub file_picker_view: String, pub file_explorer_view: String, pub clipboard_mode: String, pub git_blame: bool, pub git_gutter: crate::gutter::GutterStyle, pub top_bar: ChromeLine, pub bottom_bar: ChromeLine, pub popup: PopupConfig, pub labels: BTreeMap, pub diff_context_lines: usize, pub diff_expand_lines: usize, pub diff_view_mode: crate::diff::ViewMode, pub diff_split_orientation: crate::panes::SplitAxis, pub diff_follow_working_copy: bool, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginConfig { pub path: PathBuf, #[serde(deserialize_with = "plugin_options")] pub options: serde_json::Value, } fn plugin_options<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { let value = serde_json::Value::deserialize(deserializer)?; if value.is_object() { Ok(value) } else { Err(serde::de::Error::custom( "plugin options must be a JSON object", )) } } impl Default for Config { fn default() -> Self { Self { plugins: Vec::new(), lsp: Vec::new(), bindings: Vec::new(), leader: "space".into(), file_explorer_side: "right".into(), file_picker_view: "tree".into(), file_explorer_view: "tree".into(), clipboard_mode: "system".into(), git_blame: true, git_gutter: crate::gutter::GutterStyle::default(), top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), popup: PopupConfig::default(), labels: BTreeMap::new(), diff_context_lines: 10, diff_expand_lines: 15, diff_view_mode: crate::diff::ViewMode::Split, diff_split_orientation: crate::panes::SplitAxis::Vertical, diff_follow_working_copy: true, } } } /// Relative plugin paths are resolved against the configuration file's directory. pub fn load_config(path: &Path) -> Result { let deadline = Instant::now() + STARTUP_TIMEOUT; let path = absolute_file(path)?; let mut host = Host::spawn("config", &path, &serde_json::json!({}))?; let response = host.receive(deadline)?; match response { Response::Config(response) => { let ConfigFrame { plugins, lsp, bindings, leader, file_explorer_side, file_picker_view, file_explorer_view, clipboard_mode, git_blame, git_gutter, top_bar, bottom_bar, popup, labels, diff_context_lines, diff_expand_lines, diff_view_mode, diff_split_orientation, diff_follow_working_copy, .. } = *response; let mut config = Config::default(); let directory = path.parent().expect("absolute file has a parent"); for plugin in plugins { let plugin_path = plugin.path.to_string_lossy(); if plugin_path.is_empty() || plugin_path.contains('\0') { return Err("Ruby config contains an invalid plugin path".into()); } config.plugins.push(PluginConfig { path: directory.join(plugin.path), options: plugin.options, }); } config.bindings = bindings; config.lsp = crate::lsp_config::resolve(lsp).map_err(|error| format!("Ruby config: {error}"))?; config.leader = leader; config.file_explorer_side = file_explorer_side; config.file_picker_view = file_picker_view; config.file_explorer_view = file_explorer_view; config.clipboard_mode = clipboard_mode; config.git_blame = git_blame; config.git_gutter = git_gutter; config.top_bar = top_bar; config.bottom_bar = bottom_bar; config.popup = popup; config.labels = labels; config.diff_context_lines = diff_context_lines; config.diff_expand_lines = diff_expand_lines; config.diff_view_mode = diff_view_mode; config.diff_split_orientation = diff_split_orientation; config.diff_follow_working_copy = diff_follow_working_copy; Ok(config) } Response::Error { id: None, message, .. } => Err(format!("Ruby config: {message}")), _ => Err("Ruby config returned an unexpected protocol frame".into()), } } pub struct RubyPlugin { host: Host, next_id: u64, asynchronous: bool, pending: HashSet, } impl RubyPlugin { pub fn start(path: &Path, options: &serde_json::Value) -> Result<(Self, Feature), String> { let deadline = Instant::now() + STARTUP_TIMEOUT; let path = absolute_file(path)?; if !options.is_object() { return Err("Ruby plugin options must be a JSON object".into()); } let mut host = Host::spawn("plugin", &path, options)?; match host.receive(deadline)? { Response::Manifest { name, requires, commands, asynchronous, .. } => { if name.is_empty() || requires.iter().any(String::is_empty) || commands.iter().any(String::is_empty) { return Err("Ruby plugin manifest contains empty names".into()); } let feature = Feature { name, required_capabilities: requires, commands, core: false, }; Ok(( Self { host, next_id: 1, asynchronous, pending: HashSet::new(), }, feature, )) } Response::Error { id: None, message, .. } => Err(format!("Ruby plugin startup: {message}")), _ => Err("Ruby plugin returned an unexpected startup frame".into()), } } /// Context coordinates are one-based; effects are queued until a full valid reply arrives. /// Any failure shuts down this host. Starting a fresh plugin is the recovery path. pub fn invoke( &mut self, command: &str, args: &str, context: &Context, ) -> Result, String> { let deadline = Instant::now() + INVOKE_TIMEOUT; let result = self.invoke_inner(command, args, context, deadline); if result.is_err() { self.disable(); } result } fn invoke_inner( &mut self, command: &str, args: &str, context: &Context, deadline: Instant, ) -> Result, String> { if self.asynchronous { return Err("Asynchronous Ruby plugins must use queue_invoke".into()); } let id = self.allocate_id()?; let request = Request { version: 1, kind: "invoke", id, command, args, context, }; self.host.send(&request, deadline)?; match self.host.receive(deadline)? { Response::Result { id: response_id, effects, .. } if response_id == id => Ok(convert_effects(effects)), Response::Error { id: Some(response_id), message, .. } if response_id == id => Err(format!("Ruby plugin: {message}")), _ => Err("Ruby plugin response does not match the invocation".into()), } } pub fn is_async(&self) -> bool { self.asynchronous } /// Enqueues without waiting for Ruby to read, invoke, or respond. pub fn queue_invoke( &mut self, command: &str, args: &str, context: &Context, ) -> Result { let result = (|| { self.require_async()?; if self.pending.len() >= MAX_QUEUE { return Err("Ruby plugin pending invocation queue is full".into()); } let id = self.allocate_id()?; self.host.queue(&Request { version: 1, kind: "invoke", id, command, args, context, })?; self.pending.insert(id); Ok(id) })(); if result.is_err() { self.disable(); } result } pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> { let result = self.require_async().and_then(|()| { self.host.queue(&DocumentRequest { version: 1, kind: "document", event, }) }); if result.is_err() { self.disable(); } result } /// Drains only ready frames. Protocol failures retire this host atomically. pub fn poll(&mut self) -> Result, String> { let result = self.poll_inner(); if result.is_err() { self.disable(); } result } fn poll_inner(&mut self) -> Result, String> { self.require_async()?; let mut events = Vec::new(); for response in self.host.poll()? { match response { Response::Result { id, effects, .. } if self.pending.remove(&id) => { events.push(PluginEvent::Response { id, effects: convert_effects(effects), }); } Response::Update { request_id, effects, .. } if request_id.is_none_or(|id| id > 0 && id < self.next_id) => { events.push(PluginEvent::Update(PluginUpdate { request_id, effects, })); } Response::Error { id, message, .. } if id.is_none_or(|id| self.pending.contains(&id)) => { return Err(format!("Ruby plugin: {message}")); } _ => return Err("Ruby plugin emitted an unexpected protocol frame".into()), } } Ok(events) } fn require_async(&self) -> Result<(), String> { if self.asynchronous { Ok(()) } else { Err("Ruby plugin does not support asynchronous events".into()) } } fn allocate_id(&mut self) -> Result { let id = self.next_id; self.next_id = id.checked_add(1).ok_or("Ruby request IDs exhausted")?; Ok(id) } pub fn disable(&mut self) { self.host.shutdown(); self.pending.clear(); } } fn convert_effects(effects: Vec) -> Vec { effects .into_iter() .map(|effect| match effect { WireEffect::Insert { text } => Effect::Insert(text), WireEffect::Replace { text } => Effect::Replace(text), WireEffect::Message { text } => Effect::Message(text), WireEffect::Label { name, text } => Effect::Label { name, text }, }) .collect() } fn absolute_file(path: &Path) -> Result { if path.is_absolute() { Ok(path.to_path_buf()) } else { env::current_dir() .map(|directory| directory.join(path)) .map_err(|error| format!("Cannot resolve Ruby file path: {error}")) } } #[derive(Serialize)] struct Request<'a> { version: u8, #[serde(rename = "type")] kind: &'static str, id: u64, command: &'a str, args: &'a str, context: &'a Context, } #[derive(Serialize)] struct DocumentRequest<'a> { version: u8, #[serde(rename = "type")] kind: &'static str, #[serde(flatten)] event: &'a DocumentEvent, } fn diff_line_count<'de, D: serde::Deserializer<'de>>( deserializer: D, label: &str, minimum: usize, ) -> Result { let value = serde_json::Value::deserialize(deserializer)?; value .as_u64() .and_then(|value| usize::try_from(value).ok()) .filter(|value| *value >= minimum) .ok_or_else(|| { serde::de::Error::custom(format!( "{label} must be an Integer between {minimum} and {}", usize::MAX )) }) } fn diff_context_lines<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { diff_line_count(deserializer, "diff_context_lines", 0) } fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { diff_line_count(deserializer, "diff_expand_lines", 1) } // Startup-only configuration does not inflate every queued plugin response. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ConfigFrame { version: u8, plugins: Vec, // Keep this wire field required; resolve its configuration after decoding. #[serde(deserialize_with = "serde_json::Value::deserialize")] lsp: serde_json::Value, bindings: Vec, leader: String, file_explorer_side: String, file_picker_view: String, file_explorer_view: String, clipboard_mode: String, git_blame: bool, git_gutter: crate::gutter::GutterStyle, #[serde(deserialize_with = "crate::chrome::top")] top_bar: ChromeLine, #[serde(deserialize_with = "crate::chrome::bottom")] bottom_bar: ChromeLine, popup: PopupConfig, #[serde(deserialize_with = "crate::chrome::labels")] labels: BTreeMap, #[serde(deserialize_with = "diff_context_lines")] diff_context_lines: usize, #[serde(deserialize_with = "diff_expand_lines")] diff_expand_lines: usize, diff_view_mode: crate::diff::ViewMode, diff_split_orientation: crate::panes::SplitAxis, diff_follow_working_copy: bool, } #[derive(Deserialize)] #[serde(tag = "type", deny_unknown_fields)] enum Response { #[serde(rename = "manifest")] Manifest { version: u8, name: String, requires: Vec, commands: Vec, #[serde(rename = "async")] asynchronous: bool, }, #[serde(rename = "config")] Config(Box), #[serde(rename = "result")] Result { version: u8, id: u64, effects: Vec, }, #[serde(rename = "update")] Update { version: u8, request_id: Option, effects: Vec, }, #[serde(rename = "error")] Error { version: u8, #[serde(default, deserialize_with = "optional_request_id")] id: Option, message: String, }, } fn optional_request_id<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { u64::deserialize(deserializer).map(Some) } impl Response { fn version(&self) -> u8 { match self { Self::Manifest { version, .. } | Self::Result { version, .. } | Self::Update { version, .. } | Self::Error { version, .. } => *version, Self::Config(config) => config.version, } } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Binding { pub mode: String, pub key: String, pub command: Option, } #[derive(Deserialize)] #[serde(tag = "type", deny_unknown_fields)] enum WireEffect { #[serde(rename = "insert")] Insert { text: String }, #[serde(rename = "replace")] Replace { text: String }, #[serde(rename = "message")] Message { text: String }, #[serde(rename = "label")] Label { name: String, text: String }, } struct OutboundFrame { bytes: Vec, acknowledge: bool, } struct Host { child: Option, requests: Option>, writes: Receiver>, frames: Receiver, failure: Arc>, reader: Option>, writer: Option>, } impl Host { fn spawn(mode: &str, path: &Path, options: &serde_json::Value) -> Result { #[derive(Serialize)] struct Startup<'a> { core_actions: crate::editor::CoreActionCatalog, command_aliases: crate::editor::CoreCommandAliases, options: &'a serde_json::Value, } let mut encoded_startup = FrameWriter(Vec::new()); serde_json::to_writer( &mut encoded_startup, &Startup { core_actions: crate::editor::CoreActionCatalog, command_aliases: crate::editor::CoreCommandAliases, options, }, ) .map_err(|error| format!("Cannot encode bounded Ruby plugin options: {error}"))?; let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()); let mut command = Command::new(&executable); command .args(["-e", RUNNER, "--", mode]) .arg(path) .arg(String::from_utf8(encoded_startup.0).expect("JSON is UTF-8")) .current_dir(path.parent().expect("absolute file has a parent")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } let mut child = command.spawn().map_err(|error| { format!( "Ruby runtime {:?} unavailable or failed to start: {error}", executable ) })?; let mut stdin = child.stdin.take().expect("piped child stdin"); let stdout = child.stdout.take().expect("piped child stdout"); let (request_tx, request_rx) = mpsc::sync_channel::(MAX_QUEUE); let (write_tx, write_rx) = mpsc::sync_channel(1); let (frame_tx, frame_rx) = mpsc::sync_channel(MAX_QUEUE); let failure = Arc::new(OnceLock::new()); let mut host = Self { child: Some(child), requests: Some(request_tx), writes: write_rx, frames: frame_rx, failure: Arc::clone(&failure), reader: None, writer: None, }; let reader_failure = Arc::clone(&failure); host.reader = Some( thread::Builder::new() .name("vamp-ruby-reader".into()) .spawn(move || { let mut reader = BufReader::new(stdout); loop { let result = read_frame(&mut reader).and_then(|bytes| { let response = serde_json::from_slice::(&bytes).map_err(|error| { format!("Malformed Ruby protocol frame: {error}") })?; if response.version() != 1 { return Err("Unsupported Ruby protocol version".into()); } Ok(response) }); match result { Ok(response) => match frame_tx.try_send(response) { Ok(()) => {} Err(mpsc::TrySendError::Full(_)) => { record_failure( &reader_failure, "Ruby host update queue is full".into(), ); break; } Err(mpsc::TrySendError::Disconnected(_)) => break, }, Err(error) => { record_failure(&reader_failure, error); break; } } } }) .map_err(|error| format!("Cannot start Ruby reader: {error}"))?, ); host.writer = Some( thread::Builder::new() .name("vamp-ruby-writer".into()) .spawn(move || { while let Ok(frame) = request_rx.recv() { let result = stdin .write_all(&frame.bytes) .and_then(|_| stdin.flush()) .map_err(|error| format!("Ruby host write failed: {error}")); if let Err(error) = &result { record_failure(&failure, error.clone()); } let failed = result.is_err(); if (frame.acknowledge && write_tx.try_send(result).is_err()) || failed { break; } } }) .map_err(|error| format!("Cannot start Ruby writer: {error}"))?, ); Ok(host) } fn check(&self) -> Result<(), String> { if self.requests.is_none() { return Err("Ruby plugin is disabled".into()); } if let Some(error) = self.failure.get() { return Err(error.clone()); } Ok(()) } fn enqueue(&mut self, request: &impl Serialize, acknowledge: bool) -> Result<(), String> { self.check()?; let mut frame = FrameWriter(Vec::new()); serde_json::to_writer(&mut frame, request) .map_err(|error| format!("Cannot encode bounded Ruby request: {error}"))?; frame.0.push(b'\n'); self.requests .as_ref() .expect("checked request channel") .try_send(OutboundFrame { bytes: frame.0, acknowledge, }) .map_err(|error| match error { mpsc::TrySendError::Full(_) => "Ruby host request queue is full".into(), mpsc::TrySendError::Disconnected(_) => "Ruby host request channel failed".into(), }) } fn queue(&mut self, request: &impl Serialize) -> Result<(), String> { self.enqueue(request, false) } fn send(&mut self, request: &Request<'_>, deadline: Instant) -> Result<(), String> { self.check()?; match self.frames.try_recv() { Ok(_) => return Err("Ruby host emitted an unsolicited protocol frame".into()), Err(mpsc::TryRecvError::Disconnected) => { return Err("Ruby host crashed or closed its output".into()); } Err(mpsc::TryRecvError::Empty) => {} } self.enqueue(request, true)?; receive_until(&self.writes, deadline, "write")? } fn receive(&mut self, deadline: Instant) -> Result { // Prefer an already queued startup/error response over the subsequent EOF. match receive_until(&self.frames, deadline, "response") { Ok(response) => Ok(response), Err(error) => self.check().and(Err(error)), } } fn poll(&mut self) -> Result, String> { if self.requests.is_none() { return Err("Ruby plugin is disabled".into()); } let mut frames = Vec::new(); let mut disconnected = false; for _ in 0..MAX_QUEUE { match self.frames.try_recv() { Ok(frame) => frames.push(frame), Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { disconnected = true; break; } } } // Ruby errors remain useful even when the host exits immediately afterward. if !frames .iter() .any(|frame| matches!(frame, Response::Error { .. })) { self.check()?; if disconnected { return Err("Ruby host crashed or closed its output".into()); } } Ok(frames) } fn shutdown(&mut self) { if let Some(sender) = self.requests.as_ref() { let _ = sender.try_send(OutboundFrame { bytes: b"{\"version\":1,\"type\":\"shutdown\"}\n".to_vec(), acknowledge: false, }); } self.requests.take(); if let Some(mut child) = self.child.take() { // Descendants can inherit pipes; kill the host group before joining IO threads. #[cfg(unix)] unsafe { kill(-(child.id() as i32), 9); } let _ = child.kill(); let _ = child.wait(); } if let Some(writer) = self.writer.take() { let _ = writer.join(); } if let Some(reader) = self.reader.take() { let _ = reader.join(); } } } fn record_failure(failure: &OnceLock, error: String) { let _ = failure.set(error); } impl Drop for Host { fn drop(&mut self) { self.shutdown(); } } #[cfg(unix)] unsafe extern "C" { fn kill(pid: i32, signal: i32) -> i32; } fn receive_until(receiver: &Receiver, deadline: Instant, phase: &str) -> Result { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Err(format!("Ruby host {phase} timeout")); } receiver .recv_timeout(remaining) .map_err(|error| match error { RecvTimeoutError::Timeout => format!("Ruby host {phase} timeout"), RecvTimeoutError::Disconnected => { format!("Ruby host crashed or closed its {phase} channel") } }) } struct FrameWriter(Vec); impl Write for FrameWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { if bytes.len() > MAX_FRAME - self.0.len() { return Err(io::Error::new( io::ErrorKind::InvalidData, "protocol frame exceeds 4 MiB", )); } self.0.extend_from_slice(bytes); Ok(bytes.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } fn read_frame(reader: &mut impl BufRead) -> Result, String> { let mut frame = Vec::new(); loop { let chunk = reader .fill_buf() .map_err(|error| format!("Ruby host read failed: {error}"))?; if chunk.is_empty() { return Err("Ruby host crashed or closed its output before a complete frame".into()); } let newline = chunk.iter().position(|byte| *byte == b'\n'); let length = newline.unwrap_or(chunk.len()); if length > MAX_FRAME - frame.len() { return Err("Ruby protocol frame exceeds 4 MiB".into()); } frame.extend_from_slice(&chunk[..length]); reader.consume(length + usize::from(newline.is_some())); if newline.is_some() { return Ok(frame); } } } #[cfg(test)] mod tests { 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 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":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","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}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","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}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","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}"#, ] { 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": [], "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": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "diff_view_mode": "split", "diff_split_orientation": "vertical", "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 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.top_bar.segment("language").padding, 1); 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": {}, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "top_bar": {}, "bottom_bar": {}, "labels": {}, "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "diff_view_mode": "split", "diff_split_orientation": "vertical", "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 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": {}, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "git_blame": true, "git_gutter": "bar", "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", "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() ); } }