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
+1 -1
View File
@@ -26,7 +26,7 @@ A named frontend ability required by a feature for compatibility, not a permissi
## Configuration interfaces ## Configuration interfaces
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings. - Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names and eligibility. Ruby action methods and nested namespaces are generated from its startup catalog; add or rename actions there rather than maintaining a second Ruby list. - `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names, eligibility, and Ex aliases. Ruby action methods and nested namespaces, protected core command registration, and palette entries are generated from this catalog; add or rename actions there rather than maintaining separate lists.
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual. - Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety. - Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
+3 -1
View File
@@ -77,4 +77,6 @@ Manual completion dispatches the Insert-only `core.lsp.completion` action direct
## Ruby action catalog ## Ruby action catalog
Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports. `src/editor.rs`'s `CORE_ACTIONS` catalog owns core action names, eligible modes, optional Ex aliases, and prompt policies. It generates the Rust Action enum and protected core command registration. Typed keys and textual Ex aliases share Action-backed execution; the palette has one row per core action and searches its alias without adding duplicate command rows.
Rust supplies name/Ex-alias descriptors before loading configuration or plugin scripts. Ruby generates `config.core` methods and a frozen built-in alias lookup from those descriptors, rejecting built-in string binding targets without a second command list. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen.
+7 -2
View File
@@ -21,6 +21,7 @@ Vamp.configure do |config|
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
config.bind "g", "greet" config.bind "g", "greet"
config.bind "leader g", "greet" config.bind "leader g", "greet"
config.bind "leader q", config.core.quit
config.bind "ctrl+r", config.core.redo config.bind "ctrl+r", config.core.redo
config.bind "shift+j", config.core.join_lines config.bind "shift+j", config.core.join_lines
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
@@ -41,7 +42,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct
## Actions and modes ## Actions and modes
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. `config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike. Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
@@ -69,6 +70,8 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `delete_line`, `change_line` | | Normal | `delete_line`, `change_line` |
| Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
@@ -93,6 +96,8 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets. `core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit` and `core.buffer_force_delete` keys act immediately, without confirmation.
## Binding overrides and failures ## Binding overrides and failures
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode: Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
@@ -135,7 +140,7 @@ Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Ent
## Leader key ## Leader key
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings. Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", config.core.buffer_next`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette. Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
+3 -3
View File
@@ -195,11 +195,11 @@ The project root is the working directory captured at startup. Scanning uses the
## Command palette ## Command palette
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear. Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
Search is case-insensitive: every whitespace-separated query token must occur in the command name, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open. Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, built-in Ex alias, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. Accepting an Ex/plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Choosing `q!` therefore does not immediately discard changes. Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit` (`q!`) and `core.buffer_force_delete` (`bdelete!`) actions also open Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals. Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
+1
View File
@@ -23,6 +23,7 @@ Vamp.configure do |config|
# config.bind "K", config.core.lsp.hover # config.bind "K", config.core.lsp.hover
config.bind "g", "greet" config.bind "g", "greet"
config.bind "leader g", "greet" config.bind "leader g", "greet"
config.bind "leader q", config.core.quit
config.bind "ctrl+r", config.core.redo config.bind "ctrl+r", config.core.redo
config.bind "leader r", config.core.repeat_last_edit config.bind "leader r", config.core.repeat_last_edit
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
+389 -185
View File
@@ -281,76 +281,50 @@ enum InsertStep {
}, },
} }
#[cfg_attr(test, derive(Debug))] #[derive(Serialize)]
#[derive(Clone, Copy, Eq, PartialEq)] struct CoreAction {
enum Action { name: &'static str,
Insert, ex: Option<&'static str>,
InsertFirstNonblank, #[serde(skip)]
Append, action: Action,
AppendLineEnd, #[serde(skip)]
OpenBelow, modes: &'static [Mode],
OpenAbove, #[serde(skip)]
Left, prompt: bool,
Right, #[serde(skip)]
Down, palette_prompt: bool,
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,
} }
// Names and mode eligibility are shared by configured bindings and the palette. macro_rules! core_actions {
const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ ($(($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", Action::Insert, &[Mode::Normal]),
( (
"core.insert_first_nonblank", "core.insert_first_nonblank",
@@ -541,15 +515,26 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
Action::FileExplorer, Action::FileExplorer,
&[Mode::Normal, Mode::Visual], &[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_back", Action::JumpBack, &[Mode::Normal]),
("core.jump_forward", Action::JumpForward, &[Mode::Normal]), ("core.jump_forward", Action::JumpForward, &[Mode::Normal]),
( (
"core.lsp.definition", "core.lsp.definition",
Action::LspDefinition, Action::LspDefinition,
&[Mode::Normal], &[Mode::Normal],
"lsp.definition",
),
(
"core.lsp.hover",
Action::LspHover,
&[Mode::Normal],
"lsp.hover"
), ),
("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
( (
"core.lsp.completion", "core.lsp.completion",
Action::LspCompletion, Action::LspCompletion,
@@ -565,15 +550,68 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
"core.lsp.diagnostics", "core.lsp.diagnostics",
Action::LspDiagnostics, Action::LspDiagnostics,
&[Mode::Normal], &[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; pub(crate) struct CoreActionCatalog;
impl Serialize for CoreActionCatalog { impl Serialize for CoreActionCatalog {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 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> { fn named(mode: Mode, name: &str) -> Option<Self> {
CORE_ACTIONS CORE_ACTIONS
.iter() .iter()
.find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode)) .find(|entry| entry.name == name && entry.modes.contains(&mode))
.map(|(_, action, _)| *action) .map(|entry| entry.action)
} }
} }
@@ -1097,25 +1135,9 @@ impl Editor {
Feature { Feature {
name: "core".into(), name: "core".into(),
required_capabilities: Vec::new(), required_capabilities: Vec::new(),
commands: [ commands: CORE_ACTIONS
"w", .iter()
"q", .filter_map(|entry| entry.ex)
"q!",
"wq",
"plugins",
"edit",
"buffers",
"buffer",
"bnext",
"bprev",
"bdelete",
"bdelete!",
"follow_link",
"lsp.definition",
"lsp.hover",
"lsp.diagnostics",
]
.into_iter()
.map(str::to_owned) .map(str::to_owned)
.collect(), .collect(),
core: true, core: true,
@@ -1130,11 +1152,11 @@ impl Editor {
("visual", "leader o", "core.file_picker"), ("visual", "leader o", "core.file_picker"),
("normal", "leader e", "core.file_explorer"), ("normal", "leader e", "core.file_explorer"),
("visual", "leader e", "core.file_explorer"), ("visual", "leader e", "core.file_explorer"),
("normal", "leader w", "w"), ("normal", "leader w", "core.save"),
("normal", "leader b n", "bnext"), ("normal", "leader b n", "core.buffer_next"),
("normal", "leader b p", "bprev"), ("normal", "leader b p", "core.buffer_previous"),
("normal", "leader b l", "buffers"), ("normal", "leader b l", "core.buffers"),
("normal", "leader b d", "bdelete"), ("normal", "leader b d", "core.buffer_delete"),
] { ] {
editor editor
.bind(mode, key, Some(target.into())) .bind(mode, key, Some(target.into()))
@@ -2401,6 +2423,15 @@ impl Editor {
.ok_or_else(|| format!("Unknown action for {mode}: {target}"))?, .ok_or_else(|| format!("Unknown action for {mode}: {target}"))?,
), ),
Some(command) => { 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 { if mode != Mode::Normal {
return Err("Ex/plugin commands can only be bound in Normal mode".into()); 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(); let query = palette.query.to_lowercase();
// Derived labels contain the same searchable words as their names. // 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 query
.split_whitespace() .split_whitespace()
.all(|token| name.contains(token) || owner.contains(token)) .all(|token| name.contains(token) || owner.contains(token) || ex.contains(token))
}; };
palette.entries.clear(); 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 { if action == Action::Palette {
continue; continue;
} }
if !matches(name, "core") { if !matches(name, "core", ex.unwrap_or("")) {
continue; continue;
} }
let mut label = name.trim_start_matches("core.").replace('_', " "); let mut label = name.trim_start_matches("core.").replace('_', " ");
@@ -2646,10 +2684,13 @@ impl Editor {
}); });
} }
for feature in self.features.values() { for feature in self.features.values() {
if feature.core {
continue;
}
let owner = feature.name.to_lowercase(); let owner = feature.name.to_lowercase();
for command in &feature.commands { for command in &feature.commands {
let name = command.to_lowercase(); let name = command.to_lowercase();
if !matches(&name, &owner) { if !matches(&name, &owner, "") {
continue; continue;
} }
palette.entries.push(PaletteEntry { palette.entries.push(PaletteEntry {
@@ -2681,6 +2722,13 @@ impl Editor {
.get(palette.selected) .get(palette.selected)
.filter(|entry| entry.enabled)?; .filter(|entry| entry.enabled)?;
if let Some(action) = Action::named(self.active.mode, &entry.name) { 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; self.palette = None;
return self.act(action); return self.act(action);
} }
@@ -3025,7 +3073,21 @@ impl Editor {
None 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> { 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(); self.finish_completion();
if !matches!( if !matches!(
action, action,
@@ -3062,19 +3124,27 @@ impl Editor {
} }
Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
Action::FollowLink => { Action::FollowLink
if let Err(error) = self.follow_link() { | Action::Save
self.set_message(error); | 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::JumpBack => self.traverse_jumps(false, count),
Action::JumpForward => self.traverse_jumps(true, count), Action::JumpForward => self.traverse_jumps(true, count),
Action::DeleteLine | Action::ChangeLine => { Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count); 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 => { Action::LspCompletion => {
if self.active.mode == Mode::Insert { if self.active.mode == Mode::Insert {
return Some(Invocation { 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(); let source = self.jump_location();
match name { match action {
"edit" if args.is_empty() => self.set_message("Usage: :edit path"), Action::Edit if args.is_empty() => self.set_message("Usage: :edit path"),
"edit" => { Action::Edit => {
if let Err(error) = self.edit_buffer(PathBuf::from(args), true) { if let Err(error) = self.edit_buffer(PathBuf::from(args), true) {
self.set_message(format!("Open failed: {error}")); 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")); self.set_message(format!("The {name} command takes no arguments"));
} }
"buffers" => { Action::Buffers => {
let mut message = String::new(); let mut message = String::new();
for buffer in self.buffers() { for buffer in self.buffers() {
if !message.is_empty() { if !message.is_empty() {
@@ -3544,9 +3614,11 @@ impl Editor {
} }
self.set_message(message); self.set_message(message);
} }
"bnext" | "bprev" => self.switch_buffer(self.adjacent_buffer(name == "bnext")), Action::BufferNext | Action::BufferPrevious => {
"buffer" | "bdelete" | "bdelete!" => { self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext))
let id = if args.is_empty() && name != "buffer" { }
Action::Buffer | Action::BufferDelete | Action::BufferForceDelete => {
let id = if args.is_empty() && action != Action::Buffer {
Some(self.active.id) Some(self.active.id)
} else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) { } else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) {
args.parse::<usize>().ok().filter(|id| *id > 0) args.parse::<usize>().ok().filter(|id| *id > 0)
@@ -3554,17 +3626,20 @@ impl Editor {
None None
}; };
match id { match id {
Some(id) if name == "buffer" => self.switch_buffer(id), Some(id) if action == Action::Buffer => self.switch_buffer(id),
Some(id) => self.delete_buffer(id, name == "bdelete!"), Some(id) => self.delete_buffer(id, action == Action::BufferForceDelete),
None => self.set_message(format!( None => self.set_message(format!(
"Usage: :{name} {}", "Usage: :{name} {}",
if name == "buffer" { "N" } else { "[N]" }, if action == Action::Buffer { "N" } else { "[N]" },
)), )),
} }
} }
_ => return false, _ => return false,
} }
if matches!(name, "edit" | "buffer" | "bnext" | "bprev") { if matches!(
action,
Action::Edit | Action::Buffer | Action::BufferNext | Action::BufferPrevious
) {
self.record_jump(source); self.record_jump(source);
} }
true true
@@ -3575,6 +3650,28 @@ impl Editor {
} }
pub fn execute(&mut self, command: &str) -> Option<Invocation> { 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.finish_completion();
self.snippet = None; self.snippet = None;
self.clear_hover(); self.clear_hover();
@@ -3582,17 +3679,27 @@ impl Editor {
self.clear_leader(); self.clear_leader();
self.palette = None; self.palette = None;
self.blur_file_picker(); self.blur_file_picker();
let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); }
let (name, args) = command
.split_once(char::is_whitespace) fn finish_command_mode(&mut self) {
.unwrap_or((command, "")); if self.active.mode == Mode::Insert {
let args = args.trim(); 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. // 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; return None;
} }
// Link preflight must precede Ex cleanup: failure retains carets and text. // Link preflight must precede Ex cleanup: failure retains carets and text.
if name == "follow_link" { if action == Action::FollowLink {
if !args.is_empty() { if !args.is_empty() {
self.set_message("The follow_link command takes no arguments"); self.set_message("The follow_link command takes no arguments");
} else if let Err(error) = self.follow_link() { } else if let Err(error) = self.follow_link() {
@@ -3600,40 +3707,26 @@ impl Editor {
} }
return None; return None;
} }
if self.active.mode == Mode::Insert { self.finish_command_mode();
self.finish_insert(); match action {
} Action::LspDefinition | Action::LspHover | Action::LspDiagnostics
self.clear_selection(); if !args.is_empty() =>
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.set_message(format!("The {name} command takes no arguments")); 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 { return Some(Invocation {
plugin: "core".into(), plugin: "core".into(),
command: name.into(), command: name.into(),
args: String::new(), args: String::new(),
}); });
} }
"w" | "wq" => { Action::Save | Action::SaveAndQuit => {
if self.save(if args.is_empty() { if self.save(if args.is_empty() {
None None
} else { } else {
Some(PathBuf::from(args)) Some(PathBuf::from(args))
}) && name == "wq" }) && action == Action::SaveAndQuit
{ {
if self.any_dirty() { if self.any_dirty() {
self.set_message("Unsaved changes in other buffers; use :w or :q!"); 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"), Action::Quit | Action::ForceQuit if !args.is_empty() => {
"q" if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), self.set_message("Quit commands take no arguments")
"q" | "q!" => self.quit = true, }
"plugins" if !args.is_empty() => { 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") self.set_message("The plugins command takes no arguments")
} }
"plugins" => { Action::Plugins => {
let mut features: Vec<_> = self.features.values().collect(); let mut features: Vec<_> = self.features.values().collect();
features.sort_unstable_by(|left, right| left.name.cmp(&right.name)); features.sort_unstable_by(|left, right| left.name.cmp(&right.name));
let mut message = format!("Capabilities: {}", self.capabilities.join(", ")); 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] #[test]
fn default_leader_routes_save_switch_list_delete_and_open_palette() { fn default_leader_routes_save_switch_list_delete_and_open_palette() {
let directory = Directory::new(); let directory = Directory::new();
@@ -9747,11 +9957,18 @@ mod tests {
assert_eq!(editor.leader_pending(), None); assert_eq!(editor.leader_pending(), None);
assert_eq!(editor.count(), None); assert_eq!(editor.count(), None);
editor.handle(Input::Ctrl('p')); 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 editor
.bind("normal", ",", Some("core.command".into())) .bind("normal", ",", Some("core.command".into()))
.unwrap(); .unwrap();
assert!(palette_entry(&editor, "bnext").bindings.is_empty()); assert!(
palette_entry(&editor, "core.buffer_next")
.bindings
.is_empty()
);
editor.set_leader(",").unwrap(); editor.set_leader(",").unwrap();
editor.handle(Input::Escape); editor.handle(Input::Escape);
editor.handle(Input::Char(',')); editor.handle(Input::Char(','));
@@ -9774,7 +9991,10 @@ mod tests {
editor.handle(Input::Char('n')); editor.handle(Input::Char('n'));
assert_eq!(editor.leader_pending(), None); assert_eq!(editor.leader_pending(), None);
editor.handle(Input::Ctrl('p')); 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] #[test]
@@ -11265,7 +11485,9 @@ mod tests {
let ranges = editor.selections(); let ranges = editor.selections();
editor.execute(&format!("edit {}", b.display())); editor.execute(&format!("edit {}", b.display()));
assert_eq!(editor.mode(), Mode::Normal); 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')); editor.handle(Input::Char('g'));
assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.mode(), Mode::Visual);
@@ -11506,7 +11728,9 @@ mod tests {
let mut editor = editor(); let mut editor = editor();
editor.register(plugin("fixture", &["greet"])).unwrap(); editor.register(plugin("fixture", &["greet"])).unwrap();
editor.bind("normal", "g", Some("greet".into())).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 editor
.apply(vec![Effect::Replace("copied".into())]) .apply(vec![Effect::Replace("copied".into())])
.unwrap(); .unwrap();
@@ -11519,15 +11743,8 @@ mod tests {
assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet"); assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet");
editor.handle(Input::Ctrl('p')); 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!( assert_eq!(
palette_entry(&editor, "bnext").bindings, palette_entry(&editor, "core.buffer_next").bindings,
vec!["Space b n", "z"] vec!["Space b n", "z"]
); );
editor.disable_plugin("fixture", "failure"); editor.disable_plugin("fixture", "failure");
@@ -11778,19 +11995,6 @@ mod tests {
#[test] #[test]
fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { 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] { for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] {
let mut editor = editor(); let mut editor = editor();
editor.active.mode = mode; editor.active.mode = mode;
@@ -11813,7 +12017,7 @@ mod tests {
} }
type_text(&mut editor, "q!"); type_text(&mut editor, "q!");
assert_eq!(editor.palette().unwrap().entries.len(), 1); 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); editor.handle(Input::Enter);
assert!(!editor.should_quit()); assert!(!editor.should_quit());
assert!(editor.palette().is_some()); 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] #[test]
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() { fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -895,10 +1002,8 @@ end
.contains("must be a String") .contains("must be a String")
); );
for target in [ for target in [
"config.core.lsp_hover", "7",
"config.core.lsp_definition", "{}",
"config.core.lsp_diagnostics",
"config.core.lsp.hvoer",
"config.core.lsp", "config.core.lsp",
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", "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.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
+3 -1
View File
@@ -124,7 +124,9 @@ module Vamp
# @return [String] # @return [String]
def external_command(command) def external_command(command)
name = Vamp.string!(command, "binding 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 name
end end
+18 -4
View File
@@ -58,16 +58,30 @@ module Vamp
# @type [Module<Vamp::Core>] # @type [Module<Vamp::Core>]
Core = Module.new 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 # @return [Array<Module>] frozen action namespaces
def self.install_actions(names) def self.install_actions(entries)
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array) raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
namespaces = [Core] 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) namespaces.each(&:freeze)
end 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. # Frozen binding mode values, distinct from a buffer's editing state.
module Modes module Modes
# @!method self.normal # @!method self.normal