Files
vamp/src/ruby.rs
T
2026-10-12 12:55:17 +02:00

2452 lines
89 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::chrome::ChromeLine;
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<PluginConfig>,
pub lsp: Vec<crate::lsp::ServerConfig>,
pub bindings: Vec<Binding>,
pub leader: String,
pub file_explorer_side: String,
pub file_picker_view: String,
pub file_explorer_view: String,
pub clipboard_mode: String,
pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine,
pub labels: BTreeMap<String, String>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
}
#[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<serde_json::Value, D::Error> {
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(),
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
labels: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
}
}
}
/// Relative plugin paths are resolved against the configuration file's directory.
pub fn load_config(path: &Path) -> Result<Config, String> {
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 {
plugins,
lsp,
bindings,
leader,
file_explorer_side,
file_picker_view,
file_explorer_view,
clipboard_mode,
top_bar,
bottom_bar,
labels,
diff_context_lines,
diff_expand_lines,
..
} => {
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.top_bar = top_bar;
config.bottom_bar = bottom_bar;
config.labels = labels;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
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<u64>,
}
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<Vec<Effect>, 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<Vec<Effect>, 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<u64, String> {
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<Vec<PluginEvent>, String> {
let result = self.poll_inner();
if result.is_err() {
self.disable();
}
result
}
fn poll_inner(&mut self) -> Result<Vec<PluginEvent>, 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<u64, String> {
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<WireEffect>) -> Vec<Effect> {
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<PathBuf, String> {
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<usize, D::Error> {
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<usize, D::Error> {
diff_line_count(deserializer, "diff_context_lines", 0)
}
fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<usize, D::Error> {
diff_line_count(deserializer, "diff_expand_lines", 1)
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum Response {
#[serde(rename = "manifest")]
Manifest {
version: u8,
name: String,
requires: Vec<String>,
commands: Vec<String>,
#[serde(rename = "async")]
asynchronous: bool,
},
#[serde(rename = "config")]
Config {
version: u8,
plugins: Vec<PluginConfig>,
// Keep this wire field required; resolve its configuration after decoding.
#[serde(deserialize_with = "serde_json::Value::deserialize")]
lsp: serde_json::Value,
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
bottom_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize,
},
#[serde(rename = "result")]
Result {
version: u8,
id: u64,
effects: Vec<WireEffect>,
},
#[serde(rename = "update")]
Update {
version: u8,
request_id: Option<u64>,
effects: Vec<UpdateEffect>,
},
#[serde(rename = "error")]
Error {
version: u8,
#[serde(default, deserialize_with = "optional_request_id")]
id: Option<u64>,
message: String,
},
}
fn optional_request_id<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
u64::deserialize(deserializer).map(Some)
}
impl Response {
fn version(&self) -> u8 {
match self {
Self::Manifest { version, .. }
| Self::Config { version, .. }
| Self::Result { version, .. }
| Self::Update { version, .. }
| Self::Error { version, .. } => *version,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Binding {
pub mode: String,
pub key: String,
pub command: Option<String>,
}
#[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<u8>,
acknowledge: bool,
}
struct Host {
child: Option<Child>,
requests: Option<SyncSender<OutboundFrame>>,
writes: Receiver<Result<(), String>>,
frames: Receiver<Response>,
failure: Arc<OnceLock<String>>,
reader: Option<JoinHandle<()>>,
writer: Option<JoinHandle<()>>,
}
impl Host {
fn spawn(mode: &str, path: &Path, options: &serde_json::Value) -> Result<Self, String> {
#[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::<OutboundFrame>(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::<Response>(&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<Response, String> {
// 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<Vec<Response>, 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<String>, 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<T>(receiver: &Receiver<T>, deadline: Instant, phase: &str) -> Result<T, String> {
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<u8>);
impl Write for FrameWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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<Vec<u8>, 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 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 "L", config.core.buffers
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 "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_some());
review.handle(Input::Char('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('L'));
assert!(session.editor().message().contains("buffer.txt"));
assert!(session.editor().message().contains("second.txt"));
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","top_bar":{},"bottom_bar":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
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","top_bar":{},"bottom_bar":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
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","top_bar":{},"bottom_bar":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[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("tabs").trailing_edge, "▓▒░");
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 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", "{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::<WireEffect>(frame.clone()).is_err());
assert!(serde_json::from_value::<UpdateEffect>(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": {},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
});
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::<Response>(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",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
});
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).unwrap().is_empty());
frame["lsp"] = serde_json::json!({"rust": true});
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(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 { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).is_err(), "{frame}");
}
let mut languages: serde_json::Map<String, serde_json::Value> = (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 { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(crate::lsp_config::resolve(lsp).unwrap().len(), 16);
frame["lsp"]["ruby"] = serde_json::json!(true);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(lsp).unwrap_err(),
"LSP configuration supports at most 16 configured servers"
);
frame.as_object_mut().unwrap().remove("lsp");
assert!(serde_json::from_value::<Response>(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['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<PluginEvent> {
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::<PluginConfig>(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::<Response>(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()
);
}
}