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
+76 -49
View File
@@ -297,7 +297,7 @@ struct CoreAction {
}
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))]
#[derive(Clone, Copy, Eq, PartialEq)]
enum Action { $($action),* }
@@ -307,24 +307,24 @@ macro_rules! core_actions {
name: $name,
action: Action::$action,
modes: $modes,
ex: core_actions!(@ex $($ex)?),
ex: core_actions!(@ex $name $(, $command)?),
prompt: core_actions!(@prompt $($($prompt)?)?),
palette_prompt: core_actions!(@prompt $($($($palette_prompt)?)?)?),
}),*];
impl Action {
fn entry(self) -> &'static CoreAction {
const fn entry(self) -> &'static CoreAction {
&CORE_ACTIONS[self as usize]
}
}
};
(@ex $ex:literal) => { Some($ex) };
(@ex) => { None };
(@ex $name:literal, true) => { Some($name.split_at("core.".len()).1) };
(@ex $name:literal) => { None };
(@prompt $prompt:literal) => { $prompt };
(@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.insert", Action::Insert, &[Mode::Normal]),
(
@@ -520,7 +520,7 @@ core_actions![
"core.follow_link",
Action::FollowLink,
&[Mode::Normal],
"follow_link"
true
),
("core.jump_back", Action::JumpBack, &[Mode::Normal]),
("core.jump_forward", Action::JumpForward, &[Mode::Normal]),
@@ -528,14 +528,9 @@ core_actions![
"core.lsp.definition",
Action::LspDefinition,
&[Mode::Normal],
"lsp.definition",
),
(
"core.lsp.hover",
Action::LspHover,
&[Mode::Normal],
"lsp.hover"
true,
),
("core.lsp.hover", Action::LspHover, &[Mode::Normal], true),
(
"core.lsp.completion",
Action::LspCompletion,
@@ -551,10 +546,10 @@ core_actions![
"core.lsp.diagnostics",
Action::LspDiagnostics,
&[Mode::Normal],
"lsp.diagnostics",
true,
),
("core.save", Action::Save, &[Mode::Normal], "w"),
("core.diff", Action::Diff, &[Mode::Normal], "diff"),
("core.save", Action::Save, &[Mode::Normal], true),
("core.diff", Action::Diff, &[Mode::Normal], true),
(
"core.diff.close",
Action::DiffClose,
@@ -565,14 +560,14 @@ core_actions![
Mode::Command,
Mode::Search
],
"diff.close",
true,
),
("core.quit", Action::Quit, &[Mode::Normal], "q"),
("core.quit", Action::Quit, &[Mode::Normal], true),
(
"core.force_quit",
Action::ForceQuit,
&[Mode::Normal],
"q!",
true,
false,
true
),
@@ -580,47 +575,74 @@ core_actions![
"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.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",
Action::BufferNext,
&[Mode::Normal],
"bnext"
true
),
(
"core.buffer_previous",
Action::BufferPrevious,
&[Mode::Normal],
"bprev"
true
),
(
"core.buffer_delete",
Action::BufferDelete,
&[Mode::Normal],
"bdelete"
true
),
(
"core.buffer_force_delete",
Action::BufferForceDelete,
&[Mode::Normal],
"bdelete!",
true,
false,
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;
impl Serialize for CoreActionCatalog {
@@ -1162,6 +1184,7 @@ impl Editor {
commands: CORE_ACTIONS
.iter()
.filter_map(|entry| entry.ex)
.chain(COMMAND_ALIASES.iter().map(|(alias, _)| *alias))
.map(str::to_owned)
.collect(),
core: true,
@@ -2756,10 +2779,7 @@ 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()))
{
if let Some(entry) = core_command(&command) {
return Err(format!(
"Built-in command {command} requires typed action {}",
entry.name
@@ -2991,10 +3011,17 @@ impl Editor {
};
let query = palette.query.to_lowercase();
// Derived labels contain the same searchable words as their names.
let matches = |name: &str, owner: &str, ex: &str| {
query
.split_whitespace()
.all(|token| name.contains(token) || owner.contains(token) || ex.contains(token))
let matches = |name: &str, owner: &str, ex: &str, action: Option<Action>| {
query.split_whitespace().all(|token| {
name.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();
for entry in CORE_ACTIONS {
@@ -3008,7 +3035,7 @@ impl Editor {
if action == Action::Palette {
continue;
}
if !matches(name, "core", ex.unwrap_or("")) {
if !matches(name, "core", ex.unwrap_or(""), Some(action)) {
continue;
}
let mut label = name.trim_start_matches("core.").replace('_', " ");
@@ -3029,7 +3056,7 @@ impl Editor {
let owner = feature.name.to_lowercase();
for command in &feature.commands {
let name = command.to_lowercase();
if !matches(&name, &owner, "") {
if !matches(&name, &owner, "", None) {
continue;
}
palette.entries.push(PaletteEntry {
@@ -4115,7 +4142,7 @@ impl Editor {
.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)) {
if let Some(entry) = core_command(name) {
return self.execute_action(entry, args);
}
if self.diff.is_some() && !self.diff_editing() {
@@ -4198,7 +4225,9 @@ impl Editor {
self.prepare_command();
}
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.
if self.buffer_command(action, name, args) {
return None;
@@ -10697,7 +10726,6 @@ mod tests {
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);
@@ -12978,7 +13006,6 @@ mod tests {
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "q!");
editor.handle(Input::Enter);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit());
editor.handle(Input::Enter);
assert!(editor.should_quit());