refactor: derive canonical commands from core actions and centralize aliases
This commit is contained in:
+76
-49
@@ -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());
|
||||
|
||||
+5
-3
@@ -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
|
||||
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)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
||||
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)
|
||||
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
|
||||
|
||||
+43
-1
@@ -516,6 +516,7 @@ impl Host {
|
||||
#[derive(Serialize)]
|
||||
struct Startup<'a> {
|
||||
core_actions: crate::editor::CoreActionCatalog,
|
||||
command_aliases: crate::editor::CoreCommandAliases,
|
||||
options: &'a serde_json::Value,
|
||||
}
|
||||
let mut encoded_startup = FrameWriter(Vec::new());
|
||||
@@ -523,6 +524,7 @@ impl Host {
|
||||
&mut encoded_startup,
|
||||
&Startup {
|
||||
core_actions: crate::editor::CoreActionCatalog,
|
||||
command_aliases: crate::editor::CoreCommandAliases,
|
||||
options,
|
||||
},
|
||||
)
|
||||
@@ -1018,7 +1020,20 @@ end"#,
|
||||
#[test]
|
||||
fn raw_builtin_aliases_are_not_external_binding_commands() {
|
||||
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(
|
||||
"config.rb",
|
||||
&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]
|
||||
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ module Vamp
|
||||
# @param startup [Object] action catalog and options
|
||||
# @return [void]
|
||||
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"
|
||||
end
|
||||
@@ -100,7 +100,7 @@ module Vamp
|
||||
# @return [Proc, Method] the installed sender
|
||||
def self.setup(mode, startup, sender)
|
||||
FrameValidation.startup!(startup)
|
||||
install_actions(startup["core_actions"])
|
||||
install_actions(startup["core_actions"], startup["command_aliases"])
|
||||
@mode = mode
|
||||
reset_registrations
|
||||
@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
|
||||
|
||||
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, "duplicate command #{name}" if @commands.key?(name)
|
||||
|
||||
|
||||
+17
-6
@@ -58,26 +58,37 @@ module Vamp
|
||||
# @type [Module<Vamp::Core>]
|
||||
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
|
||||
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, "command aliases must be a Hash" unless command_aliases.is_a?(Hash)
|
||||
|
||||
namespaces = [Core]
|
||||
aliases = {}
|
||||
commands = {}
|
||||
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)
|
||||
|
||||
install_action(entry.fetch("name"), namespaces)
|
||||
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
|
||||
@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)
|
||||
end
|
||||
|
||||
# @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)
|
||||
@builtin_commands.key?(name)
|
||||
end
|
||||
|
||||
@@ -1918,24 +1918,8 @@ Vamp.configure do |config|
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.editor()
|
||||
.message()
|
||||
.contains("unavailable capability: graphics")
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.editor()
|
||||
.message()
|
||||
.contains("Command already registered: q")
|
||||
);
|
||||
for command in ["draw", "leaked"] {
|
||||
session.execute(command);
|
||||
assert_eq!(
|
||||
session.editor().message(),
|
||||
format!("Unknown command: {command}")
|
||||
);
|
||||
assert_eq!(session.editor().lines(), &[String::new()]);
|
||||
}
|
||||
session.handle(Input::Char('g'));
|
||||
|
||||
@@ -8303,7 +8303,6 @@ end
|
||||
8 => {
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line(), "q! ");
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
9 => assert!(editor.should_quit()),
|
||||
|
||||
Reference in New Issue
Block a user