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
This commit is contained in:
Fabian Boehm 2024-04-24 18:09:04 +02:00 committed by GitHub
parent 16eeba8f65
commit 69583f3030
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 105 additions and 19 deletions

View file

@ -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
--------------------

View file

@ -50,6 +50,9 @@ pub struct Abbreviation {
/// we accomplish this by surrounding the regex in ^ and $.
pub regex: Option<Box<Regex>>,
/// The commands this abbr is valid for (or empty if any)
pub commands: Vec<WString>,
/// 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<Replacer> {
pub fn r#match(&self, token: &wstr, position: Position, cmd: &wstr) -> Vec<Replacer> {
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<Replacer> {
with_abbrs(|set| set.r#match(token, position))
pub fn abbrs_match(token: &wstr, position: Position, cmd: &wstr) -> Vec<Replacer> {
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,

View file

@ -17,6 +17,7 @@ struct Options {
query: bool,
function: Option<WString>,
regex_pattern: Option<WString>,
commands: Vec<WString>,
position: Option<Position>,
set_cursor_marker: Option<WString>,
args: Vec<WString>,
@ -154,6 +155,10 @@ fn abbr_show(streams: &mut IoStreams) -> Option<c_int> {
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<c_int> {
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<c_int> {
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!(

View file

@ -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

View file

@ -4569,19 +4569,38 @@ pub fn reader_expand_abbreviation_at_cursor(
) -> Option<abbrs::Replacement> {
// 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::<usize>::from(t)])
} else {
None
};
let token_str = &cmdline[Range::<usize>::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);

View file

@ -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()

View file

@ -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<WString> {
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;
}

View file

@ -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"<echo derp | less >")
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")