refactor: unify core actions and Ex command aliases

This commit is contained in:
2026-10-10 21:57:33 +02:00
parent 4e9378816e
commit 3f05287d79
9 changed files with 536 additions and 203 deletions
+391 -187
View File
@@ -281,76 +281,50 @@ enum InsertStep {
},
}
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, Copy, Eq, PartialEq)]
enum Action {
Insert,
InsertFirstNonblank,
Append,
AppendLineEnd,
OpenBelow,
OpenAbove,
Left,
Right,
Down,
Up,
JoinLines,
LineStart,
LineEnd,
DocumentStart,
DocumentEnd,
Delete,
Undo,
Redo,
RepeatLastEdit,
Command,
Normal,
Newline,
Backspace,
DeleteLine,
ChangeLine,
Submit,
Search,
SearchNext,
SearchPrevious,
WordNext,
WordPrevious,
WordEnd,
BigWordNext,
BigWordPrevious,
BigWordEnd,
Select,
SelectLinewise,
Copy,
Cut,
CopySystem,
Change,
PasteBefore,
PasteAfter,
PasteSystemBefore,
PasteSystemAfter,
SelectLines,
CloneAbove,
CloneBelow,
SelectNext,
SkipOccurrence,
SelectAll,
Palette,
FilePicker,
FileExplorer,
FollowLink,
JumpBack,
JumpForward,
LspDefinition,
LspHover,
LspDiagnostics,
LspCompletion,
SnippetNext,
SnippetPrevious,
#[derive(Serialize)]
struct CoreAction {
name: &'static str,
ex: Option<&'static str>,
#[serde(skip)]
action: Action,
#[serde(skip)]
modes: &'static [Mode],
#[serde(skip)]
prompt: bool,
#[serde(skip)]
palette_prompt: bool,
}
// Names and mode eligibility are shared by configured bindings and the palette.
const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
macro_rules! core_actions {
($(($name:literal, Action::$action:ident, $modes:expr $(, $ex:literal $(, $prompt:literal $(, $palette_prompt:literal)?)?)? $(,)?)),* $(,)?) => {
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, Copy, Eq, PartialEq)]
enum Action { $($action),* }
// Generated together: each discriminant indexes its own catalog entry.
const CORE_ACTIONS: &[CoreAction] = &[$(CoreAction {
name: $name,
action: Action::$action,
modes: $modes,
ex: core_actions!(@ex $($ex)?),
prompt: core_actions!(@prompt $($($prompt)?)?),
palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?),
}),*];
impl Action {
fn entry(self) -> &'static CoreAction {
&CORE_ACTIONS[self as usize]
}
}
};
(@ex $ex:literal) => { Some($ex) };
(@ex) => { None };
(@prompt $prompt:literal) => { $prompt };
(@prompt) => { false };
}
// One catalog owns typed bindings, Ex aliases, and palette eligibility.
core_actions![
("core.insert", Action::Insert, &[Mode::Normal]),
(
"core.insert_first_nonblank",
@@ -541,15 +515,26 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
Action::FileExplorer,
&[Mode::Normal, Mode::Visual],
),
("core.follow_link", Action::FollowLink, &[Mode::Normal]),
(
"core.follow_link",
Action::FollowLink,
&[Mode::Normal],
"follow_link"
),
("core.jump_back", Action::JumpBack, &[Mode::Normal]),
("core.jump_forward", Action::JumpForward, &[Mode::Normal]),
(
"core.lsp.definition",
Action::LspDefinition,
&[Mode::Normal],
"lsp.definition",
),
(
"core.lsp.hover",
Action::LspHover,
&[Mode::Normal],
"lsp.hover"
),
("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
(
"core.lsp.completion",
Action::LspCompletion,
@@ -565,15 +550,68 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
"core.lsp.diagnostics",
Action::LspDiagnostics,
&[Mode::Normal],
"lsp.diagnostics",
),
("core.save", Action::Save, &[Mode::Normal], "w"),
("core.quit", Action::Quit, &[Mode::Normal], "q"),
(
"core.force_quit",
Action::ForceQuit,
&[Mode::Normal],
"q!",
false,
true
),
(
"core.save_and_quit",
Action::SaveAndQuit,
&[Mode::Normal],
"wq"
),
("core.plugins", Action::Plugins, &[Mode::Normal], "plugins"),
("core.edit", Action::Edit, &[Mode::Normal], "edit", true),
("core.buffers", Action::Buffers, &[Mode::Normal], "buffers"),
(
"core.buffer",
Action::Buffer,
&[Mode::Normal],
"buffer",
true
),
(
"core.buffer_next",
Action::BufferNext,
&[Mode::Normal],
"bnext"
),
(
"core.buffer_previous",
Action::BufferPrevious,
&[Mode::Normal],
"bprev"
),
(
"core.buffer_delete",
Action::BufferDelete,
&[Mode::Normal],
"bdelete"
),
(
"core.buffer_force_delete",
Action::BufferForceDelete,
&[Mode::Normal],
"bdelete!",
false,
true
),
];
/// Serializes the authoritative action names without copying the catalog.
/// Serializes the authoritative typed names and their optional Ex aliases.
pub(crate) struct CoreActionCatalog;
impl Serialize for CoreActionCatalog {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name))
serializer.collect_seq(CORE_ACTIONS)
}
}
@@ -581,8 +619,8 @@ impl Action {
fn named(mode: Mode, name: &str) -> Option<Self> {
CORE_ACTIONS
.iter()
.find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode))
.map(|(_, action, _)| *action)
.find(|entry| entry.name == name && entry.modes.contains(&mode))
.map(|entry| entry.action)
}
}
@@ -1097,27 +1135,11 @@ impl Editor {
Feature {
name: "core".into(),
required_capabilities: Vec::new(),
commands: [
"w",
"q",
"q!",
"wq",
"plugins",
"edit",
"buffers",
"buffer",
"bnext",
"bprev",
"bdelete",
"bdelete!",
"follow_link",
"lsp.definition",
"lsp.hover",
"lsp.diagnostics",
]
.into_iter()
.map(str::to_owned)
.collect(),
commands: CORE_ACTIONS
.iter()
.filter_map(|entry| entry.ex)
.map(str::to_owned)
.collect(),
core: true,
},
true,
@@ -1130,11 +1152,11 @@ impl Editor {
("visual", "leader o", "core.file_picker"),
("normal", "leader e", "core.file_explorer"),
("visual", "leader e", "core.file_explorer"),
("normal", "leader w", "w"),
("normal", "leader b n", "bnext"),
("normal", "leader b p", "bprev"),
("normal", "leader b l", "buffers"),
("normal", "leader b d", "bdelete"),
("normal", "leader w", "core.save"),
("normal", "leader b n", "core.buffer_next"),
("normal", "leader b p", "core.buffer_previous"),
("normal", "leader b l", "core.buffers"),
("normal", "leader b d", "core.buffer_delete"),
] {
editor
.bind(mode, key, Some(target.into()))
@@ -2401,6 +2423,15 @@ impl Editor {
.ok_or_else(|| format!("Unknown action for {mode}: {target}"))?,
),
Some(command) => {
if let Some(entry) = CORE_ACTIONS
.iter()
.find(|entry| entry.ex == Some(command.as_str()))
{
return Err(format!(
"Built-in command {command} requires typed action {}",
entry.name
));
}
if mode != Mode::Normal {
return Err("Ex/plugin commands can only be bound in Normal mode".into());
}
@@ -2621,17 +2652,24 @@ impl Editor {
};
let query = palette.query.to_lowercase();
// Derived labels contain the same searchable words as their names.
let matches = |name: &str, owner: &str| {
let matches = |name: &str, owner: &str, ex: &str| {
query
.split_whitespace()
.all(|token| name.contains(token) || owner.contains(token))
.all(|token| name.contains(token) || owner.contains(token) || ex.contains(token))
};
palette.entries.clear();
for &(name, action, modes) in CORE_ACTIONS {
for entry in CORE_ACTIONS {
let CoreAction {
name,
action,
modes,
ex,
..
} = *entry;
if action == Action::Palette {
continue;
}
if !matches(name, "core") {
if !matches(name, "core", ex.unwrap_or("")) {
continue;
}
let mut label = name.trim_start_matches("core.").replace('_', " ");
@@ -2646,10 +2684,13 @@ impl Editor {
});
}
for feature in self.features.values() {
if feature.core {
continue;
}
let owner = feature.name.to_lowercase();
for command in &feature.commands {
let name = command.to_lowercase();
if !matches(&name, &owner) {
if !matches(&name, &owner, "") {
continue;
}
palette.entries.push(PaletteEntry {
@@ -2681,6 +2722,13 @@ impl Editor {
.get(palette.selected)
.filter(|entry| entry.enabled)?;
if let Some(action) = Action::named(self.active.mode, &entry.name) {
if let Some(core) = CORE_ACTIONS
.iter()
.find(|core| core.action == action && core.palette_prompt)
{
self.open_action_prompt(core);
return None;
}
self.palette = None;
return self.act(action);
}
@@ -3025,7 +3073,21 @@ impl Editor {
None
}
fn open_action_prompt(&mut self, entry: &CoreAction) {
self.prepare_command();
self.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias"));
self.active.mode = Mode::Command;
}
fn act(&mut self, action: Action) -> Option<Invocation> {
let entry = action.entry();
if entry.ex.is_some() {
if entry.prompt {
self.open_action_prompt(entry);
return None;
}
return self.execute_action(entry, "");
}
self.finish_completion();
if !matches!(
action,
@@ -3062,19 +3124,27 @@ impl Editor {
}
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
Action::FollowLink => {
if let Err(error) = self.follow_link() {
self.set_message(error);
}
}
Action::FollowLink
| Action::Save
| Action::Quit
| Action::ForceQuit
| Action::SaveAndQuit
| Action::Plugins
| Action::Edit
| Action::Buffers
| Action::Buffer
| Action::BufferNext
| Action::BufferPrevious
| Action::BufferDelete
| Action::BufferForceDelete
| Action::LspDefinition
| Action::LspHover
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
Action::JumpBack => self.traverse_jumps(false, count),
Action::JumpForward => self.traverse_jumps(true, count),
Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count);
}
Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
Action::LspCompletion => {
if self.active.mode == Mode::Insert {
return Some(Invocation {
@@ -3511,19 +3581,19 @@ impl Editor {
));
}
fn buffer_command(&mut self, name: &str, args: &str) -> bool {
fn buffer_command(&mut self, action: Action, name: &str, args: &str) -> bool {
let source = self.jump_location();
match name {
"edit" if args.is_empty() => self.set_message("Usage: :edit path"),
"edit" => {
match action {
Action::Edit if args.is_empty() => self.set_message("Usage: :edit path"),
Action::Edit => {
if let Err(error) = self.edit_buffer(PathBuf::from(args), true) {
self.set_message(format!("Open failed: {error}"));
}
}
"buffers" | "bnext" | "bprev" if !args.is_empty() => {
Action::Buffers | Action::BufferNext | Action::BufferPrevious if !args.is_empty() => {
self.set_message(format!("The {name} command takes no arguments"));
}
"buffers" => {
Action::Buffers => {
let mut message = String::new();
for buffer in self.buffers() {
if !message.is_empty() {
@@ -3544,9 +3614,11 @@ impl Editor {
}
self.set_message(message);
}
"bnext" | "bprev" => self.switch_buffer(self.adjacent_buffer(name == "bnext")),
"buffer" | "bdelete" | "bdelete!" => {
let id = if args.is_empty() && name != "buffer" {
Action::BufferNext | Action::BufferPrevious => {
self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext))
}
Action::Buffer | Action::BufferDelete | Action::BufferForceDelete => {
let id = if args.is_empty() && action != Action::Buffer {
Some(self.active.id)
} else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) {
args.parse::<usize>().ok().filter(|id| *id > 0)
@@ -3554,17 +3626,20 @@ impl Editor {
None
};
match id {
Some(id) if name == "buffer" => self.switch_buffer(id),
Some(id) => self.delete_buffer(id, name == "bdelete!"),
Some(id) if action == Action::Buffer => self.switch_buffer(id),
Some(id) => self.delete_buffer(id, action == Action::BufferForceDelete),
None => self.set_message(format!(
"Usage: :{name} {}",
if name == "buffer" { "N" } else { "[N]" },
if action == Action::Buffer { "N" } else { "[N]" },
)),
}
}
_ => return false,
}
if matches!(name, "edit" | "buffer" | "bnext" | "bprev") {
if matches!(
action,
Action::Edit | Action::Buffer | Action::BufferNext | Action::BufferPrevious
) {
self.record_jump(source);
}
true
@@ -3575,6 +3650,28 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command
.split_once(char::is_whitespace)
.unwrap_or((command, ""));
let args = args.trim();
if let Some(entry) = CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name)) {
return self.execute_action(entry, args);
}
self.prepare_command();
self.finish_command_mode();
let Some(feature) = self.owner(name) else {
self.set_message(format!("Unknown command: {name}"));
return None;
};
Some(Invocation {
plugin: feature.name.clone(),
command: name.into(),
args: args.into(),
})
}
fn prepare_command(&mut self) {
self.finish_completion();
self.snippet = None;
self.clear_hover();
@@ -3582,17 +3679,27 @@ impl Editor {
self.clear_leader();
self.palette = None;
self.blur_file_picker();
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command
.split_once(char::is_whitespace)
.unwrap_or((command, ""));
let args = args.trim();
}
fn finish_command_mode(&mut self) {
if self.active.mode == Mode::Insert {
self.finish_insert();
}
self.clear_selection();
self.active.mode = Mode::Normal;
self.normalize_targets();
}
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
self.prepare_command();
let action = entry.action;
let name = entry.ex.expect("Ex dispatcher requires an alias");
// Switching must park directed Visual selections before ordinary Ex cleanup.
if self.buffer_command(name, args) {
if self.buffer_command(action, name, args) {
return None;
}
// Link preflight must precede Ex cleanup: failure retains carets and text.
if name == "follow_link" {
if action == Action::FollowLink {
if !args.is_empty() {
self.set_message("The follow_link command takes no arguments");
} else if let Err(error) = self.follow_link() {
@@ -3600,40 +3707,26 @@ impl Editor {
}
return None;
}
if self.active.mode == Mode::Insert {
self.finish_insert();
}
self.clear_selection();
self.active.mode = Mode::Normal;
self.normalize_targets();
let Some(feature) = self.owner(name) else {
self.set_message(format!("Unknown command: {name}"));
return None;
};
if !feature.core {
return Some(Invocation {
plugin: feature.name.clone(),
command: name.into(),
args: args.into(),
});
}
match name {
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" if !args.is_empty() => {
self.finish_command_mode();
match action {
Action::LspDefinition | Action::LspHover | Action::LspDiagnostics
if !args.is_empty() =>
{
self.set_message(format!("The {name} command takes no arguments"));
}
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" => {
Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => {
return Some(Invocation {
plugin: "core".into(),
command: name.into(),
args: String::new(),
});
}
"w" | "wq" => {
Action::Save | Action::SaveAndQuit => {
if self.save(if args.is_empty() {
None
} else {
Some(PathBuf::from(args))
}) && name == "wq"
}) && action == Action::SaveAndQuit
{
if self.any_dirty() {
self.set_message("Unsaved changes in other buffers; use :w or :q!");
@@ -3642,13 +3735,15 @@ impl Editor {
}
}
}
"q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"),
"q" if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"),
"q" | "q!" => self.quit = true,
"plugins" if !args.is_empty() => {
Action::Quit | Action::ForceQuit if !args.is_empty() => {
self.set_message("Quit commands take no arguments")
}
Action::Quit if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"),
Action::Quit | Action::ForceQuit => self.quit = true,
Action::Plugins if !args.is_empty() => {
self.set_message("The plugins command takes no arguments")
}
"plugins" => {
Action::Plugins => {
let mut features: Vec<_> = self.features.values().collect();
features.sort_unstable_by(|left, right| left.name.cmp(&right.name));
let mut message = format!("Capabilities: {}", self.capabilities.join(", "));
@@ -9664,6 +9759,121 @@ mod tests {
}
}
#[test]
fn typed_quit_checks_all_buffers_and_force_quit_is_immediate_from_keys() {
let directory = Directory::new();
let mut editor = editor();
editor
.bind("normal", "Q", Some("core.quit".into()))
.unwrap();
editor
.bind("normal", "Z", Some("core.force_quit".into()))
.unwrap();
editor
.apply(vec![Effect::Replace("unsaved".into())])
.unwrap();
editor.execute(&format!("edit {}", directory.path("other").display()));
editor.handle(Input::Char('Q'));
assert!(!editor.should_quit());
assert!(editor.message().contains("Unsaved changes"));
editor.handle(Input::Char('Z'));
assert!(editor.should_quit());
}
#[test]
fn typed_save_and_quit_refuses_dirty_parked_buffers_and_save_failure() {
let directory = Directory::new();
let path = directory.path("current");
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor
.bind("normal", "Z", Some("core.save_and_quit".into()))
.unwrap();
editor.apply(vec![Effect::Replace("first".into())]).unwrap();
editor.execute(&format!("edit {}", directory.path("other").display()));
editor
.apply(vec![Effect::Replace("second".into())])
.unwrap();
editor.handle(Input::Char('Z'));
assert!(!editor.should_quit());
assert!(!editor.dirty());
assert!(editor.message().contains("other buffers"));
editor.execute("buffer 1");
editor.handle(Input::Char('Z'));
assert!(editor.should_quit());
assert_eq!(fs::read_to_string(path).unwrap(), "first");
let mut unnamed = Editor::open(None, vec![]).unwrap();
unnamed
.bind("normal", "Z", Some("core.save_and_quit".into()))
.unwrap();
unnamed
.apply(vec![Effect::Replace("unsaved".into())])
.unwrap();
unnamed.handle(Input::Char('Z'));
assert!(!unnamed.should_quit());
assert!(unnamed.dirty());
}
#[test]
fn required_argument_actions_prompt_and_share_ex_execution() {
let directory = Directory::new();
let path = directory.path("file with spaces");
fs::write(&path, "opened").unwrap();
let mut editor = editor();
assert!(editor.bind("normal", "E", Some("edit".into())).is_err());
editor
.bind("normal", "E", Some("core.edit".into()))
.unwrap();
editor.handle(Input::Char('E'));
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "edit ");
type_text(&mut editor, &path.to_string_lossy());
editor.handle(Input::Enter);
assert_eq!(editor.context().text, "opened");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.buffer");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "buffer ");
type_text(&mut editor, "1");
editor.handle(Input::Enter);
assert_eq!(editor.buffer_id(), 1);
editor.execute("edit");
assert_eq!(editor.message(), "Usage: :edit path");
}
#[test]
fn force_delete_palette_requires_submission_before_discarding_dirty_buffer() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved".into())])
.unwrap();
let dirty_id = editor.buffer_id();
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "bdelete!");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "bdelete! ");
assert!(editor.has_buffer(dirty_id));
assert!(editor.dirty());
editor.handle(Input::Enter);
assert!(!editor.has_buffer(dirty_id));
}
#[test]
fn built_in_alias_bindings_require_typed_normal_actions() {
let mut editor = editor();
for entry in CORE_ACTIONS.iter().filter(|entry| entry.ex.is_some()) {
assert!(
editor
.bind("normal", "Z", Some(entry.ex.unwrap().into()))
.is_err()
);
assert!(editor.bind("normal", "Z", Some(entry.name.into())).is_ok());
assert!(editor.bind("insert", "Z", Some(entry.name.into())).is_err());
}
}
#[test]
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
let directory = Directory::new();
@@ -9747,11 +9957,18 @@ mod tests {
assert_eq!(editor.leader_pending(), None);
assert_eq!(editor.count(), None);
editor.handle(Input::Ctrl('p'));
assert_eq!(palette_entry(&editor, "bnext").bindings, vec![", b n"]);
assert_eq!(
palette_entry(&editor, "core.buffer_next").bindings,
vec![", b n"]
);
editor
.bind("normal", ",", Some("core.command".into()))
.unwrap();
assert!(palette_entry(&editor, "bnext").bindings.is_empty());
assert!(
palette_entry(&editor, "core.buffer_next")
.bindings
.is_empty()
);
editor.set_leader(",").unwrap();
editor.handle(Input::Escape);
editor.handle(Input::Char(','));
@@ -9774,7 +9991,10 @@ mod tests {
editor.handle(Input::Char('n'));
assert_eq!(editor.leader_pending(), None);
editor.handle(Input::Ctrl('p'));
assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["\\ b n"]);
assert_eq!(
palette_entry(&editor, "core.buffer_next").bindings,
vec!["\\ b n"]
);
}
#[test]
@@ -11265,7 +11485,9 @@ mod tests {
let ranges = editor.selections();
editor.execute(&format!("edit {}", b.display()));
assert_eq!(editor.mode(), Mode::Normal);
editor.bind("normal", "g", Some("bprev".into())).unwrap();
editor
.bind("normal", "g", Some("core.buffer_previous".into()))
.unwrap();
editor.handle(Input::Char('g'));
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.mode(), Mode::Visual);
@@ -11506,7 +11728,9 @@ mod tests {
let mut editor = editor();
editor.register(plugin("fixture", &["greet"])).unwrap();
editor.bind("normal", "g", Some("greet".into())).unwrap();
editor.bind("normal", "z", Some("bnext".into())).unwrap();
editor
.bind("normal", "z", Some("core.buffer_next".into()))
.unwrap();
editor
.apply(vec![Effect::Replace("copied".into())])
.unwrap();
@@ -11519,15 +11743,8 @@ mod tests {
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet");
editor.handle(Input::Ctrl('p'));
for command in [
"edit", "buffers", "buffer", "bnext", "bprev", "bdelete", "bdelete!",
] {
let entry = palette_entry(&editor, command);
assert_eq!(entry.owner, "core");
assert!(entry.enabled);
}
assert_eq!(
palette_entry(&editor, "bnext").bindings,
palette_entry(&editor, "core.buffer_next").bindings,
vec!["Space b n", "z"]
);
editor.disable_plugin("fixture", "failure");
@@ -11778,19 +11995,6 @@ mod tests {
#[test]
fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() {
let mut names = HashSet::new();
for &(name, action, modes) in CORE_ACTIONS {
assert!(names.insert(name));
for mode in [
Mode::Normal,
Mode::Insert,
Mode::Command,
Mode::Search,
Mode::Visual,
] {
assert!(Action::named(mode, name) == modes.contains(&mode).then_some(action));
}
}
for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] {
let mut editor = editor();
editor.active.mode = mode;
@@ -11813,7 +12017,7 @@ mod tests {
}
type_text(&mut editor, "q!");
assert_eq!(editor.palette().unwrap().entries.len(), 1);
assert!(!palette_entry(&editor, "q!").enabled);
assert!(!palette_entry(&editor, "core.force_quit").enabled);
editor.handle(Input::Enter);
assert!(!editor.should_quit());
assert!(editor.palette().is_some());
+109 -4
View File
@@ -869,6 +869,113 @@ end
);
}
#[test]
fn generated_core_actions_execute_through_ruby_configuration() {
use crate::{
editor::{Input, Mode},
session::EditorSession,
};
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "Q", config.core.quit
config.bind "F", config.core.force_quit
config.bind "W", config.core.save
config.bind "X", config.core.save_and_quit
config.bind "E", config.core.edit
config.bind "B", config.core.buffer
config.bind "P", config.core.plugins
config.bind "L", config.core.buffers
config.bind "N", config.core.buffer_next
config.bind "V", config.core.buffer_previous
config.bind "D", config.core.buffer_delete
config.bind "Z", config.core.buffer_force_delete
end"#,
);
for (key, dirty, quit, saved) in [
('Q', false, true, false),
('Q', true, false, false),
('F', true, true, false),
('W', true, false, true),
('X', true, true, true),
] {
let buffer = fixture.file("buffer.txt", "base");
let mut session =
EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config))
.unwrap();
assert!(session.editor().startup_diagnostic().is_none());
if dirty {
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
session.handle(input);
}
}
session.handle(Input::Char(key));
assert_eq!(session.editor().should_quit(), quit, "{key}");
assert_eq!(
std::fs::read_to_string(&buffer).unwrap(),
if saved { "!base" } else { "base" },
"{key}"
);
assert_eq!(session.editor().dirty(), dirty && !saved, "{key}");
}
let buffer = fixture.file("buffer.txt", "base");
let second = fixture.file("second.txt", "second");
let mut session =
EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session.handle(Input::Char('E'));
assert_eq!(session.editor().mode(), Mode::Command);
assert_eq!(session.editor().command_line(), "edit ");
for ch in second.to_string_lossy().chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
assert_eq!(session.editor().lines(), &["second"]);
session.handle(Input::Char('B'));
assert_eq!(session.editor().mode(), Mode::Command);
assert_eq!(session.editor().command_line(), "buffer ");
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Char('P'));
assert!(session.editor().message().contains("text"));
session.handle(Input::Char('L'));
assert!(session.editor().message().contains("buffer.txt"));
assert!(session.editor().message().contains("second.txt"));
session.handle(Input::Char('N'));
assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('V'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Char('D'));
assert!(!session.editor().has_buffer(1));
assert_eq!(session.editor().buffer_id(), 2);
for input in [Input::Char('i'), Input::Char('!'), Input::Escape] {
session.handle(input);
}
session.handle(Input::Char('D'));
assert!(session.editor().has_buffer(2));
session.handle(Input::Char('Z'));
assert!(!session.editor().has_buffer(2));
assert_eq!(std::fs::read_to_string(second).unwrap(), "second");
}
#[test]
fn raw_builtin_aliases_are_not_external_binding_commands() {
let fixture = Fixture::new();
for name in ["q", "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 config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
let fixture = Fixture::new();
@@ -895,10 +1002,8 @@ end
.contains("must be a String")
);
for target in [
"config.core.lsp_hover",
"config.core.lsp_definition",
"config.core.lsp_diagnostics",
"config.core.lsp.hvoer",
"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",
+3 -1
View File
@@ -124,7 +124,9 @@ module Vamp
# @return [String]
def external_command(command)
name = Vamp.string!(command, "binding command")
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
if name.start_with?("core.") || Vamp.builtin_command?(name)
raise TypeError, "use config.core for built-in actions"
end
name
end
+18 -4
View File
@@ -58,16 +58,30 @@ module Vamp
# @type [Module<Vamp::Core>]
Core = Module.new
# @param names [Array<String>] dotted core action names
# @param entries [Array<Hash{String => String, nil}>] action name and optional Ex alias descriptors
# @return [Array<Module>] frozen action namespaces
def self.install_actions(names)
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
def self.install_actions(entries)
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
namespaces = [Core]
names.each { |name| install_action(name, namespaces) }
aliases = {}
entries.each do |entry|
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
install_action(entry.fetch("name"), namespaces)
ex = entry.fetch("ex")
aliases[string!(ex, "core Ex alias").freeze] = true unless ex.nil?
end
@builtin_commands = aliases.freeze
namespaces.each(&:freeze)
end
# @param name [String] binding command name
# @return [Boolean] whether the catalog owns this textual Ex alias
def self.builtin_command?(name)
@builtin_commands.key?(name)
end
# Frozen binding mode values, distinct from a buffer's editing state.
module Modes
# @!method self.normal