From 69583f30300f2177b3d89500d67d8a38ae7f5df9 Mon Sep 17 00:00:00 2001 From: Fabian Boehm Date: Wed, 24 Apr 2024 18:09:04 +0200 Subject: [PATCH] Allow restricting abbreviations to specific commands (#10452) This allows making something like ```fish abbr --add gc --position anywhere --command git back 'reset --hard HEAD^' ``` to expand "gc" to "reset --hard HEAD^", but only if the command is git (including "command git gc" or "and git gc"). Fixes #9411 --- doc_src/cmds/abbr.rst | 9 ++++++++- src/abbrs.rs | 26 +++++++++++++++++++------- src/builtins/abbr.rs | 26 ++++++++++++++++++++++++-- src/highlight.rs | 2 +- src/reader.rs | 29 ++++++++++++++++++++++++----- src/tests/abbrs.rs | 4 ++-- src/tests/expand.rs | 2 +- tests/pexpects/abbrs.py | 26 ++++++++++++++++++++++++++ 8 files changed, 105 insertions(+), 19 deletions(-) diff --git a/doc_src/cmds/abbr.rst b/doc_src/cmds/abbr.rst index 0ce9fdfa6..d5b24cef4 100644 --- a/doc_src/cmds/abbr.rst +++ b/doc_src/cmds/abbr.rst @@ -8,7 +8,7 @@ Synopsis .. synopsis:: - abbr --add NAME [--position command | anywhere] [-r | --regex PATTERN] + abbr --add NAME [--position command | anywhere] [-r | --regex PATTERN] [-c | --command COMMAND] [--set-cursor[=MARKER]] ([-f | --function FUNCTION] | EXPANSION) abbr --erase NAME ... abbr --rename OLD_WORD NEW_WORD @@ -69,6 +69,8 @@ Combining these features, it is possible to create custom syntaxes, where a regu With **--position command**, the abbreviation will only expand when it is positioned as a command, not as an argument to another command. With **--position anywhere** the abbreviation may expand anywhere in the command line. The default is **command**. +With **--command COMMAND**, the abbreviation will only expand when it is used as an argument to the given COMMAND. Multiple **--command** can be used together, and the abbreviation will expand for each. An empty **COMMAND** means it will expand only when there *is* no command. **--command** implies **--position anywhere** and disallows **--position command**. Even with different **COMMANDS**, the **NAME** of the abbreviation needs to be unique. Consider using **--regex** if you want to expand the same word differently for multiple commands. + With **--regex**, the abbreviation matches using the regular expression given by **PATTERN**, instead of the literal **NAME**. The pattern is interpreted using PCRE2 syntax and must match the entire token. If multiple abbreviations match the same token, the last abbreviation added is used. With **--set-cursor=MARKER**, the cursor is moved to the first occurrence of **MARKER** in the expansion. The **MARKER** value is erased. The **MARKER** may be omitted (i.e. simply ``--set-cursor``), in which case it defaults to ``%``. @@ -122,6 +124,11 @@ This first creates a function ``vim_edit`` which prepends ``vim`` before its arg This creates an abbreviation "4DIRS" which expands to a multi-line loop "template." The template enters each directory and then leaves it. The cursor is positioned ready to enter the command to run in each directory, at the location of the ``!``, which is itself erased. +:: + abbr --command git co checkout + +Turns "co" as an argument to "git" into "checkout". Multiple commands are possible, ``--command={git,hg}`` would expand "co" to "checkout" for both git and hg. + Other subcommands -------------------- diff --git a/src/abbrs.rs b/src/abbrs.rs index 20d3ac510..002e5eefd 100644 --- a/src/abbrs.rs +++ b/src/abbrs.rs @@ -50,6 +50,9 @@ pub struct Abbreviation { /// we accomplish this by surrounding the regex in ^ and $. pub regex: Option>, + /// The commands this abbr is valid for (or empty if any) + pub commands: Vec, + /// Replacement string. pub replacement: WString, @@ -80,6 +83,7 @@ impl Abbreviation { name, key, regex: None, + commands: vec![], replacement, replacement_is_function: false, position, @@ -94,10 +98,15 @@ impl Abbreviation { } // \return true if we match a token at a given position. - pub fn matches(&self, token: &wstr, position: Position) -> bool { + pub fn matches(&self, token: &wstr, position: Position, command: &wstr) -> bool { if !self.matches_position(position) { return false; } + if !self.commands.is_empty() { + if !self.commands.contains(&command.to_owned()) { + return false; + } + } match &self.regex { Some(r) => r .is_match(token.as_char_slice()) @@ -176,12 +185,12 @@ pub struct AbbreviationSet { impl AbbreviationSet { /// \return the list of replacers for an input token, in priority order. /// The \p position is given to describe where the token was found. - pub fn r#match(&self, token: &wstr, position: Position) -> Vec { + pub fn r#match(&self, token: &wstr, position: Position, cmd: &wstr) -> Vec { let mut result = vec![]; // Later abbreviations take precedence so walk backwards. for abbr in self.abbrs.iter().rev() { - if abbr.matches(token, position) { + if abbr.matches(token, position, cmd) { result.push(Replacer { replacement: abbr.replacement.clone(), is_function: abbr.replacement_is_function, @@ -193,8 +202,10 @@ impl AbbreviationSet { } /// \return whether we would have at least one replacer for a given token. - pub fn has_match(&self, token: &wstr, position: Position) -> bool { - self.abbrs.iter().any(|abbr| abbr.matches(token, position)) + pub fn has_match(&self, token: &wstr, position: Position, cmd: &wstr) -> bool { + self.abbrs + .iter() + .any(|abbr| abbr.matches(token, position, cmd)) } /// Add an abbreviation. Any abbreviation with the same name is replaced. @@ -260,8 +271,8 @@ impl AbbreviationSet { /// \return the list of replacers for an input token, in priority order, using the global set. /// The \p position is given to describe where the token was found. -pub fn abbrs_match(token: &wstr, position: Position) -> Vec { - with_abbrs(|set| set.r#match(token, position)) +pub fn abbrs_match(token: &wstr, position: Position, cmd: &wstr) -> Vec { + with_abbrs(|set| set.r#match(token, position, cmd)) .into_iter() .collect() } @@ -279,6 +290,7 @@ fn rename_abbrs() { name: name.into(), key: name.into(), regex: None, + commands: vec![], replacement: repl.into(), replacement_is_function: false, position, diff --git a/src/builtins/abbr.rs b/src/builtins/abbr.rs index 8e33f2633..7e310e5c4 100644 --- a/src/builtins/abbr.rs +++ b/src/builtins/abbr.rs @@ -17,6 +17,7 @@ struct Options { query: bool, function: Option, regex_pattern: Option, + commands: Vec, position: Option, set_cursor_marker: Option, args: Vec, @@ -154,6 +155,10 @@ fn abbr_show(streams: &mut IoStreams) -> Option { add_arg(L!("--function")); add_arg(&escape_string(&abbr.replacement, style)); } + for cmd in &abbr.commands { + add_arg(L!("--command")); + add_arg(&escape_string(cmd, style)); + } add_arg(L!("--")); // Literal abbreviations have the name and key as the same. // Regex abbreviations have a pattern separate from the name. @@ -372,7 +377,21 @@ fn abbr_add(opts: &Options, streams: &mut IoStreams) -> Option { replacement }; - let position = opts.position.unwrap_or(Position::Command); + let position = opts.position.unwrap_or({ + if opts.commands.is_empty() { + Position::Command + } else { + // If it is valid for a command, the abbr can't be in command-position. + Position::Anywhere + } + }); + if !opts.commands.is_empty() && position == Position::Command { + streams.err.appendln(wgettext_fmt!( + "%ls: --command cannot be combined with --position command", + CMD, + )); + return STATUS_INVALID_ARGS; + } // Note historically we have allowed overwriting existing abbreviations. abbrs::with_abbrs_mut(move |abbrs| { @@ -385,6 +404,7 @@ fn abbr_add(opts: &Options, streams: &mut IoStreams) -> Option { position, set_cursor_marker: opts.set_cursor_marker.clone(), from_universal: false, + commands: opts.commands.clone(), }) }); @@ -433,10 +453,11 @@ pub fn abbr(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -> Opt // Note the leading '-' causes wgetopter to return arguments in order, instead of permuting // them. We need this behavior for compatibility with pre-builtin abbreviations where options // could be given literally, for example `abbr e emacs -nw`. - const short_options: &wstr = L!("-:af:r:seqgUh"); + const short_options: &wstr = L!("-:ac:f:r:seqgUh"); const longopts: &[WOption] = &[ wopt(L!("add"), ArgType::NoArgument, 'a'), + wopt(L!("command"), ArgType::RequiredArgument, 'c'), wopt(L!("position"), ArgType::RequiredArgument, 'p'), wopt(L!("regex"), ArgType::RequiredArgument, 'r'), wopt( @@ -476,6 +497,7 @@ pub fn abbr(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -> Opt } } 'a' => opts.add = true, + 'c' => opts.commands.push(w.woptarg.map(|x| x.to_owned()).unwrap()), 'p' => { if opts.position.is_some() { streams.err.append(wgettext_fmt!( diff --git a/src/highlight.rs b/src/highlight.rs index 0f72d3c2f..69855633c 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -257,7 +257,7 @@ fn command_is_valid( // Abbreviations if !is_valid && abbreviation_ok { - is_valid = with_abbrs(|set| set.has_match(cmd, abbrs::Position::Command)) + is_valid = with_abbrs(|set| set.has_match(cmd, abbrs::Position::Command, L!(""))) }; // Regular commands diff --git a/src/reader.rs b/src/reader.rs index 41830d630..fc625f1ad 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -4569,19 +4569,38 @@ pub fn reader_expand_abbreviation_at_cursor( ) -> Option { // Find the token containing the cursor. Usually users edit from the end, so walk backwards. let tokens = extract_tokens(cmdline); - let token = tokens - .into_iter() - .rev() - .find(|token| token.range.contains_inclusive(cursor_pos))?; + let mut token: Option<_> = None; + let mut cmdtok: Option<_> = None; + + for t in tokens.into_iter().rev() { + let range = t.range; + let is_cmd = t.is_cmd; + if t.range.contains_inclusive(cursor_pos) { + token = Some(t); + } + // The command is at or *before* the token the cursor is on, + // and once we have a command we can stop. + if token.is_some() && is_cmd { + cmdtok = Some(range); + break; + } + } + let token = token?; let range = token.range; let position = if token.is_cmd { abbrs::Position::Command } else { abbrs::Position::Anywhere }; + // If the token itself is the command, we have no command to pass. + let cmd = if !token.is_cmd { + cmdtok.map(|t| &cmdline[Range::::from(t)]) + } else { + None + }; let token_str = &cmdline[Range::::from(range)]; - let replacers = abbrs_match(token_str, position); + let replacers = abbrs_match(token_str, position, cmd.unwrap_or(L!(""))); for replacer in replacers { if let Some(replacement) = expand_replacer(range, token_str, &replacer, parser) { return Some(replacement); diff --git a/src/tests/abbrs.rs b/src/tests/abbrs.rs index 485d89955..caf8c7dbb 100644 --- a/src/tests/abbrs.rs +++ b/src/tests/abbrs.rs @@ -45,11 +45,11 @@ fn test_abbreviations() { // Helper to expand an abbreviation, enforcing we have no more than one result. macro_rules! abbr_expand_1 { ($token:expr, $position:expr) => { - let result = abbrs_match(L!($token), $position); + let result = abbrs_match(L!($token), $position, L!("")); assert_eq!(result, vec![]); }; ($token:expr, $position:expr, $expected:expr) => { - let result = abbrs_match(L!($token), $position); + let result = abbrs_match(L!($token), $position, L!("")); assert_eq!( result .into_iter() diff --git a/src/tests/expand.rs b/src/tests/expand.rs index e328a9f75..092dae6f0 100644 --- a/src/tests/expand.rs +++ b/src/tests/expand.rs @@ -422,7 +422,7 @@ fn test_abbreviations() { // Helper to expand an abbreviation, enforcing we have no more than one result. let abbr_expand_1 = |token, pos| -> Option { - let result = with_abbrs(|abbrset| abbrset.r#match(token, pos)); + let result = with_abbrs(|abbrset| abbrset.r#match(token, pos, L!(""))); if result.is_empty() { return None; } diff --git a/tests/pexpects/abbrs.py b/tests/pexpects/abbrs.py index 918695804..4ec201f5d 100644 --- a/tests/pexpects/abbrs.py +++ b/tests/pexpects/abbrs.py @@ -157,3 +157,29 @@ sendline(r"""abbr LLL --position anywhere --set-cursor=!HERE! '!HERE! | less'""" expect_prompt() send(r"""echo LLL derp?""") expect_str(r"") + +sendline(r"""abbr foo --command echo bar""") +expect_prompt() +sendline(r"""printf '%s\n' foo """) +expect_prompt("foo") +sendline(r"""echo foo """) +expect_prompt("bar") +sendline(r"""true; and echo foo """) +expect_prompt("bar") +sendline(r"""true; and builtin echo foo """) +expect_prompt("bar") +sendline(r"""abbr fruit --command={git,hg,svn} banana""") +expect_prompt() +sendline(r"""function git; echo git $argv; end; function hg; echo hg $argv; end; function svn; echo svn $argv; end""") +expect_prompt() +sendline(r"""git fruit""") +expect_prompt("git banana") +sendline(r"""abbr""") +expect_prompt("abbr -a --position anywhere --command git --command hg --command svn -- fruit banana") +sendline(r"""function banana; echo I am a banana; end""") +expect_prompt() +sendline(r"""abbr fruit --command={git,hg,svn,} banana""") +expect_prompt() +sendline(r"""fruit foo""") +expect_prompt("I am a banana") +