Files
vamp/tests/unit/ruby.rs
T
2026-10-21 11:28:55 +02:00

2359 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::{editor::Input, session::EditorSession};
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = env::temp_dir().join(format!(
"vamp-ruby-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, text).unwrap();
path
}
fn plugin(&self, text: &str) -> (RubyPlugin, Feature) {
self.plugin_with_options(text, &serde_json::json!({}))
}
fn plugin_with_options(
&self,
text: &str,
options: &serde_json::Value,
) -> (RubyPlugin, Feature) {
RubyPlugin::start(&self.file("plugin.rb", text), options).unwrap_or_else(|error| {
panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}")
})
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn context() -> Context {
Context {
buffer_id: 7,
revision: 11,
root: "/project".into(),
text: "α\nhello".into(),
row: 2,
column: 3,
mode: "NORMAL".into(),
path: Some("buffer.txt".into()),
}
}
#[test]
fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
let fixture = Fixture::new();
let absolute = fixture.0.join("absolute-reviews");
for (value, expected) in [
(
r#""relative-reviews""#.to_owned(),
Some(fixture.0.join("relative-reviews")),
),
(serde_json::to_string(&absolute).unwrap(), Some(absolute)),
("nil".to_owned(), None),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
);
assert_eq!(load_config(&path).unwrap().review_directory, expected);
let session = EditorSession::open(None, vec![], Some(&path)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
if let Some(directory) = expected {
assert!(
!directory.exists(),
"startup created {}",
directory.display()
);
}
}
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
assert_eq!(load_config(&path).unwrap().review_directory, None);
for value in [
"7",
"false",
"[]",
"{}",
":reviews",
r#""""#,
r#""bad\0path""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.review_directory = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(error.contains("review_directory"), "{value}: {error}");
}
}
#[test]
fn review_directory_and_review_symbol_wire_fields_are_required_and_validated() {
let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
"review_directory": null, "leader": "space",
"file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {},
"scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false,
"whitespace_characters": {}, "whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
frame["review_directory"] = value;
assert!(serde_json::from_value::<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["review_directory"] = serde_json::Value::Null;
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in ["", "👩‍🚀 R", "ok"] {
frame[field] = serde_json::json!(value);
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
}
for value in [
serde_json::json!(null),
serde_json::json!(false),
serde_json::json!(7),
serde_json::json!([]),
serde_json::json!({}),
serde_json::json!("\0"),
serde_json::json!("\n"),
serde_json::json!("\t"),
serde_json::json!("\u{7f}"),
serde_json::json!("\u{85}"),
] {
frame[field] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
frame[field] = serde_json::json!("");
}
frame.as_object_mut().unwrap().remove("review_directory");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn pull_request_symbols_load_defaults_custom_and_empty_and_reject_invalid_ruby_values() {
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, "🙏");
assert_eq!(config.pull_request_approved_symbol, "👍");
for symbol in ["", "👩‍🚀 R", "ok"] {
let value = serde_json::to_string(symbol).unwrap();
let path = fixture.file("config.rb", &format!(
"Vamp.configure {{ |c| c.pull_request_review_requested_symbol = {value}; c.pull_request_approved_symbol = {value} }}"
));
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, symbol);
assert_eq!(config.pull_request_approved_symbol, symbol);
}
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in [
"nil",
"false",
"7",
"[]",
"{}",
":ok",
r#""\n""#,
r#""\t""#,
r#""\0""#,
r#""\u007f""#,
r#""\u0085""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.{field} = {value} }}"),
);
assert!(
load_config(&path).unwrap_err().contains(field),
"{field}: {value}"
);
}
}
}
#[test]
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"
Vamp.configure do |c|
c.tab_spaces = 4
c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5)
c.language_tab_spaces["cpp"] = 3
end
"#,
);
let widest = " ".repeat(255);
for (name, prefix, inserted) in [
("default.rs", "", "\t"),
("buffer.rs", "", " "),
("buffer.rb", "", " "),
("Gemfile", "", " "),
("script", "#!/usr/bin/env ruby\n", " "),
("Makefile", "", "\t"),
("plain.txt", "", " "),
("buffer.json", "", " "),
("buffer.yaml", "", widest.as_str()),
("buffer.cpp", "", " "),
("buffer.tsx", "", " "),
] {
let source = format!("{prefix}αx\nβy");
let path = fixture.file(name, &source);
let config = (name != "default.rs").then_some(config.as_path());
let mut session = EditorSession::open(Some(path), vec![], config).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
if !prefix.is_empty() {
session.handle(Input::Char('j'));
}
session.handle(Input::Ctrl('e'));
for input in [
Input::Char('l'),
Input::Char('i'),
Input::Char('\t'),
Input::Ctrl('i'),
Input::Escape,
] {
session.handle(input);
}
let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y");
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, source, "{name}");
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('u'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('i'));
session.handle(Input::End);
session.handle(Input::Paste("\t".into()));
assert_eq!(
session.editor().context().text,
format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"),
"{name}"
);
}
}
#[test]
fn tab_spaces_reject_invalid_widths_and_language_maps() {
let fixture = Fixture::new();
for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] {
for assignment in [
format!("c.tab_spaces = {value}"),
format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#),
format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#),
format!(r#"c.language_tab_spaces["ruby"] = {value}"#),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| {assignment} }}"),
);
let error = load_config(&config).unwrap_err();
assert!(
error.contains("tab_spaces must be nil or an Integer"),
"{assignment}: {error}"
);
}
}
for value in [
"nil",
"[]",
r#"{ "" => 2 }"#,
r#"{ :ruby => 2 }"#,
r#"{ "\n" => 2 }"#,
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"),
);
assert!(load_config(&config).is_err(), "{value}");
}
}
#[test]
fn language_tab_space_mutations_reject_invalid_keys_before_serialization() {
let fixture = Fixture::new();
for key in [r#""""#, ":ruby", r#""\n""#, "7"] {
for assignment in [
format!("c.language_tab_spaces.merge!({key} => 2)"),
format!("c.language_tab_spaces[{key}] = 2"),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| {assignment} }}"),
);
let error = load_config(&config).unwrap_err();
assert!(error.contains("Tab language"), "{assignment}: {error}");
}
}
}
#[test]
fn actual_ruby_dsl_context_queued_effects_and_output_isolation() {
let fixture = Fixture::new();
let (mut plugin, feature) = fixture.plugin(r#"
puts "startup noise"
STDOUT.write "direct startup noise\n"
warn "warning"
Vamp.plugin "greeting", requires: ["text"]
Vamp.command "greet" do |editor, args|
puts "invocation noise"
STDOUT.write "direct invocation noise\n"
raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt"
raise "bad source" unless editor.buffer_id == 7 && editor.revision == 11 && editor.root == "/project" && editor.request_id > 0
editor.insert "Hello #{args}!"
editor.replace editor.text
editor.message "Greeting inserted"
editor.label :battery, "界 e\u0301 👩‍🚀\t\n\e"
editor.label "battery", ""
end
"#);
assert_eq!(feature.name, "greeting");
assert_eq!(feature.required_capabilities, ["text"]);
assert_eq!(feature.commands, ["greet"]);
assert!(!feature.core);
assert!(!plugin.is_async());
for _ in 0..2 {
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
assert!(
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }]
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
&& name == "battery" && cleared_name == "battery"
&& label == "界 e\u{301} 👩‍🚀\t\n\u{1b}" && cleared.is_empty())
);
}
plugin.disable();
plugin.disable();
assert!(
plugin
.invoke("greet", "", &context())
.unwrap_err()
.contains("disabled")
);
assert!(
plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none()
);
}
#[test]
fn generated_core_actions_execute_through_ruby_configuration() {
use crate::{
editor::{Input, Mode},
session::EditorSession,
};
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "Q", config.core.quit
config.bind "F", config.core.force_quit
config.bind "W", config.core.save
config.bind "X", config.core.save_and_quit
config.bind "E", config.core.edit
config.bind "B", config.core.buffer
config.bind "P", config.core.plugins
config.bind "N", config.core.buffer_next
config.bind "V", config.core.buffer_previous
config.bind "D", config.core.buffer_delete
config.bind "Z", config.core.buffer_force_delete
config.bind "R", config.core.diff
config.bind "ctrl+t", config.core.diff.close
config.bind "H", config.core.word_wrap.toggle
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
config.core.diff.close.name.frozen?
end"#,
);
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap();
assert!(!review.editor().word_wrap());
review.handle(Input::Char('H'));
assert!(review.editor().word_wrap());
review.execute("word_wrap.toggle unexpected");
assert!(review.editor().word_wrap());
review.execute("word_wrap.toggle");
assert!(!review.editor().word_wrap());
review.handle(Input::Char('R'));
review.handle(Input::Escape);
assert!(review.editor().diff().is_none());
review.handle(Input::Char('R'));
assert!(review.editor().diff().is_some());
review.handle(Input::Ctrl('t'));
assert!(review.editor().diff().is_none());
for (key, dirty, quit, saved) in [
('Q', false, true, false),
('Q', true, false, false),
('F', true, true, false),
('W', true, false, true),
('X', true, true, true),
] {
let buffer = fixture.file("buffer.txt", "base");
let mut session =
EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
if dirty {
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
session.handle(input);
}
}
session.handle(Input::Char(key));
assert_eq!(session.editor().should_quit(), quit, "{key}");
assert_eq!(
std::fs::read_to_string(&buffer).unwrap(),
if saved { "!base" } else { "base" },
"{key}"
);
assert_eq!(session.editor().dirty(), dirty && !saved, "{key}");
}
let buffer = fixture.file("buffer.txt", "base");
let second = fixture.file("second.txt", "second");
let mut session =
EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session.handle(Input::Char('E'));
assert_eq!(session.editor().mode(), Mode::Command);
assert_eq!(session.editor().command_line(), "edit ");
for ch in second.to_string_lossy().chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
assert_eq!(session.editor().lines(), &["second"]);
session.handle(Input::Char('B'));
assert_eq!(session.editor().mode(), Mode::Command);
assert_eq!(session.editor().command_line(), "buffer ");
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Char('P'));
assert!(session.editor().message().contains("text"));
session.handle(Input::Char('N'));
assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('V'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Char('D'));
assert!(!session.editor().has_buffer(1));
assert_eq!(session.editor().buffer_id(), 2);
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
session.handle(input);
}
session.handle(Input::Char('D'));
assert!(session.editor().has_buffer(2));
session.handle(Input::Char('Z'));
assert!(!session.editor().has_buffer(2));
assert_eq!(std::fs::read_to_string(second).unwrap(), "second");
}
#[test]
fn raw_builtin_aliases_are_not_external_binding_commands() {
let fixture = Fixture::new();
for name in [
"quit",
"save",
"buffer_next",
"q",
"w",
"bnext",
"q!",
"wq",
"bprev",
"bdelete",
"bdelete!",
"core.quit",
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
);
assert!(load_config(&config).is_err(), "{name}");
}
}
#[test]
fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() {
let fixture = Fixture::new();
for name in [
"quit",
"save",
"buffer_next",
"q",
"w",
"bnext",
"q!",
"wq",
"bprev",
"bdelete",
"bdelete!",
] {
let path = fixture.file(
"plugin.rb",
&format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"),
);
assert!(
RubyPlugin::start(&path, &serde_json::json!({})).is_err(),
"plugin shadowed built-in command {name}"
);
}
}
#[test]
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"
puts "configuration noise"
Vamp.configure do |config|
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
end
"#,
);
let loaded = load_config(&config).unwrap();
assert_eq!(
loaded.plugins[0].path,
fixture.0.join("plugins/greeting.rb")
);
assert_eq!(loaded.plugins[0].options, serde_json::json!({}));
let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }");
assert!(
load_config(&invalid)
.unwrap_err()
.contains("must be a String")
);
for target in [
"7",
"{}",
"config.core.lsp",
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
"config.core.diff.tap { |action| action.name = 'core.delete' }",
"config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus",
"config.core.diff.close.tap { |action| action.name = 'core.delete' }",
] {
let invalid = fixture.file(
"invalid_action.rb",
&format!("Vamp.configure {{ |config| config.bind 'K', {target} }}"),
);
assert!(load_config(&invalid).is_err(), "{target}");
}
let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true");
assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err());
let invalid = fixture.file("syntax.rb", "this is not valid ruby (");
assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err());
}
#[test]
fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
let fixture = Fixture::new();
let defaults = Config::default();
assert_eq!(defaults.leader, "space");
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
assert!(defaults.lsp.is_empty());
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
assert_eq!(load_config(&path).unwrap().leader, "space");
for (source, key) in [
(r#"Vamp.configure { |config| config.leader = "," }"#, ","),
(r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"),
] {
let path = fixture.file("config.rb", source);
assert_eq!(load_config(&path).unwrap().leader, key);
}
for (value, error) in [
("7", "leader key must be a String"),
("nil", "leader key must be a String"),
(r#""""#, "leader key must not be empty"),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.leader = {value} }}"),
);
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
] {
assert!(serde_json::from_str::<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",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"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",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for value in ["all", "leading"] {
frame["whitespace_scope"] = serde_json::json!(value);
assert!(serde_json::from_value::<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.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",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
for value in [
"-1",
"1.5",
"\"3\"",
"true",
"false",
"nil",
"18446744073709551616",
"0",
] {
if setting == "diff_context_lines" && value == "0" {
continue;
}
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains(setting) && error.contains("must be"),
"{value}: {error}"
);
let mut invalid_frame = frame.clone();
invalid_frame[setting] =
serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap();
let error = serde_json::from_value::<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",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true, "scroll_distance": 0
});
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
);
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
let mut valid_frame = frame.clone();
valid_frame["scroll_distance"] = serde_json::json!(distance);
let response = serde_json::from_value::<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",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"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()
);
}