2439 lines
89 KiB
Rust
2439 lines
89 KiB
Rust
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["typst"] = 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
|
||
config.lsp["typst"] = true
|
||
end"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
assert_eq!(config.lsp.len(), 5);
|
||
for (language, command, extension) in [
|
||
("rust", &["rust-analyzer"][..], "rs"),
|
||
("ruby", &["solargraph", "stdio"][..], "rb"),
|
||
("typst", &["tinymist", "lsp"][..], "typ"),
|
||
] {
|
||
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()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn pull_request_preferences_distinguish_unset_nil_and_explicit_sections() {
|
||
use crate::pull_requests::{Direction, ReviewFilter, SortField};
|
||
let fixture = Fixture::new();
|
||
for assignment in [
|
||
"",
|
||
"config.pull_request_sort = nil; config.pull_request_filters = nil",
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| {assignment} }}"),
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
assert!(config.pull_request_sort.is_none());
|
||
assert!(config.pull_request_filters.is_none());
|
||
}
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
r#"
|
||
Vamp.configure do |config|
|
||
config.pull_request_sort = {
|
||
updated_at: {precedence: 1, direction: :desc},
|
||
created_at: {direction: :asc}
|
||
}
|
||
config.pull_request_filters = [:requested, :approved]
|
||
end
|
||
"#,
|
||
);
|
||
let config = load_config(&path).unwrap();
|
||
let sort = config.pull_request_sort.unwrap();
|
||
assert_eq!(sort[&SortField::UpdatedAt].precedence, Some(1));
|
||
assert_eq!(sort[&SortField::UpdatedAt].direction, Direction::Desc);
|
||
assert_eq!(sort[&SortField::CreatedAt].precedence, None);
|
||
assert_eq!(sort[&SortField::CreatedAt].direction, Direction::Asc);
|
||
assert_eq!(
|
||
config.pull_request_filters.unwrap(),
|
||
[ReviewFilter::Requested, ReviewFilter::Approved]
|
||
);
|
||
let path = fixture.file("config.rb", "Vamp.configure { |config| config.pull_request_sort = {}; config.pull_request_filters = [] }");
|
||
let config = load_config(&path).unwrap();
|
||
assert!(config.pull_request_sort.unwrap().is_empty());
|
||
assert!(config.pull_request_filters.unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn pull_request_preferences_reject_unknown_values_and_invalid_ranks() {
|
||
let fixture = Fixture::new();
|
||
for (field, value) in [
|
||
("sort", "[]"),
|
||
("sort", "{unknown: {}}"),
|
||
("sort", "{updated_at: []}"),
|
||
("sort", "{updated_at: {unknown: 1}}"),
|
||
("sort", "{updated_at: {direction: :up}}"),
|
||
("sort", "{updated_at: {direction: nil}}"),
|
||
("sort", "{updated_at: {precedence: 0}}"),
|
||
("sort", "{updated_at: {precedence: 5}}"),
|
||
("sort", "{updated_at: {precedence: 1.0}}"),
|
||
(
|
||
"sort",
|
||
"{updated_at: {precedence: 1}, created_at: {precedence: 1}}",
|
||
),
|
||
("filters", "{}"),
|
||
("filters", "[:unknown]"),
|
||
("filters", "[:requested, :requested]"),
|
||
] {
|
||
let path = fixture.file(
|
||
"config.rb",
|
||
&format!("Vamp.configure {{ |config| config.pull_request_{field} = {value} }}"),
|
||
);
|
||
let error = load_config(&path).unwrap_err();
|
||
assert!(
|
||
error.contains(&format!("pull_request_{field}")),
|
||
"{value}: {error}"
|
||
);
|
||
}
|
||
}
|