From 3f05287d79a9ca4990d87ba6c6f6623b2427aa3d Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 10 Oct 2026 21:57:33 +0200 Subject: [PATCH] refactor: unify core actions and Ex command aliases --- CONTEXT.md | 2 +- docs/architecture.md | 4 +- docs/configuration.md | 9 +- docs/editing.md | 6 +- examples/config.rb | 1 + src/editor.rs | 578 ++++++++++++++++++++++++++++-------------- src/ruby.rs | 113 ++++++++- src/ruby_config.rb | 4 +- src/ruby_values.rb | 22 +- 9 files changed, 536 insertions(+), 203 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4a92778..222c9f0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,7 +26,7 @@ A named frontend ability required by a feature for compatibility, not a permissi ## 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. -- `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. - Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety. diff --git a/docs/architecture.md b/docs/architecture.md index 24c3d94..f5319b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,4 +77,6 @@ Manual completion dispatches the Insert-only `core.lsp.completion` action direct ## 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. diff --git a/docs/configuration.md b/docs/configuration.md index f70efbe..2897dd4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,7 @@ Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" config.bind "leader g", "greet" + config.bind "leader q", config.core.quit config.bind "ctrl+r", config.core.redo config.bind "shift+j", config.core.join_lines 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 -`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. @@ -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 | `join_lines`, `undo`, `redo`, `command` | | 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 | `jump_back`, `jump_forward` | | 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.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 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 -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. diff --git a/docs/editing.md b/docs/editing.md index 9d381c3..a6d5131 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -195,11 +195,11 @@ The project root is the working directory captured at startup. Scanning uses the ## 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. diff --git a/examples/config.rb b/examples/config.rb index 265672a..1c883b1 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -23,6 +23,7 @@ Vamp.configure do |config| # config.bind "K", config.core.lsp.hover config.bind "g", "greet" config.bind "leader g", "greet" + config.bind "leader q", config.core.quit config.bind "ctrl+r", config.core.redo config.bind "leader r", config.core.repeat_last_edit config.bind "ctrl+c", config.core.normal, mode: config.modes.insert diff --git a/src/editor.rs b/src/editor.rs index 1900240..a53e121 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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(&self, serializer: S) -> Result { - 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 { 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 { + 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::().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 { + 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 { + 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()); diff --git a/src/ruby.rs b/src/ruby.rs index d168636..f685579 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -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", diff --git a/src/ruby_config.rb b/src/ruby_config.rb index e14a621..26f3ce8 100644 --- a/src/ruby_config.rb +++ b/src/ruby_config.rb @@ -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 diff --git a/src/ruby_values.rb b/src/ruby_values.rb index 6741823..1bc90b5 100644 --- a/src/ruby_values.rb +++ b/src/ruby_values.rb @@ -58,16 +58,30 @@ module Vamp # @type [Module] Core = Module.new - # @param names [Array] dotted core action names + # @param entries [Array String, nil}>] action name and optional Ex alias descriptors # @return [Array] 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