refactor: unify core actions and Ex command aliases
This commit is contained in:
+391
-187
@@ -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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name))
|
||||
serializer.collect_seq(CORE_ACTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,8 +619,8 @@ impl Action {
|
||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||
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<Invocation> {
|
||||
let entry = action.entry();
|
||||
if entry.ex.is_some() {
|
||||
if entry.prompt {
|
||||
self.open_action_prompt(entry);
|
||||
return None;
|
||||
}
|
||||
return self.execute_action(entry, "");
|
||||
}
|
||||
self.finish_completion();
|
||||
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::<usize>().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<Invocation> {
|
||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||
let (name, args) = command
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((command, ""));
|
||||
let args = args.trim();
|
||||
if let Some(entry) = CORE_ACTIONS.iter().find(|entry| entry.ex == Some(name)) {
|
||||
return self.execute_action(entry, args);
|
||||
}
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let Some(feature) = self.owner(name) else {
|
||||
self.set_message(format!("Unknown command: {name}"));
|
||||
return None;
|
||||
};
|
||||
Some(Invocation {
|
||||
plugin: feature.name.clone(),
|
||||
command: name.into(),
|
||||
args: args.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_command(&mut self) {
|
||||
self.finish_completion();
|
||||
self.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<Invocation> {
|
||||
self.prepare_command();
|
||||
let action = entry.action;
|
||||
let name = entry.ex.expect("Ex dispatcher requires an alias");
|
||||
// Switching must park directed Visual selections before ordinary Ex cleanup.
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user