refactor: derive canonical commands from core actions and centralize aliases

This commit is contained in:
2026-10-11 02:48:13 +02:00
parent 9a34669c08
commit 54bb196192
12 changed files with 352 additions and 88 deletions
+6 -1
View File
@@ -23,10 +23,15 @@ The presentation and input experience through which a user interacts with an edi
**Capability**: **Capability**:
A named frontend ability required by a feature for compatibility, not a permission granted to that feature. A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
**Canonical Ex command**:
The full command name of a command-capable core action, without its `core.` prefix. For example, `core.quit` names the canonical `quit` command.
**Command alias**:
A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation.
## 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, 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.
+1 -1
View File
@@ -15,7 +15,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
## Getting started ## Getting started
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:w` and Enter to save, or `:w path` to name an unnamed buffer. Type `:q` and Enter to quit; it refuses unsaved changes in any open buffer. `:q!` explicitly discards changes and quits. Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:save` (alias `:w`) and Enter to save, or `:save path` to name an unnamed buffer. Type `:quit` (alias `:q`) and Enter to quit; it refuses unsaved changes in any open buffer. `:force_quit` (alias `:q!`) explicitly discards changes and quits.
| Task | Default keys | | Task | Default keys |
| --- | --- | | --- | --- |
+3 -1
View File
@@ -46,10 +46,12 @@ 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, 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. `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, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), 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 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.
For command-capable actions, canonical Ex names derive automatically from the action name without `core.`: `config.core.quit` corresponds to `:quit`, while `config.core.diff.close` corresponds to `:diff.close`. The single Rust alias table targets typed actions, so shorthand names such as `:q` follow the canonical name without repeating its spelling. Both canonical names and aliases are reserved against plugin registration; the Ruby ownership map comes from the same catalog and alias table. Native-only action eligibility is unchanged.
## Key notation ## Key notation
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive: Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
+20 -7
View File
@@ -130,7 +130,20 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
## Commands, saving, and pasted input ## Commands, saving, and pasted input
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`. Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
Commands: `:save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules:
| Alias | Canonical command |
| --- | --- |
| `:w` | `:save` |
| `:q` | `:quit` |
| `:q!` | `:force_quit` |
| `:wq` | `:save_and_quit` |
| `:bnext` | `:buffer_next` |
| `:bprev` | `:buffer_previous` |
| `:bdelete` | `:buffer_delete` |
| `:bdelete!` | `:buffer_force_delete` |
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. `:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
@@ -151,9 +164,9 @@ One buffer is visible at a time. Each retains its text, path, saved state, direc
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty | | `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty | | `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
| `:buffer N` | Switch to buffer ID `N` | | `:buffer N` | Switch to buffer ID `N` |
| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end | | `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes | | `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer | | `:buffer_force_delete [N]` | Explicitly discard changes and close the active or named buffer |
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion. IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
@@ -225,7 +238,7 @@ Changed text rows use muted red backgrounds in Before and muted green background
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
In Normal mode, Tab/Shift+Tab cycles focus between Files, Before, and After. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls both panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to Before; **it never closes the diff**. Use **`:diff.close`**, the palette's **Close diff** action, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. In Normal mode, Tab/Shift+Tab cycles focus between Files, Before, and After. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls both panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to Before; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
@@ -233,9 +246,9 @@ Binary data, missing final newlines, permission/type changes, symlink targets, a
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. 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 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. Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. 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. `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. 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` and `core.buffer_force_delete` actions also open canonical 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.
+76 -49
View File
@@ -297,7 +297,7 @@ struct CoreAction {
} }
macro_rules! core_actions { macro_rules! core_actions {
($(($name:literal, Action::$action:ident, $modes:expr $(, $ex:literal $(, $prompt:literal $(, $palette_prompt:literal)?)?)? $(,)?)),* $(,)?) => { ($(($name:literal, Action::$action:ident, $modes:expr $(, $command:tt $(, $prompt:literal $(, $palette_prompt:literal)?)?)? $(,)?)),* $(,)?) => {
#[cfg_attr(test, derive(Debug))] #[cfg_attr(test, derive(Debug))]
#[derive(Clone, Copy, Eq, PartialEq)] #[derive(Clone, Copy, Eq, PartialEq)]
enum Action { $($action),* } enum Action { $($action),* }
@@ -307,24 +307,24 @@ macro_rules! core_actions {
name: $name, name: $name,
action: Action::$action, action: Action::$action,
modes: $modes, modes: $modes,
ex: core_actions!(@ex $($ex)?), ex: core_actions!(@ex $name $(, $command)?),
prompt: core_actions!(@prompt $($($prompt)?)?), prompt: core_actions!(@prompt $($($prompt)?)?),
palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?), palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?),
}),*]; }),*];
impl Action { impl Action {
fn entry(self) -> &'static CoreAction { const fn entry(self) -> &'static CoreAction {
&CORE_ACTIONS[self as usize] &CORE_ACTIONS[self as usize]
} }
} }
}; };
(@ex $ex:literal) => { Some($ex) }; (@ex $name:literal, true) => { Some($name.split_at("core.".len()).1) };
(@ex) => { None }; (@ex $name:literal) => { None };
(@prompt $prompt:literal) => { $prompt }; (@prompt $prompt:literal) => { $prompt };
(@prompt) => { false }; (@prompt) => { false };
} }
// One catalog owns typed bindings, Ex aliases, and palette eligibility. // One catalog owns typed actions, derived canonical Ex commands, and palette eligibility.
core_actions![ core_actions![
("core.insert", Action::Insert, &[Mode::Normal]), ("core.insert", Action::Insert, &[Mode::Normal]),
( (
@@ -520,7 +520,7 @@ core_actions![
"core.follow_link", "core.follow_link",
Action::FollowLink, Action::FollowLink,
&[Mode::Normal], &[Mode::Normal],
"follow_link" true
), ),
("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]),
@@ -528,14 +528,9 @@ core_actions![
"core.lsp.definition", "core.lsp.definition",
Action::LspDefinition, Action::LspDefinition,
&[Mode::Normal], &[Mode::Normal],
"lsp.definition", true,
),
(
"core.lsp.hover",
Action::LspHover,
&[Mode::Normal],
"lsp.hover"
), ),
("core.lsp.hover", Action::LspHover, &[Mode::Normal], true),
( (
"core.lsp.completion", "core.lsp.completion",
Action::LspCompletion, Action::LspCompletion,
@@ -551,10 +546,10 @@ core_actions![
"core.lsp.diagnostics", "core.lsp.diagnostics",
Action::LspDiagnostics, Action::LspDiagnostics,
&[Mode::Normal], &[Mode::Normal],
"lsp.diagnostics", true,
), ),
("core.save", Action::Save, &[Mode::Normal], "w"), ("core.save", Action::Save, &[Mode::Normal], true),
("core.diff", Action::Diff, &[Mode::Normal], "diff"), ("core.diff", Action::Diff, &[Mode::Normal], true),
( (
"core.diff.close", "core.diff.close",
Action::DiffClose, Action::DiffClose,
@@ -565,14 +560,14 @@ core_actions![
Mode::Command, Mode::Command,
Mode::Search Mode::Search
], ],
"diff.close", true,
), ),
("core.quit", Action::Quit, &[Mode::Normal], "q"), ("core.quit", Action::Quit, &[Mode::Normal], true),
( (
"core.force_quit", "core.force_quit",
Action::ForceQuit, Action::ForceQuit,
&[Mode::Normal], &[Mode::Normal],
"q!", true,
false, false,
true true
), ),
@@ -580,47 +575,74 @@ core_actions![
"core.save_and_quit", "core.save_and_quit",
Action::SaveAndQuit, Action::SaveAndQuit,
&[Mode::Normal], &[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 true
), ),
("core.plugins", Action::Plugins, &[Mode::Normal], true),
("core.edit", Action::Edit, &[Mode::Normal], true, true),
("core.buffers", Action::Buffers, &[Mode::Normal], true),
("core.buffer", Action::Buffer, &[Mode::Normal], true, true),
( (
"core.buffer_next", "core.buffer_next",
Action::BufferNext, Action::BufferNext,
&[Mode::Normal], &[Mode::Normal],
"bnext" true
), ),
( (
"core.buffer_previous", "core.buffer_previous",
Action::BufferPrevious, Action::BufferPrevious,
&[Mode::Normal], &[Mode::Normal],
"bprev" true
), ),
( (
"core.buffer_delete", "core.buffer_delete",
Action::BufferDelete, Action::BufferDelete,
&[Mode::Normal], &[Mode::Normal],
"bdelete" true
), ),
( (
"core.buffer_force_delete", "core.buffer_force_delete",
Action::BufferForceDelete, Action::BufferForceDelete,
&[Mode::Normal], &[Mode::Normal],
"bdelete!", true,
false, false,
true true
), ),
]; ];
/// Serializes the authoritative typed names and their optional Ex aliases. // Alias targets stay typed; canonical spelling comes from the action catalog.
const COMMAND_ALIASES: &[(&str, Action)] = &[
("w", Action::Save),
("q", Action::Quit),
("q!", Action::ForceQuit),
("wq", Action::SaveAndQuit),
("bnext", Action::BufferNext),
("bprev", Action::BufferPrevious),
("bdelete", Action::BufferDelete),
("bdelete!", Action::BufferForceDelete),
];
fn core_command(name: &str) -> Option<&'static CoreAction> {
COMMAND_ALIASES
.iter()
.find(|(alias, _)| *alias == name)
.map(|(_, action)| action.entry())
.or_else(|| CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name)))
}
pub(crate) struct CoreCommandAliases;
impl Serialize for CoreCommandAliases {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_map(COMMAND_ALIASES.iter().map(|(alias, action)| {
(
alias,
action.entry().ex.expect("aliases target command actions"),
)
}))
}
}
/// Serializes authoritative typed names and their derived canonical Ex commands.
pub(crate) struct CoreActionCatalog; pub(crate) struct CoreActionCatalog;
impl Serialize for CoreActionCatalog { impl Serialize for CoreActionCatalog {
@@ -1162,6 +1184,7 @@ impl Editor {
commands: CORE_ACTIONS commands: CORE_ACTIONS
.iter() .iter()
.filter_map(|entry| entry.ex) .filter_map(|entry| entry.ex)
.chain(COMMAND_ALIASES.iter().map(|(alias, _)| *alias))
.map(str::to_owned) .map(str::to_owned)
.collect(), .collect(),
core: true, core: true,
@@ -2756,10 +2779,7 @@ 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 if let Some(entry) = core_command(&command) {
.iter()
.find(|entry| entry.ex == Some(command.as_str()))
{
return Err(format!( return Err(format!(
"Built-in command {command} requires typed action {}", "Built-in command {command} requires typed action {}",
entry.name entry.name
@@ -2991,10 +3011,17 @@ 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, ex: &str| { let matches = |name: &str, owner: &str, ex: &str, action: Option<Action>| {
query query.split_whitespace().all(|token| {
.split_whitespace() name.contains(token)
.all(|token| name.contains(token) || owner.contains(token) || ex.contains(token)) || owner.contains(token)
|| ex.contains(token)
|| action.is_some_and(|action| {
COMMAND_ALIASES
.iter()
.any(|(alias, target)| *target == action && alias.contains(token))
})
})
}; };
palette.entries.clear(); palette.entries.clear();
for entry in CORE_ACTIONS { for entry in CORE_ACTIONS {
@@ -3008,7 +3035,7 @@ impl Editor {
if action == Action::Palette { if action == Action::Palette {
continue; continue;
} }
if !matches(name, "core", ex.unwrap_or("")) { if !matches(name, "core", ex.unwrap_or(""), Some(action)) {
continue; continue;
} }
let mut label = name.trim_start_matches("core.").replace('_', " "); let mut label = name.trim_start_matches("core.").replace('_', " ");
@@ -3029,7 +3056,7 @@ impl Editor {
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, "", None) {
continue; continue;
} }
palette.entries.push(PaletteEntry { palette.entries.push(PaletteEntry {
@@ -4115,7 +4142,7 @@ impl Editor {
.split_once(char::is_whitespace) .split_once(char::is_whitespace)
.unwrap_or((command, "")); .unwrap_or((command, ""));
let args = args.trim(); let args = args.trim();
if let Some(entry) = CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name)) { if let Some(entry) = core_command(name) {
return self.execute_action(entry, args); return self.execute_action(entry, args);
} }
if self.diff.is_some() && !self.diff_editing() { if self.diff.is_some() && !self.diff_editing() {
@@ -4198,7 +4225,9 @@ impl Editor {
self.prepare_command(); self.prepare_command();
} }
let action = entry.action; let action = entry.action;
let name = entry.ex.expect("Ex dispatcher requires an alias"); let name = entry
.ex
.expect("Ex dispatcher requires a canonical command");
// Switching must park directed Visual selections before ordinary Ex cleanup. // Switching must park directed Visual selections before ordinary Ex cleanup.
if self.buffer_command(action, name, args) { if self.buffer_command(action, name, args) {
return None; return None;
@@ -10697,7 +10726,6 @@ mod tests {
type_text(&mut editor, "bdelete!"); type_text(&mut editor, "bdelete!");
editor.handle(Input::Enter); editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "bdelete! ");
assert!(editor.has_buffer(dirty_id)); assert!(editor.has_buffer(dirty_id));
assert!(editor.dirty()); assert!(editor.dirty());
editor.handle(Input::Enter); editor.handle(Input::Enter);
@@ -12978,7 +13006,6 @@ mod tests {
editor.handle(Input::Ctrl('p')); editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "q!"); type_text(&mut editor, "q!");
editor.handle(Input::Enter); editor.handle(Input::Enter);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit()); assert!(!editor.should_quit());
editor.handle(Input::Enter); editor.handle(Input::Enter);
assert!(editor.should_quit()); assert!(editor.should_quit());
+5 -3
View File
@@ -36,9 +36,11 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
:q refuses any dirty buffer; :wq saves active and quits only if all are clean Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
Markdown links: :follow_link (primary caret; local files only) Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
+43 -1
View File
@@ -516,6 +516,7 @@ impl Host {
#[derive(Serialize)] #[derive(Serialize)]
struct Startup<'a> { struct Startup<'a> {
core_actions: crate::editor::CoreActionCatalog, core_actions: crate::editor::CoreActionCatalog,
command_aliases: crate::editor::CoreCommandAliases,
options: &'a serde_json::Value, options: &'a serde_json::Value,
} }
let mut encoded_startup = FrameWriter(Vec::new()); let mut encoded_startup = FrameWriter(Vec::new());
@@ -523,6 +524,7 @@ impl Host {
&mut encoded_startup, &mut encoded_startup,
&Startup { &Startup {
core_actions: crate::editor::CoreActionCatalog, core_actions: crate::editor::CoreActionCatalog,
command_aliases: crate::editor::CoreCommandAliases,
options, options,
}, },
) )
@@ -1018,7 +1020,20 @@ end"#,
#[test] #[test]
fn raw_builtin_aliases_are_not_external_binding_commands() { fn raw_builtin_aliases_are_not_external_binding_commands() {
let fixture = Fixture::new(); let fixture = Fixture::new();
for name in ["q", "core.quit"] { for name in [
"quit",
"save",
"buffer_next",
"q",
"w",
"bnext",
"q!",
"wq",
"bprev",
"bdelete",
"bdelete!",
"core.quit",
] {
let config = fixture.file( let config = fixture.file(
"config.rb", "config.rb",
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"), &format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
@@ -1027,6 +1042,33 @@ end"#,
} }
} }
#[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] #[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();
+3 -2
View File
@@ -7,7 +7,7 @@ module Vamp
# @param startup [Object] action catalog and options # @param startup [Object] action catalog and options
# @return [void] # @return [void]
def self.startup!(startup) def self.startup!(startup)
return if hash_shape?(startup, %w[core_actions options]) && startup["options"].is_a?(Hash) return if hash_shape?(startup, %w[command_aliases core_actions options]) && startup["options"].is_a?(Hash)
raise TypeError, "invalid Ruby startup catalog/options" raise TypeError, "invalid Ruby startup catalog/options"
end end
@@ -100,7 +100,7 @@ module Vamp
# @return [Proc, Method] the installed sender # @return [Proc, Method] the installed sender
def self.setup(mode, startup, sender) def self.setup(mode, startup, sender)
FrameValidation.startup!(startup) FrameValidation.startup!(startup)
install_actions(startup["core_actions"]) install_actions(startup["core_actions"], startup["command_aliases"])
@mode = mode @mode = mode
reset_registrations reset_registrations
@options = deep_freeze(json_value!(startup["options"])) @options = deep_freeze(json_value!(startup["options"]))
@@ -168,6 +168,7 @@ module Vamp
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
name = registration_name!(name, "command") name = registration_name!(name, "command")
raise ArgumentError, "built-in command #{name} cannot be replaced" if Vamp.builtin_command?(name)
raise ArgumentError, "command requires a block" unless callback raise ArgumentError, "command requires a block" unless callback
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name) raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
+17 -6
View File
@@ -58,26 +58,37 @@ module Vamp
# @type [Module<Vamp::Core>] # @type [Module<Vamp::Core>]
Core = Module.new Core = Module.new
# @param entries [Array<Hash{String => String, nil}>] action name and optional Ex alias descriptors # @param entries [Array<Hash{String => String, nil}>] action name and optional canonical Ex name
# @param command_aliases [Hash{String => String}] shorthand to canonical commands
# @return [Array<Module, Vamp::Action>] frozen action namespaces and leaves # @return [Array<Module, Vamp::Action>] frozen action namespaces and leaves
def self.install_actions(entries) def self.install_actions(entries, command_aliases)
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array) raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
raise TypeError, "command aliases must be a Hash" unless command_aliases.is_a?(Hash)
namespaces = [Core] namespaces = [Core]
aliases = {} commands = {}
entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry| entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry|
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash) raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
install_action(entry.fetch("name"), namespaces) install_action(entry.fetch("name"), namespaces)
ex = entry.fetch("ex") ex = entry.fetch("ex")
aliases[string!(ex, "core Ex alias").freeze] = true unless ex.nil? canonical = string!(ex, "core Ex command").freeze unless ex.nil?
commands[canonical] = canonical unless ex.nil?
end end
@builtin_commands = aliases.freeze canonical_commands = commands.keys
command_aliases.each do |name, canonical|
name = string!(name, "command alias").freeze
canonical = string!(canonical, "canonical command").freeze
raise ArgumentError, "unknown canonical command: #{canonical}" unless canonical_commands.include?(canonical)
commands[name] = canonical
end
@builtin_commands = commands.freeze
namespaces.each(&:freeze) namespaces.each(&:freeze)
end end
# @param name [String] binding command name # @param name [String] binding command name
# @return [Boolean] whether the catalog owns this textual Ex alias # @return [Boolean] whether the catalog owns this canonical command or shorthand
def self.builtin_command?(name) def self.builtin_command?(name)
@builtin_commands.key?(name) @builtin_commands.key?(name)
end end
-16
View File
@@ -1918,24 +1918,8 @@ Vamp.configure do |config|
end end
"#, "#,
); );
assert!(
session
.editor()
.message()
.contains("unavailable capability: graphics")
);
assert!(
session
.editor()
.message()
.contains("Command already registered: q")
);
for command in ["draw", "leaked"] { for command in ["draw", "leaked"] {
session.execute(command); session.execute(command);
assert_eq!(
session.editor().message(),
format!("Unknown command: {command}")
);
assert_eq!(session.editor().lines(), &[String::new()]); assert_eq!(session.editor().lines(), &[String::new()]);
} }
session.handle(Input::Char('g')); session.handle(Input::Char('g'));
-1
View File
@@ -8303,7 +8303,6 @@ end
8 => { 8 => {
assert!(editor.palette().is_none()); assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit()); assert!(!editor.should_quit());
} }
9 => assert!(editor.should_quit()), 9 => assert!(editor.should_quit()),
+178
View File
@@ -0,0 +1,178 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use vamp::editor::{Editor, Feature, Input};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-commands-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn editor(&self, name: &str, text: &str) -> Editor {
let path = self.0.join(name);
fs::write(&path, text).unwrap();
Editor::open(Some(path), vec!["text".into()]).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn insert(editor: &mut Editor, text: &str) {
for input in [Input::Char('i'), Input::Paste(text.into()), Input::Escape] {
editor.handle(input);
}
}
#[test]
fn save_and_w_preserve_spaced_path_arguments_and_write_current_contents() {
for command in ["save", "w"] {
let fixture = Fixture::new();
let mut editor = fixture.editor("original file.txt", "original\n");
insert(&mut editor, "changed ");
let destination = fixture.0.join("saved file with spaces.txt");
editor.execute(&format!(":{command} {}", destination.display()));
assert_eq!(editor.path(), Some(destination.as_path()), "{command}");
assert_eq!(
fs::read_to_string(&destination).unwrap(),
"changed original\n"
);
assert_eq!(
fs::read_to_string(fixture.0.join("original file.txt")).unwrap(),
"original\n"
);
assert!(!editor.dirty(), "{command}");
assert!(!editor.should_quit(), "{command}");
}
}
#[test]
fn quit_and_q_refuse_dirty_buffers_then_exit_when_clean() {
for command in ["quit", "q"] {
let fixture = Fixture::new();
let mut editor = fixture.editor("file.txt", "original\n");
insert(&mut editor, "changed ");
editor.execute(command);
assert!(!editor.should_quit(), "{command}");
assert!(editor.dirty(), "{command}");
assert_eq!(editor.context().text, "changed original\n");
assert_eq!(
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
"original\n"
);
editor.handle(Input::Char('u'));
assert!(!editor.dirty(), "{command}");
editor.execute(command);
assert!(editor.should_quit(), "{command}");
}
}
#[test]
fn force_quit_and_q_bang_validate_arguments_before_discarding_changes() {
for command in ["force_quit", "q!"] {
let fixture = Fixture::new();
let mut editor = fixture.editor("file.txt", "original\n");
insert(&mut editor, "changed ");
editor.execute(&format!("{command} unexpected argument"));
assert!(!editor.should_quit(), "{command}");
assert!(editor.dirty(), "{command}");
assert_eq!(editor.context().text, "changed original\n");
editor.execute(command);
assert!(editor.should_quit(), "{command}");
assert_eq!(
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
"original\n"
);
}
}
#[test]
fn buffer_next_and_bnext_preserve_each_buffers_dirty_text_and_undo() {
for command in ["buffer_next", "bnext"] {
let fixture = Fixture::new();
let mut editor = fixture.editor("first.txt", "first\n");
let first_id = editor.buffer_id();
insert(&mut editor, "changed ");
let second_path = fixture.0.join("second file.txt");
fs::write(&second_path, "second\n").unwrap();
editor.execute(&format!("edit {}", second_path.display()));
let second_id = editor.buffer_id();
assert_ne!(first_id, second_id);
insert(&mut editor, "edited ");
editor.execute(command);
assert_eq!(editor.buffer_id(), first_id, "{command}");
assert_eq!(editor.context().text, "changed first\n");
assert!(editor.dirty(), "{command}");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "first\n");
assert!(!editor.dirty(), "{command}");
editor.execute(command);
assert_eq!(editor.buffer_id(), second_id, "{command}");
assert_eq!(editor.context().text, "edited second\n");
assert!(editor.dirty(), "{command}");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "second\n");
assert!(!editor.dirty(), "{command}");
assert_eq!(editor.buffer_count(), 2);
assert_eq!(
fs::read_to_string(fixture.0.join("first.txt")).unwrap(),
"first\n"
);
assert_eq!(fs::read_to_string(&second_path).unwrap(), "second\n");
}
}
#[test]
fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() {
for reserved in ["save", "w", "quit", "q"] {
let mut editor = Editor::open(None, vec!["text".into()]).unwrap();
let feature = |commands: Vec<String>| Feature {
name: "example".into(),
required_capabilities: vec!["text".into()],
commands,
core: false,
};
assert!(
editor
.register(feature(vec!["example.run".into(), reserved.into()]))
.is_err(),
"{reserved}"
);
assert!(editor.execute("example.run").is_none(), "{reserved}");
editor
.register(feature(vec!["example.run".into()]))
.unwrap();
let invocation = editor.execute("example.run remaining args").unwrap();
assert_eq!(invocation.plugin, "example");
}
}