3270 lines
123 KiB
Rust
3270 lines
123 KiB
Rust
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::num::{NonZeroU8, NonZeroUsize};
|
||
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 review_directory: Option<PathBuf>,
|
||
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 git_blame: bool,
|
||
pub git_gutter: crate::gutter::GutterStyle,
|
||
pub top_bar: ChromeLine,
|
||
pub bottom_bar: ChromeLine,
|
||
pub popup: PopupConfig,
|
||
pub labels: BTreeMap<String, String>,
|
||
pub scroll_distance: Option<usize>,
|
||
pub scroll_step_lines: Option<NonZeroUsize>,
|
||
pub highlight_trailing_whitespace: bool,
|
||
pub whitespace_characters: BTreeMap<char, char>,
|
||
pub whitespace_scope: crate::editor::WhitespaceScope,
|
||
pub tab_spaces: Option<NonZeroU8>,
|
||
pub language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||
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,
|
||
pub resolve_history_layout: crate::editor::ResolverLayout,
|
||
}
|
||
|
||
#[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(),
|
||
review_directory: None,
|
||
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(),
|
||
scroll_distance: Some(0),
|
||
scroll_step_lines: None,
|
||
highlight_trailing_whitespace: false,
|
||
whitespace_characters: BTreeMap::new(),
|
||
whitespace_scope: crate::editor::WhitespaceScope::All,
|
||
tab_spaces: None,
|
||
language_tab_spaces: 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,
|
||
resolve_history_layout: crate::editor::ResolverLayout::Columns,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Relative plugin and review 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(response) => {
|
||
let ConfigFrame {
|
||
plugins,
|
||
lsp,
|
||
review_directory,
|
||
bindings,
|
||
leader,
|
||
file_explorer_side,
|
||
file_picker_view,
|
||
file_explorer_view,
|
||
clipboard_mode,
|
||
git_blame,
|
||
git_gutter,
|
||
top_bar,
|
||
bottom_bar,
|
||
popup,
|
||
labels,
|
||
scroll_distance,
|
||
scroll_step_lines,
|
||
highlight_trailing_whitespace,
|
||
whitespace_characters,
|
||
whitespace_scope,
|
||
tab_spaces,
|
||
language_tab_spaces,
|
||
diff_context_lines,
|
||
diff_expand_lines,
|
||
diff_view_mode,
|
||
diff_split_orientation,
|
||
diff_follow_working_copy,
|
||
resolve_history_layout,
|
||
..
|
||
} = *response;
|
||
let mut config = Config::default();
|
||
let directory = path.parent().expect("absolute file has a parent");
|
||
config.review_directory = review_directory.map(|path| directory.join(path));
|
||
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.scroll_distance = scroll_distance;
|
||
config.scroll_step_lines = scroll_step_lines;
|
||
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
|
||
config.whitespace_characters = whitespace_characters;
|
||
config.whitespace_scope = whitespace_scope;
|
||
config.tab_spaces = tab_spaces;
|
||
config.language_tab_spaces = language_tab_spaces;
|
||
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;
|
||
config.resolve_history_layout = resolve_history_layout;
|
||
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)
|
||
}
|
||
|
||
fn scroll_distance<'de, D: serde::Deserializer<'de>>(
|
||
deserializer: D,
|
||
) -> Result<Option<usize>, D::Error> {
|
||
Option::<usize>::deserialize(deserializer).map_err(|error| {
|
||
serde::de::Error::custom(format!(
|
||
"scroll_distance must be nil or an Integer between 0 and {}: {error}",
|
||
usize::MAX
|
||
))
|
||
})
|
||
}
|
||
|
||
fn review_directory<'de, D: serde::Deserializer<'de>>(
|
||
deserializer: D,
|
||
) -> Result<Option<PathBuf>, D::Error> {
|
||
let directory = Option::<String>::deserialize(deserializer)?;
|
||
if directory
|
||
.as_ref()
|
||
.is_some_and(|path| path.is_empty() || path.contains('\0'))
|
||
{
|
||
return Err(serde::de::Error::custom(
|
||
"review_directory must not be empty or contain NUL",
|
||
));
|
||
}
|
||
Ok(directory.map(PathBuf::from))
|
||
}
|
||
|
||
// Startup-only configuration does not inflate every queued plugin response.
|
||
#[derive(Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
struct ConfigFrame {
|
||
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,
|
||
#[serde(deserialize_with = "review_directory")]
|
||
review_directory: Option<PathBuf>,
|
||
bindings: Vec<Binding>,
|
||
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<String, String>,
|
||
#[serde(deserialize_with = "scroll_distance")]
|
||
scroll_distance: Option<usize>,
|
||
scroll_step_lines: Option<NonZeroUsize>,
|
||
highlight_trailing_whitespace: bool,
|
||
#[serde(deserialize_with = "whitespace_characters")]
|
||
whitespace_characters: BTreeMap<char, char>,
|
||
whitespace_scope: crate::editor::WhitespaceScope,
|
||
#[serde(deserialize_with = "Option::deserialize")]
|
||
tab_spaces: Option<NonZeroU8>,
|
||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||
#[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,
|
||
resolve_history_layout: crate::editor::ResolverLayout,
|
||
}
|
||
|
||
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
||
deserializer: D,
|
||
) -> Result<BTreeMap<char, char>, D::Error> {
|
||
use unicode_width::UnicodeWidthChar;
|
||
|
||
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
|
||
for (&whitespace, &marker) in &characters {
|
||
if !whitespace.is_whitespace()
|
||
|| (!matches!(whitespace, '\t' | '\n')
|
||
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
|
||
{
|
||
return Err(serde::de::Error::custom(
|
||
"whitespace_characters keys must be visible-width whitespace characters, tabs, or newlines",
|
||
));
|
||
}
|
||
if marker.is_control() || marker.width() != Some(1) {
|
||
return Err(serde::de::Error::custom(
|
||
"whitespace_characters values must be printable single-cell characters",
|
||
));
|
||
}
|
||
}
|
||
Ok(characters)
|
||
}
|
||
|
||
#[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(Box<ConfigFrame>),
|
||
#[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::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<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 crate::{editor::Input, session::EditorSession};
|
||
|
||
use super::*;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
|
||
|
||
struct Fixture(PathBuf);
|
||
|
||
impl Fixture {
|
||
fn new() -> Self {
|
||
let path = env::temp_dir().join(format!(
|
||
"vamp-ruby-{}-{}",
|
||
std::process::id(),
|
||
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
|
||
));
|
||
std::fs::create_dir(&path).unwrap();
|
||
Self(path)
|
||
}
|
||
|
||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||
let path = self.0.join(name);
|
||
std::fs::write(&path, text).unwrap();
|
||
path
|
||
}
|
||
|
||
fn plugin(&self, text: &str) -> (RubyPlugin, Feature) {
|
||
self.plugin_with_options(text, &serde_json::json!({}))
|
||
}
|
||
|
||
fn plugin_with_options(
|
||
&self,
|
||
text: &str,
|
||
options: &serde_json::Value,
|
||
) -> (RubyPlugin, Feature) {
|
||
RubyPlugin::start(&self.file("plugin.rb", text), options).unwrap_or_else(|error| {
|
||
panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}")
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Drop for Fixture {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
fn context() -> Context {
|
||
Context {
|
||
buffer_id: 7,
|
||
revision: 11,
|
||
root: "/project".into(),
|
||
text: "α\nhello".into(),
|
||
row: 2,
|
||
column: 3,
|
||
mode: "NORMAL".into(),
|
||
path: Some("buffer.txt".into()),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
|
||
let fixture = Fixture::new();
|
||
let absolute = fixture.0.join("absolute-reviews");
|
||
for (value, expected) in [
|
||
(
|
||
r#""relative-reviews""#.to_owned(),
|
||
Some(fixture.0.join("relative-reviews")),
|
||
),
|
||
(serde_json::to_string(&absolute).unwrap(), Some(absolute)),
|
||
("nil".to_owned(), None),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
|
||
);
|
||
assert_eq!(load_config(&path).unwrap().review_directory, expected);
|
||
let session = EditorSession::open(None, vec![], Some(&path)).unwrap();
|
||
assert!(session.editor().startup_diagnostic().is_none());
|
||
if let Some(directory) = expected {
|
||
assert!(
|
||
!directory.exists(),
|
||
"startup created {}",
|
||
directory.display()
|
||
);
|
||
}
|
||
}
|
||
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||
assert_eq!(load_config(&path).unwrap().review_directory, None);
|
||
for value in [
|
||
"7",
|
||
"false",
|
||
"[]",
|
||
"{}",
|
||
":reviews",
|
||
r#""""#,
|
||
r#""bad\0path""#,
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
|
||
);
|
||
let error = load_config(&path).unwrap_err();
|
||
assert!(error.contains("review_directory"), "{value}: {error}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn review_directory_wire_field_is_required_nullable_and_validated() {
|
||
let mut frame = serde_json::json!({
|
||
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
|
||
"review_directory": null, "leader": "space",
|
||
"file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree",
|
||
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {},
|
||
"scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {}, "whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"diff_follow_working_copy": true
|
||
});
|
||
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
|
||
frame["review_directory"] = value;
|
||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||
}
|
||
for value in [
|
||
serde_json::json!(""),
|
||
serde_json::json!("bad\0path"),
|
||
serde_json::json!(7),
|
||
serde_json::json!(false),
|
||
serde_json::json!([]),
|
||
serde_json::json!({}),
|
||
] {
|
||
frame["review_directory"] = value;
|
||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||
}
|
||
frame.as_object_mut().unwrap().remove("review_directory");
|
||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
|
||
let fixture = Fixture::new();
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
r#"
|
||
Vamp.configure do |c|
|
||
c.tab_spaces = 4
|
||
c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5)
|
||
c.language_tab_spaces["cpp"] = 3
|
||
end
|
||
"#,
|
||
);
|
||
let widest = " ".repeat(255);
|
||
for (name, prefix, inserted) in [
|
||
("default.rs", "", "\t"),
|
||
("buffer.rs", "", " "),
|
||
("buffer.rb", "", " "),
|
||
("Gemfile", "", " "),
|
||
("script", "#!/usr/bin/env ruby\n", " "),
|
||
("Makefile", "", "\t"),
|
||
("plain.txt", "", " "),
|
||
("buffer.json", "", " "),
|
||
("buffer.yaml", "", widest.as_str()),
|
||
("buffer.cpp", "", " "),
|
||
("buffer.tsx", "", " "),
|
||
] {
|
||
let source = format!("{prefix}αx\nβy");
|
||
let path = fixture.file(name, &source);
|
||
let config = (name != "default.rs").then_some(config.as_path());
|
||
let mut session = EditorSession::open(Some(path), vec![], config).unwrap();
|
||
assert!(session.editor().startup_diagnostic().is_none());
|
||
if !prefix.is_empty() {
|
||
session.handle(Input::Char('j'));
|
||
}
|
||
session.handle(Input::Ctrl('e'));
|
||
for input in [
|
||
Input::Char('l'),
|
||
Input::Char('i'),
|
||
Input::Char('\t'),
|
||
Input::Ctrl('i'),
|
||
Input::Escape,
|
||
] {
|
||
session.handle(input);
|
||
}
|
||
let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y");
|
||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||
session.handle(Input::Char('u'));
|
||
assert_eq!(session.editor().context().text, source, "{name}");
|
||
session.handle(Input::Char('U'));
|
||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||
session.handle(Input::Char('u'));
|
||
session.handle(Input::Char('.'));
|
||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||
session.handle(Input::Char('i'));
|
||
session.handle(Input::End);
|
||
session.handle(Input::Paste("\t".into()));
|
||
assert_eq!(
|
||
session.editor().context().text,
|
||
format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"),
|
||
"{name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tab_spaces_reject_invalid_widths_and_language_maps() {
|
||
let fixture = Fixture::new();
|
||
for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] {
|
||
for assignment in [
|
||
format!("c.tab_spaces = {value}"),
|
||
format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#),
|
||
format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#),
|
||
format!(r#"c.language_tab_spaces["ruby"] = {value}"#),
|
||
] {
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| {assignment} }}"),
|
||
);
|
||
let error = load_config(&config).unwrap_err();
|
||
assert!(
|
||
error.contains("tab_spaces must be nil or an Integer"),
|
||
"{assignment}: {error}"
|
||
);
|
||
}
|
||
}
|
||
for value in [
|
||
"nil",
|
||
"[]",
|
||
r#"{ "" => 2 }"#,
|
||
r#"{ :ruby => 2 }"#,
|
||
r#"{ "\n" => 2 }"#,
|
||
] {
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"),
|
||
);
|
||
assert!(load_config(&config).is_err(), "{value}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn language_tab_space_mutations_reject_invalid_keys_before_serialization() {
|
||
let fixture = Fixture::new();
|
||
for key in [r#""""#, ":ruby", r#""\n""#, "7"] {
|
||
for assignment in [
|
||
format!("c.language_tab_spaces.merge!({key} => 2)"),
|
||
format!("c.language_tab_spaces[{key}] = 2"),
|
||
] {
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| {assignment} }}"),
|
||
);
|
||
let error = load_config(&config).unwrap_err();
|
||
assert!(error.contains("Tab language"), "{assignment}: {error}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn actual_ruby_dsl_context_queued_effects_and_output_isolation() {
|
||
let fixture = Fixture::new();
|
||
let (mut plugin, feature) = fixture.plugin(r#"
|
||
puts "startup noise"
|
||
STDOUT.write "direct startup noise\n"
|
||
warn "warning"
|
||
Vamp.plugin "greeting", requires: ["text"]
|
||
Vamp.command "greet" do |editor, args|
|
||
puts "invocation noise"
|
||
STDOUT.write "direct invocation noise\n"
|
||
raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt"
|
||
raise "bad source" unless editor.buffer_id == 7 && editor.revision == 11 && editor.root == "/project" && editor.request_id > 0
|
||
editor.insert "Hello #{args}!"
|
||
editor.replace editor.text
|
||
editor.message "Greeting inserted"
|
||
editor.label :battery, "界 e\u0301 👩🚀\t\n\e"
|
||
editor.label "battery", ""
|
||
end
|
||
"#);
|
||
assert_eq!(feature.name, "greeting");
|
||
assert_eq!(feature.required_capabilities, ["text"]);
|
||
assert_eq!(feature.commands, ["greet"]);
|
||
assert!(!feature.core);
|
||
assert!(!plugin.is_async());
|
||
for _ in 0..2 {
|
||
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
||
assert!(
|
||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }]
|
||
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
|
||
&& name == "battery" && cleared_name == "battery"
|
||
&& label == "界 e\u{301} 👩🚀\t\n\u{1b}" && cleared.is_empty())
|
||
);
|
||
}
|
||
plugin.disable();
|
||
plugin.disable();
|
||
assert!(
|
||
plugin
|
||
.invoke("greet", "", &context())
|
||
.unwrap_err()
|
||
.contains("disabled")
|
||
);
|
||
assert!(
|
||
plugin.host.child.is_none()
|
||
&& plugin.host.reader.is_none()
|
||
&& plugin.host.writer.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_core_actions_execute_through_ruby_configuration() {
|
||
use crate::{
|
||
editor::{Input, Mode},
|
||
session::EditorSession,
|
||
};
|
||
|
||
let fixture = Fixture::new();
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure do |config|
|
||
config.clipboard_mode = "separate"
|
||
config.bind "Q", config.core.quit
|
||
config.bind "F", config.core.force_quit
|
||
config.bind "W", config.core.save
|
||
config.bind "X", config.core.save_and_quit
|
||
config.bind "E", config.core.edit
|
||
config.bind "B", config.core.buffer
|
||
config.bind "P", config.core.plugins
|
||
config.bind "N", config.core.buffer_next
|
||
config.bind "V", config.core.buffer_previous
|
||
config.bind "D", config.core.buffer_delete
|
||
config.bind "Z", config.core.buffer_force_delete
|
||
config.bind "R", config.core.diff
|
||
config.bind "ctrl+t", config.core.diff.close
|
||
config.bind "H", config.core.word_wrap.toggle
|
||
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
||
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
||
config.core.diff.close.name.frozen?
|
||
end"#,
|
||
);
|
||
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap();
|
||
assert!(!review.editor().word_wrap());
|
||
review.handle(Input::Char('H'));
|
||
assert!(review.editor().word_wrap());
|
||
review.execute("word_wrap.toggle unexpected");
|
||
assert!(review.editor().word_wrap());
|
||
review.execute("word_wrap.toggle");
|
||
assert!(!review.editor().word_wrap());
|
||
review.handle(Input::Char('R'));
|
||
review.handle(Input::Escape);
|
||
assert!(review.editor().diff().is_none());
|
||
review.handle(Input::Char('R'));
|
||
assert!(review.editor().diff().is_some());
|
||
review.handle(Input::Ctrl('t'));
|
||
assert!(review.editor().diff().is_none());
|
||
for (key, dirty, quit, saved) in [
|
||
('Q', false, true, false),
|
||
('Q', true, false, false),
|
||
('F', true, true, false),
|
||
('W', true, false, true),
|
||
('X', true, true, true),
|
||
] {
|
||
let buffer = fixture.file("buffer.txt", "base");
|
||
let mut session =
|
||
EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config))
|
||
.unwrap();
|
||
assert!(session.editor().startup_diagnostic().is_none());
|
||
if dirty {
|
||
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
|
||
session.handle(input);
|
||
}
|
||
}
|
||
session.handle(Input::Char(key));
|
||
assert_eq!(session.editor().should_quit(), quit, "{key}");
|
||
assert_eq!(
|
||
std::fs::read_to_string(&buffer).unwrap(),
|
||
if saved { "!base" } else { "base" },
|
||
"{key}"
|
||
);
|
||
assert_eq!(session.editor().dirty(), dirty && !saved, "{key}");
|
||
}
|
||
|
||
let buffer = fixture.file("buffer.txt", "base");
|
||
let second = fixture.file("second.txt", "second");
|
||
let mut session =
|
||
EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap();
|
||
assert!(session.editor().startup_diagnostic().is_none());
|
||
session.handle(Input::Char('E'));
|
||
assert_eq!(session.editor().mode(), Mode::Command);
|
||
assert_eq!(session.editor().command_line(), "edit ");
|
||
for ch in second.to_string_lossy().chars() {
|
||
session.handle(Input::Char(ch));
|
||
}
|
||
session.handle(Input::Enter);
|
||
assert_eq!(session.editor().lines(), &["second"]);
|
||
session.handle(Input::Char('B'));
|
||
assert_eq!(session.editor().mode(), Mode::Command);
|
||
assert_eq!(session.editor().command_line(), "buffer ");
|
||
session.handle(Input::Char('1'));
|
||
session.handle(Input::Enter);
|
||
assert_eq!(session.editor().buffer_id(), 1);
|
||
assert_eq!(session.editor().lines(), &["base"]);
|
||
session.handle(Input::Char('P'));
|
||
assert!(session.editor().message().contains("text"));
|
||
session.handle(Input::Char('N'));
|
||
assert_eq!(session.editor().buffer_id(), 2);
|
||
session.handle(Input::Char('V'));
|
||
assert_eq!(session.editor().buffer_id(), 1);
|
||
session.handle(Input::Char('D'));
|
||
assert!(!session.editor().has_buffer(1));
|
||
assert_eq!(session.editor().buffer_id(), 2);
|
||
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
|
||
session.handle(input);
|
||
}
|
||
session.handle(Input::Char('D'));
|
||
assert!(session.editor().has_buffer(2));
|
||
session.handle(Input::Char('Z'));
|
||
assert!(!session.editor().has_buffer(2));
|
||
assert_eq!(std::fs::read_to_string(second).unwrap(), "second");
|
||
}
|
||
|
||
#[test]
|
||
fn raw_builtin_aliases_are_not_external_binding_commands() {
|
||
let fixture = Fixture::new();
|
||
for name in [
|
||
"quit",
|
||
"save",
|
||
"buffer_next",
|
||
"q",
|
||
"w",
|
||
"bnext",
|
||
"q!",
|
||
"wq",
|
||
"bprev",
|
||
"bdelete",
|
||
"bdelete!",
|
||
"core.quit",
|
||
] {
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
|
||
);
|
||
assert!(load_config(&config).is_err(), "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() {
|
||
let fixture = Fixture::new();
|
||
for name in [
|
||
"quit",
|
||
"save",
|
||
"buffer_next",
|
||
"q",
|
||
"w",
|
||
"bnext",
|
||
"q!",
|
||
"wq",
|
||
"bprev",
|
||
"bdelete",
|
||
"bdelete!",
|
||
] {
|
||
let path = fixture.file(
|
||
"plugin.rb",
|
||
&format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"),
|
||
);
|
||
assert!(
|
||
RubyPlugin::start(&path, &serde_json::json!({})).is_err(),
|
||
"plugin shadowed built-in command {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
|
||
let fixture = Fixture::new();
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
r#"
|
||
puts "configuration noise"
|
||
Vamp.configure do |config|
|
||
config.plugin "plugins/greeting.rb"
|
||
config.bind "g", "greet"
|
||
end
|
||
"#,
|
||
);
|
||
let loaded = load_config(&config).unwrap();
|
||
assert_eq!(
|
||
loaded.plugins[0].path,
|
||
fixture.0.join("plugins/greeting.rb")
|
||
);
|
||
assert_eq!(loaded.plugins[0].options, serde_json::json!({}));
|
||
let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }");
|
||
assert!(
|
||
load_config(&invalid)
|
||
.unwrap_err()
|
||
.contains("must be a String")
|
||
);
|
||
for target in [
|
||
"7",
|
||
"{}",
|
||
"config.core.lsp",
|
||
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
|
||
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
|
||
"config.core.diff.tap { |action| action.name = 'core.delete' }",
|
||
"config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus",
|
||
"config.core.diff.close.tap { |action| action.name = 'core.delete' }",
|
||
] {
|
||
let invalid = fixture.file(
|
||
"invalid_action.rb",
|
||
&format!("Vamp.configure {{ |config| config.bind 'K', {target} }}"),
|
||
);
|
||
assert!(load_config(&invalid).is_err(), "{target}");
|
||
}
|
||
let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true");
|
||
assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err());
|
||
let invalid = fixture.file("syntax.rb", "this is not valid ruby (");
|
||
assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
|
||
let fixture = Fixture::new();
|
||
let defaults = Config::default();
|
||
assert_eq!(defaults.leader, "space");
|
||
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
|
||
assert!(defaults.lsp.is_empty());
|
||
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||
assert_eq!(load_config(&path).unwrap().leader, "space");
|
||
for (source, key) in [
|
||
(r#"Vamp.configure { |config| config.leader = "," }"#, ","),
|
||
(r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"),
|
||
] {
|
||
let path = fixture.file("config.rb", source);
|
||
assert_eq!(load_config(&path).unwrap().leader, key);
|
||
}
|
||
for (value, error) in [
|
||
("7", "leader key must be a String"),
|
||
("nil", "leader key must be a String"),
|
||
(r#""""#, "leader key must not be empty"),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.leader = {value} }}"),
|
||
);
|
||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||
}
|
||
for frame in [
|
||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||
] {
|
||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn git_gutter_config_modes_default_and_rejection() {
|
||
use crate::gutter::GutterStyle;
|
||
let fixture = Fixture::new();
|
||
let path = fixture.file("config.rb", "Vamp.configure { |_config| }");
|
||
assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar);
|
||
for (name, expected) in [
|
||
("bar", GutterStyle::Bar),
|
||
("symbols", GutterStyle::Symbols),
|
||
("line_numbers", GutterStyle::LineNumbers),
|
||
("none", GutterStyle::None),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"),
|
||
);
|
||
assert_eq!(load_config(&path).unwrap().git_gutter, expected);
|
||
}
|
||
for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"),
|
||
);
|
||
assert!(load_config(&path).is_err(), "{value}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
||
let fixture = Fixture::new();
|
||
let config = fixture.file("config.rb", "Vamp.configure { |_config| }");
|
||
assert!(load_config(&config).unwrap().git_blame);
|
||
let config = fixture.file("config.rb", r#"
|
||
Vamp.configure do |config|
|
||
config.git_blame = false
|
||
config.bind "G", config.core.git_blame.toggle
|
||
raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen?
|
||
end
|
||
"#);
|
||
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
|
||
assert!(!session.editor().git_blame());
|
||
session.handle(Input::Char('G'));
|
||
assert!(session.editor().git_blame());
|
||
session.execute("git_blame.toggle extra");
|
||
assert!(session.editor().git_blame());
|
||
session.execute("git_blame.toggle");
|
||
assert!(!session.editor().git_blame());
|
||
session.handle(Input::Ctrl('p'));
|
||
for ch in "git blame".chars() {
|
||
session.handle(Input::Char(ch));
|
||
}
|
||
assert_eq!(session.editor().palette().unwrap().entries.len(), 1);
|
||
session.handle(Input::Enter);
|
||
assert!(session.editor().git_blame());
|
||
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
|
||
let config = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.git_blame = {value} }}"),
|
||
);
|
||
assert!(
|
||
load_config(&config)
|
||
.unwrap_err()
|
||
.contains("git_blame must be true or false")
|
||
);
|
||
}
|
||
let mut frame = serde_json::json!({
|
||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||
"review_directory": null,
|
||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||
"git_gutter": "bar",
|
||
"scroll_distance": 0,
|
||
"highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {},
|
||
"whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"diff_follow_working_copy": true
|
||
});
|
||
assert!(serde_json::from_value::<Response>(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::<Response>(frame.clone()).is_err());
|
||
}
|
||
frame.as_object_mut().unwrap().remove("git_blame");
|
||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn trailing_whitespace_configuration_rejects_non_booleans() {
|
||
let fixture = Fixture::new();
|
||
for value in ["nil", "0", "\"true\"", "[]", "{}"] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"),
|
||
);
|
||
assert!(
|
||
load_config(&path)
|
||
.unwrap_err()
|
||
.contains("highlight_trailing_whitespace must be true or false")
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn whitespace_characters_validate_real_ruby_configuration() {
|
||
let fixture = Fixture::new();
|
||
for value in [
|
||
"nil",
|
||
"false",
|
||
"[]",
|
||
r#"{ space: "·" }"#,
|
||
r#"{ " " => 1 }"#,
|
||
r#"{ "" => "." }"#,
|
||
r#"{ " " => "." }"#,
|
||
r#"{ " " => ".." }"#,
|
||
r#"{ "x" => "." }"#,
|
||
r#"{ "\u000b" => "." }"#,
|
||
r#"{ "\r" => "." }"#,
|
||
r#"{ "\u000c" => "." }"#,
|
||
r#"{ "\u0085" => "." }"#,
|
||
r#"{ "\u200b" => "." }"#,
|
||
r#"{ " " => "\t" }"#,
|
||
r#"{ " " => "\n" }"#,
|
||
r#"{ " " => "\u0301" }"#,
|
||
r#"{ " " => "界" }"#,
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
|
||
);
|
||
assert!(load_config(&path).is_err(), "{value}");
|
||
}
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
|
||
);
|
||
assert_eq!(
|
||
load_config(&path).unwrap().whitespace_characters,
|
||
BTreeMap::from([
|
||
(' ', '·'),
|
||
('\t', '→'),
|
||
('\n', '↵'),
|
||
('\u{a0}', '⍽'),
|
||
('\u{3000}', '_'),
|
||
]),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() {
|
||
use crate::editor::WhitespaceScope;
|
||
|
||
let fixture = Fixture::new();
|
||
let path = fixture.file("config.rb", "Vamp.configure {}");
|
||
assert_eq!(
|
||
load_config(&path).unwrap().whitespace_scope,
|
||
WhitespaceScope::All
|
||
);
|
||
for (value, expected) in [
|
||
("all", WhitespaceScope::All),
|
||
("leading", WhitespaceScope::Leading),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#),
|
||
);
|
||
assert_eq!(load_config(&path).unwrap().whitespace_scope, expected);
|
||
}
|
||
for value in [
|
||
"nil",
|
||
"false",
|
||
"0",
|
||
"[]",
|
||
"{}",
|
||
":all",
|
||
r#""""#,
|
||
r#""unknown""#,
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"),
|
||
);
|
||
assert!(load_config(&path).is_err(), "{value}");
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!(
|
||
r#"Vamp.configure do |c|
|
||
c.whitespace_scope = "leading"
|
||
begin
|
||
c.whitespace_scope = {value}
|
||
raise "invalid scope accepted"
|
||
rescue TypeError, ArgumentError
|
||
end
|
||
end"#,
|
||
),
|
||
);
|
||
assert_eq!(
|
||
load_config(&path).unwrap().whitespace_scope,
|
||
WhitespaceScope::Leading
|
||
);
|
||
}
|
||
let mut frame = serde_json::json!({
|
||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||
"review_directory": null,
|
||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {}, "whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"diff_follow_working_copy": true
|
||
});
|
||
for value in ["all", "leading"] {
|
||
frame["whitespace_scope"] = serde_json::json!(value);
|
||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||
}
|
||
for value in [
|
||
serde_json::Value::Null,
|
||
serde_json::json!(false),
|
||
serde_json::json!(0),
|
||
serde_json::json!([]),
|
||
serde_json::json!({}),
|
||
serde_json::json!(""),
|
||
serde_json::json!("unknown"),
|
||
] {
|
||
frame["whitespace_scope"] = value;
|
||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||
}
|
||
frame.as_object_mut().unwrap().remove("whitespace_scope");
|
||
assert!(serde_json::from_value::<Response>(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::<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": {},
|
||
"review_directory": null,
|
||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||
"scroll_distance": 0,
|
||
"highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {},
|
||
"whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"popup": {"command": {}, "search": {}},
|
||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"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::<Response>(invalid_frame)
|
||
.err()
|
||
.unwrap();
|
||
assert!(error.to_string().contains(setting), "{value}: {error}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn config_scroll_step_lines_controls_counted_scrolling_and_rejects_invalid_values() {
|
||
let fixture = Fixture::new();
|
||
for (value, expected) in [
|
||
("nil", 30),
|
||
("1", 3),
|
||
("7", 21),
|
||
("18446744073709551615", isize::MAX),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!(
|
||
"Vamp.configure do |config|
|
||
config.scroll_step_lines = {value}
|
||
config.bind \"alt+j\", config.core.scroll_step_down
|
||
config.bind \"alt+k\", config.core.scroll_step_up
|
||
end"
|
||
),
|
||
);
|
||
let mut session =
|
||
crate::session::EditorSession::open(None, vec![], Some(&path)).unwrap();
|
||
session.handle(crate::editor::Input::Char('3'));
|
||
session.handle(crate::editor::Input::Alt('j'));
|
||
assert_eq!(
|
||
session.editor().take_viewport_scroll(20),
|
||
expected,
|
||
"{value}"
|
||
);
|
||
session.handle(crate::editor::Input::Char('3'));
|
||
session.handle(crate::editor::Input::Alt('k'));
|
||
let up = if expected == isize::MAX {
|
||
isize::MIN
|
||
} else {
|
||
-expected
|
||
};
|
||
assert_eq!(session.editor().take_viewport_scroll(20), up, "{value}");
|
||
}
|
||
for value in [
|
||
"0",
|
||
"-1",
|
||
"1.5",
|
||
"\"3\"",
|
||
"true",
|
||
"false",
|
||
"18446744073709551616",
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.scroll_step_lines = {value} }}"),
|
||
);
|
||
let error = load_config(&path).unwrap_err();
|
||
assert!(
|
||
error.contains("scroll_step_lines") && error.contains("must be"),
|
||
"{value}: {error}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
|
||
let fixture = Fixture::new();
|
||
let frame = serde_json::json!({
|
||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||
"review_directory": null,
|
||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||
"highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {},
|
||
"whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||
"popup": {"command": {}, "search": {}},
|
||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"diff_follow_working_copy": true, "scroll_distance": 0
|
||
});
|
||
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
||
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||
);
|
||
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
|
||
let mut valid_frame = frame.clone();
|
||
valid_frame["scroll_distance"] = serde_json::json!(distance);
|
||
let response = serde_json::from_value::<Response>(valid_frame).unwrap();
|
||
let Response::Config(config) = response else {
|
||
panic!("expected a configuration frame");
|
||
};
|
||
assert_eq!(config.scroll_distance, distance);
|
||
}
|
||
for value in [
|
||
"-1",
|
||
"1.5",
|
||
"\"3\"",
|
||
"true",
|
||
"false",
|
||
"18446744073709551616",
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||
);
|
||
let error = load_config(&path).unwrap_err();
|
||
assert!(
|
||
error.contains("scroll_distance") && error.contains("must be"),
|
||
"{value}: {error}"
|
||
);
|
||
let mut invalid_frame = frame.clone();
|
||
invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap();
|
||
let error = serde_json::from_value::<Response>(invalid_frame)
|
||
.err()
|
||
.unwrap();
|
||
assert!(
|
||
error.to_string().contains("scroll_distance"),
|
||
"{value}: {error}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
|
||
let fixture = Fixture::new();
|
||
for source in [
|
||
"Vamp.configure { |_| }",
|
||
r#"Vamp.configure do |config|
|
||
config.lsp["rust"] = false
|
||
config.lsp["python"] = false
|
||
config.lsp["not-a-preset"] = false
|
||
end"#,
|
||
] {
|
||
let path = fixture.file("config.rb", source);
|
||
let config = load_config(&path).unwrap();
|
||
assert!(config.lsp.is_empty());
|
||
assert!(config.plugins.is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() {
|
||
let fixture = Fixture::new();
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure do |config|
|
||
config.lsp["rust"] = true
|
||
config.lsp["python"] = true
|
||
config.lsp["ruby"] = true
|
||
end"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
assert_eq!(config.lsp.len(), 4);
|
||
for (language, command, extension) in [
|
||
("rust", &["rust-analyzer"][..], "rs"),
|
||
("ruby", &["solargraph", "stdio"][..], "rb"),
|
||
] {
|
||
let server = config
|
||
.lsp
|
||
.iter()
|
||
.find(|server| server.language_id == language)
|
||
.unwrap();
|
||
assert_eq!(server.command, command);
|
||
assert_eq!(server.extensions, [extension]);
|
||
assert_eq!(server.initialization_options, serde_json::json!({}));
|
||
assert_eq!(server.settings, serde_json::json!({}));
|
||
}
|
||
let python: Vec<_> = config
|
||
.lsp
|
||
.iter()
|
||
.filter(|server| server.language_id == "python")
|
||
.collect();
|
||
assert_eq!(python.len(), 2);
|
||
for (server, command, initialization_options) in [
|
||
(
|
||
python[0],
|
||
["ty", "server"],
|
||
serde_json::json!({"experimental": {"useUv": "off"}}),
|
||
),
|
||
(python[1], ["ruff", "server"], serde_json::json!({})),
|
||
] {
|
||
assert_eq!(server.command, command);
|
||
assert_eq!(server.extensions, ["py"]);
|
||
assert_eq!(server.initialization_options, initialization_options);
|
||
assert_eq!(server.settings, serde_json::json!({}));
|
||
}
|
||
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure do |config|
|
||
config.lsp["rust"] = {
|
||
extensions: ["rs", "ron"],
|
||
initialization_options: {cargo: {features: ["α"]}},
|
||
settings: {"rust-analyzer" => {check: {command: "clippy"}}}
|
||
}
|
||
config.lsp["python"] = {command: ["custom-python-server", "--stdio"]}
|
||
config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]}
|
||
config.lsp["my-language.c++"] = {
|
||
command: ["custom server", "--flag", ""],
|
||
extensions: ["custom", "لغة"],
|
||
settings: {nested: {enabled: true, limit: 3, unset: nil}}
|
||
}
|
||
end"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
let rust = config
|
||
.lsp
|
||
.iter()
|
||
.find(|server| server.language_id == "rust")
|
||
.unwrap();
|
||
assert_eq!(rust.command, ["rust-analyzer"]);
|
||
assert_eq!(rust.extensions, ["rs", "ron"]);
|
||
assert_eq!(
|
||
rust.initialization_options,
|
||
serde_json::json!({"cargo": {"features": ["α"]}})
|
||
);
|
||
assert_eq!(
|
||
rust.settings,
|
||
serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}})
|
||
);
|
||
let python = config
|
||
.lsp
|
||
.iter()
|
||
.find(|server| server.language_id == "python")
|
||
.unwrap();
|
||
assert_eq!(python.command, ["custom-python-server", "--stdio"]);
|
||
assert_eq!(python.extensions, ["py"]);
|
||
assert_eq!(python.initialization_options, serde_json::json!({}));
|
||
assert_eq!(python.settings, serde_json::json!({}));
|
||
assert_eq!(
|
||
config
|
||
.lsp
|
||
.iter()
|
||
.filter(|server| server.language_id == "python")
|
||
.count(),
|
||
1
|
||
);
|
||
let ruby = config
|
||
.lsp
|
||
.iter()
|
||
.find(|server| server.language_id == "ruby")
|
||
.unwrap();
|
||
assert_eq!(ruby.command, ["bundle", "exec", "solargraph", "stdio"]);
|
||
assert_eq!(ruby.extensions, ["rb"]);
|
||
let custom = config
|
||
.lsp
|
||
.iter()
|
||
.find(|server| server.language_id == "my-language.c++")
|
||
.unwrap();
|
||
assert_eq!(custom.command, ["custom server", "--flag", ""]);
|
||
assert_eq!(custom.extensions, ["custom", "لغة"]);
|
||
assert_eq!(custom.initialization_options, serde_json::json!({}));
|
||
assert_eq!(
|
||
custom.settings,
|
||
serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_python_common_options_and_ordered_server_overrides() {
|
||
let fixture = Fixture::new();
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure do |config|
|
||
config.lsp["python"] = {
|
||
extensions: ["py", "pyi"],
|
||
initialization_options: {diagnosticMode: "workspace"},
|
||
settings: {shared: true}
|
||
}
|
||
end"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
assert_eq!(config.lsp.len(), 2);
|
||
for (server, command) in config
|
||
.lsp
|
||
.iter()
|
||
.zip([["ty", "server"], ["ruff", "server"]])
|
||
{
|
||
assert_eq!(server.language_id, "python");
|
||
assert_eq!(server.command, command);
|
||
assert_eq!(server.extensions, ["py", "pyi"]);
|
||
assert_eq!(
|
||
server.initialization_options,
|
||
serde_json::json!({"diagnosticMode": "workspace"})
|
||
);
|
||
assert_eq!(server.settings, serde_json::json!({"shared": true}));
|
||
}
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure do |config|
|
||
config.lsp["python"] = {servers: [
|
||
{command: ["type-server", "--stdio"], initialization_options: {experimental: {useUv: "off"}}, settings: {first: true}},
|
||
{command: ["lint-server"], initialization_options: {settings: {lint: {enable: true}}}}
|
||
]}
|
||
config.lsp["custom"] = {extensions: ["custom"], servers: [
|
||
{command: ["custom-first"]}, {command: ["custom-second"], settings: {second: true}}
|
||
]}
|
||
end"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
for (language, commands, extensions) in [
|
||
(
|
||
"python",
|
||
[&["type-server", "--stdio"][..], &["lint-server"][..]],
|
||
["py"],
|
||
),
|
||
(
|
||
"custom",
|
||
[&["custom-first"][..], &["custom-second"][..]],
|
||
["custom"],
|
||
),
|
||
] {
|
||
let servers: Vec<_> = config
|
||
.lsp
|
||
.iter()
|
||
.filter(|server| server.language_id == language)
|
||
.collect();
|
||
assert_eq!(servers.len(), 2);
|
||
for (server, command) in servers.iter().zip(commands) {
|
||
assert_eq!(server.command, command);
|
||
assert_eq!(server.extensions, extensions);
|
||
}
|
||
}
|
||
let python: Vec<_> = config
|
||
.lsp
|
||
.iter()
|
||
.filter(|server| server.language_id == "python")
|
||
.collect();
|
||
assert_eq!(
|
||
python[0].initialization_options,
|
||
serde_json::json!({"experimental": {"useUv": "off"}})
|
||
);
|
||
assert_eq!(python[0].settings, serde_json::json!({"first": true}));
|
||
assert_eq!(
|
||
python[1].initialization_options,
|
||
serde_json::json!({"settings": {"lint": {"enable": true}}})
|
||
);
|
||
assert_eq!(python[1].settings, serde_json::json!({}));
|
||
let custom: Vec<_> = config
|
||
.lsp
|
||
.iter()
|
||
.filter(|server| server.language_id == "custom")
|
||
.collect();
|
||
assert_eq!(custom[0].initialization_options, serde_json::json!({}));
|
||
assert_eq!(custom[1].settings, serde_json::json!({"second": true}));
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"Vamp.configure { |config| config.lsp["python"] = {settings: {shared: true}} }"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
assert_eq!(
|
||
config.lsp[0].initialization_options,
|
||
serde_json::json!({"experimental": {"useUv": "off"}})
|
||
);
|
||
assert_eq!(config.lsp[1].initialization_options, serde_json::json!({}));
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_server_lists_reject_invalid_shapes_and_ambiguous_options_locally() {
|
||
let fixture = Fixture::new();
|
||
for (options, error) in [
|
||
("{servers: nil}", "nonempty array"),
|
||
("{servers: {command: ['server']}}", "nonempty array"),
|
||
("{servers: []}", "nonempty array"),
|
||
(
|
||
"{servers: [true]}",
|
||
"servers[0] must be a configuration object",
|
||
),
|
||
("{servers: [{}]}", "servers[0] requires command argv"),
|
||
("{servers: [{command: []}]}", "servers[0] command must be"),
|
||
(
|
||
"{servers: [{command: ['server'], extensions: ['py']}]}",
|
||
"servers[0] has unknown option",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server'], servers: []}]}",
|
||
"servers[0] has unknown option",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server'], initialization_options: []}]}",
|
||
"servers[0] initialization_options must be",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server']}, {command: ['other'], settings: nil}]}",
|
||
"servers[1] settings must be",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server']}], command: ['other']}",
|
||
"cannot be combined",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server']}], initialization_options: {}}",
|
||
"cannot be combined",
|
||
),
|
||
(
|
||
"{servers: [{command: ['server']}], settings: {}}",
|
||
"cannot be combined",
|
||
),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.lsp[\"python\"] = {options} }}"),
|
||
);
|
||
let actual = load_config(&path).unwrap_err();
|
||
assert!(
|
||
actual.starts_with("Ruby config: LSP language \"python\""),
|
||
"{actual}"
|
||
);
|
||
assert!(actual.contains(error), "{options}: {actual}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_server_limit_is_enforced_at_configuration_startup() {
|
||
let fixture = Fixture::new();
|
||
for count in [16, 17] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!(
|
||
"Vamp.configure {{ |config| config.lsp[\"python\"] = {{servers: Array.new({count}) {{ {{command: [\"server\"]}} }} }} }}"
|
||
),
|
||
);
|
||
let result = load_config(&path);
|
||
if count == 16 {
|
||
assert_eq!(result.unwrap().lsp.len(), 16);
|
||
} else {
|
||
assert_eq!(
|
||
result.unwrap_err(),
|
||
"Ruby config: LSP configuration supports at most 16 configured servers"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_configuration_rejects_invalid_keys_values_and_json_options() {
|
||
let fixture = Fixture::new();
|
||
for assignment in [
|
||
r#"config.lsp[:rust] = true"#,
|
||
r#"config.lsp[7] = false"#,
|
||
r#"config.lsp[""] = false"#,
|
||
r#"config.lsp["bad\nkey"] = false"#,
|
||
r#"config.lsp["\xFF".b] = false"#,
|
||
r#"config.lsp["rust"] = nil"#,
|
||
r#"config.lsp["rust"] = []"#,
|
||
r#"config.lsp["rust"] = "yes""#,
|
||
r#"config.lsp["custom"] = {}"#,
|
||
r#"config.lsp["custom"] = {command: ["server"]}"#,
|
||
r#"config.lsp["custom"] = {servers: [{command: ["server"]}]}"#,
|
||
r#"config.lsp["custom"] = {extensions: ["custom"]}"#,
|
||
r#"config.lsp["rust"] = {command: "server"}"#,
|
||
r#"config.lsp["rust"] = {command: []}"#,
|
||
r#"config.lsp["rust"] = {command: [""]}"#,
|
||
r#"config.lsp["rust"] = {command: ["server", 7]}"#,
|
||
r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#,
|
||
r#"config.lsp["rust"] = {extensions: "rs"}"#,
|
||
r#"config.lsp["rust"] = {extensions: []}"#,
|
||
r#"config.lsp["rust"] = {extensions: [""]}"#,
|
||
r#"config.lsp["rust"] = {extensions: [7]}"#,
|
||
r#"config.lsp["rust"] = {extensions: [".rs"]}"#,
|
||
r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#,
|
||
r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#,
|
||
r#"config.lsp["rust"] = {extensions: ["r s"]}"#,
|
||
r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#,
|
||
r#"config.lsp["rust"] = {initialization_options: []}"#,
|
||
r#"config.lsp["rust"] = {settings: nil}"#,
|
||
r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#,
|
||
r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#,
|
||
r#"config.lsp["rust"] = {settings: {7 => true}}"#,
|
||
r#"config.lsp["rust"] = {unknown: true}"#,
|
||
] {
|
||
let path = fixture.file(
|
||
"invalid.rb",
|
||
&format!("Vamp.configure {{ |config| {assignment} }}"),
|
||
);
|
||
assert!(load_config(&path).is_err(), "{assignment}");
|
||
}
|
||
let path = fixture.file(
|
||
"invalid.rb",
|
||
r#"Vamp.configure { |config| config.lsp["unsupported-language"] = true }"#,
|
||
);
|
||
let error = load_config(&path).unwrap_err();
|
||
assert!(!error.contains("Malformed Ruby protocol frame"), "{error}");
|
||
assert!(
|
||
error.contains("\"unsupported-language\"")
|
||
&& error.contains("command")
|
||
&& error.contains("extensions"),
|
||
"{error}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_wire_configuration_is_resolved_after_decoding() {
|
||
let mut frame = serde_json::json!({
|
||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||
"review_directory": null,
|
||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||
"git_blame": true,
|
||
"git_gutter": "bar",
|
||
"scroll_distance": 0,
|
||
"highlight_trailing_whitespace": false,
|
||
"whitespace_characters": {},
|
||
"whitespace_scope": "all",
|
||
"tab_spaces": null, "language_tab_spaces": {},
|
||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||
"popup": {"command": {}, "search": {}},
|
||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||
"resolve_history_layout": "columns",
|
||
"diff_follow_working_copy": true
|
||
});
|
||
let Response::Config(config) = serde_json::from_value::<Response>(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::<Response>(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::<Response>(frame.clone()).unwrap()
|
||
else {
|
||
panic!("expected config frame");
|
||
};
|
||
assert!(crate::lsp_config::resolve(config.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(config) = serde_json::from_value::<Response>(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::<Response>(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::<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.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<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()
|
||
);
|
||
}
|
||
}
|