Skip to content

[Need discussion (cf #814)] refactor(_command_offset): implement Bash's $2 passed to completion functions #791

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions bash_completion
Original file line number Diff line number Diff line change
Expand Up @@ -2246,6 +2246,95 @@ else
complete -F _cd -o nospace cd pushd
fi

# Create a regular expresssion that matches with one of the characters in the
# specified string. This function basically constructs a bracket expression
# `ret=[$1]' but works around the cases where some characters in $1 have
# unexpected special meanings.
# @param $1 chars The set of characters that can match
# @var[out] ret Set to a regexp that matches with one character from `set`
_comp_command_offset__get_charcter_set_regex()
{
local chars=$1
ret=
if [[ $chars == '^' ]]; then
# This is the special case where /[$chars]/ cannot be used.
ret='\^'
elif [[ $chars ]]; then
# reorder characters so that /[$chars]/ is not misinterpreted as
# /[[:space:]]/, /[a]]/ (interpreted as [a] + ]), /[^a]/, and /[a-z]/.
[[ $chars == *'['* ]] && chars=${chars//'['/}'[' # avoid [:space:]
[[ $chars == *']'* ]] && chars=']'${chars//']'/} # avoid ]
[[ $chars == '^'* ]] && chars=${chars//'^'/}'^' # avoid [^
[[ $chars == *'-'* ]] && chars=${chars//'-'/}'-' # avoid a-z
ret=[$chars]
else
# a regular expression that does not match with any strings
ret='dummy^'
fi
}

# Initialize regular expressions used by `_comp_command_offset__reduce_cur`.
_comp_command_offset__initialize_regex()
{
# skip if it is already initialized.
[[ -v _comp_command_offset__mut_initialized && $COMP_WORDBREAKS == "$_comp_command_offset__mut_initialized" ]] && return
_comp_command_offset__mut_initialized=$COMP_WORDBREAKS
local backslash_quote='\\.' # \?
local double_quotes='"([^\"]|\\.)*"' # "..."
local single_quotes="'[^']*'" # '...'
local escape_string="\\$'([^'\]|\\\\.)*'" # $'...'

_comp_command_offset__mut_regex_closed_prefix='^([^\"'\'']|'$backslash_quote'|'$double_quotes'|'$single_quotes')*'

local chars=${COMP_WORDBREAKS//[\'\"]/}
if [[ $chars ]]; then
# construct a regular expression used to break the word by
# COMP_WORDBREAKS. The characters ' and " are not considered. The
# characters \ and $ matches in special ways.
local regex_break=
[[ $chars == *\\* ]] && chars=${chars//\\/} regex_break='\\(.|$)'
[[ $chars == *\$* ]] && chars=${chars//\$/} regex_break+=${regex_break:+'|'}'\$([^$'\'${regex_break:+\\}']|$)'
if [[ $chars ]]; then
local ret
_comp_command_offset__get_charcter_set_regex "$chars"
regex_break+=${regex_break:+'|'}$ret
fi
# Note: /\$*xxx|\$*yyy|\$+([^']|$)/ is a workaround for POSIX
# ERE not supporting forward assertions like
# /xxx|yyy|\$(?!')/.
_comp_command_offset__mut_regex_break='^([^\"'\''$]|'$single_quotes'|\$*'$escape_string'|\$*'$backslash_quote'|\$*'$double_quotes'|\$+([^'\'']|$))*\$*('$regex_break')'
else
# a regular expression that never matches
_comp_command_offset__mut_regex_break='dummy^'
fi
}

# Modify the current word in the way bash passes it as the second argument of a
# completion function specified by `complete -F func`.
#
# The rule is 1) When there is an unclosed left quote, the result is its
# content. To find an unclosed quotes, "...", '...', and \? are considered.
# 2) Otherwise, when there are delimiters that are specified by
# COMP_WORDBREAKS, the result is the substring after the last delimiter. The
# quote characters ' and " in COMP_WORDBREAKS are not counted as delimiters.
# To find unquoted delimiters, "...", '...', $'...', and \? are considered.
# The delimiters $, @, and \ are contained in the result in a special
# manner. 3) Otherwise, the result is the original string.
#
# @param $1 cur Current word before COMP_POINT
# @var[out] ret Set to modified current word
_comp_command_offset__reduce_cur()
{
_comp_command_offset__initialize_regex
ret=$1
if [[ $ret =~ $_comp_command_offset__mut_regex_closed_prefix && ${ret:${#BASH_REMATCH}} == [\'\"]* ]]; then
ret=${ret:${#BASH_REMATCH}+1}
elif [[ $ret =~ $_comp_command_offset__mut_regex_break ]]; then
ret=${ret:${#BASH_REMATCH}}
[[ ${BASH_REMATCH[5]} == @(\$*|@|\\?) ]] && ret=${BASH_REMATCH[5]#\\}$ret
fi
}

# A meta-command completion function for commands like sudo(8), which need to
# first complete on a command, then complete according to that command's own
# completion definition.
Expand Down Expand Up @@ -2314,10 +2403,13 @@ _comp_command_offset()
local func=${cspec#* -F }
func=${func%% *}

local ret
_comp_command_offset__reduce_cur "$cur"
local original_cur=$ret
if ((${#COMP_WORDS[@]} >= 2)); then
$func "$cmd" "${COMP_WORDS[-1]}" "${COMP_WORDS[-2]}"
$func "$cmd" "$original_cur" "${COMP_WORDS[-2]}"
else
$func "$cmd" "${COMP_WORDS[-1]}"
$func "$cmd" "$original_cur"
fi

# restart completion (once) if function exited with 124
Expand Down
170 changes: 169 additions & 1 deletion test/t/unit/test_unit_command_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from conftest import assert_bash_exec, assert_complete
from conftest import assert_bash_exec, assert_complete, bash_env_saved


def join(words):
Expand Down Expand Up @@ -72,3 +72,171 @@ def test_2(self, bash, functions, cmd, expected_completion):
cleared before the retry.
"""
assert assert_complete(bash, "meta %s " % cmd) == expected_completion


@pytest.mark.bashcomp(cmd=None, ignore_env=r"^[+-]ret=")
class TestUnitCommandOffsetReduceCur:
def check(self, bash, cur, expected):
assert (
assert_bash_exec(
bash,
'_comp_command_offset__reduce_cur %s; echo "$ret"'
% quote(cur),
want_output=True,
).strip("\r\n")
== expected
)

@pytest.mark.parametrize(
"cur,expected",
[
("==", ""),
("=:", ""),
("--foo'=", "="),
("=", ""),
("'=", "="),
("'='", "'='"),
('a"b', "b"),
('a"b"', 'a"b"'),
('a"b"c', 'a"b"c'),
('a"b"c"', ""),
("a", "a"),
("ab", "ab"),
("abc", "abc"),
("abcd", "abcd"),
('a"a$(echo', "a$(echo"),
('a"a$(echo "', 'a"a$(echo "'),
('a"a$(echo "world"x', "x"),
('a"a$(echo "world"x)', "x)"),
("a${va", "a${va"),
("$'a", "a"),
(r"$'a\n", r"a\n"),
(r"$'a\n\'", r"$'a\n\'"),
(r"$'a\n\' '", ""),
(r"$'a\n\' \'x", r"$'a\n\' \'x"),
(r"$'a\n\' \'xyz'x", "x"),
(r"a'bb\'aaa", r"a'bb\'aaa"),
(r"a'bb\ 'aaa'c", "c"),
('a"bb', "bb"),
(r'a"bb\"a', r"bb\"a"),
(r'a"bb\"a"c', r'a"bb\"a"c'),
("a`", "a`"),
("a`echo", "a`echo"),
("a`echo w", "w"),
('a"echo ', "echo "),
('a"echo w', "echo w"),
(r"$'a\' x", r"$'a\' x"),
("a`bbb ccc`", "ccc`"),
("a`aa'a", "a"),
('a`aa"aa', "aa"),
(r"a`aa$'a\'a a", r"a`aa$'a\'a a"),
(r"a`b$'c\'d e", r"a`b$'c\'d e"),
(r"$'c\'d e`f g", r"$'c\'d e`f g"),
(r"$'c\'d e'f`g h", "f`g h"),
("$'a b'c`d e", "e"),
("a`b'c'd e", "e"),
("a`b'c'd e f", "f"),
("a`$(echo world", "world"),
(r"a`$'a\' b", r"a`$'a\' b"),
(r"a`$'b c\'d e$'f g\'", r"g\'"),
(r"a`$'b c\'d e$'f g\'h i", "i"),
(r"a`$'b c\'d e$'f g\'h i`j", "i`j"),
(r"a`$'b c\'d e'f g'", "g'"),
("a`a;", ""),
("a`x=", ""),
("a`x=y", "y"),
("a`b|", ""),
("a`b:c", "c"),
("a`b&", ""),
],
)
def test_1(self, bash, cur, expected):
self.check(bash, cur, expected)

def test_COMP_WORDBREAKS_atmark(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable("COMP_WORDBREAKS", "@$IFS", quote=False)
self.check(bash, "a`b@", "@")
self.check(bash, "a`b@c", "@c")
self.check(bash, "a`b@@c", "@c")
self.check(bash, "a`b@@c@", "@")

def test_COMP_WORDBREAKS_z(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("z"), quote=False
)
self.check(bash, "a`b;c", "a`b;c")
self.check(bash, "a`bzc", "c")
self.check(bash, "a`bzcdze", "e")
self.check(bash, "a`bzcdzze", "e")
self.check(bash, "a`bzcdzzze", "e")
self.check(bash, r"a`b\zc", r"a`b\zc")

def test_COMP_WORDBREAKS_dollar(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("$"), quote=False
)
self.check(bash, "a`b$'hxy'", "a`b$'hxy'")
self.check(bash, "a`b$", "$")
self.check(bash, "a`b$x", "$x")
self.check(bash, "a`b${", "${")
self.check(bash, "a`b${x}", "${x}")
self.check(bash, "a`b${x}y", "${x}y")
self.check(bash, "a`b$=", "$=")
self.check(bash, "a`b$.", "$.")
self.check(bash, "a`b$'a'", "a`b$'a'")
self.check(bash, 'a`b$"a"', '$"a"')
self.check(bash, "a'b", "b")
self.check(bash, "a`b$'' a$'xyz", "xyz")

def test_COMP_WORDBREAKS_backslash(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("\\"), quote=False
)
self.check(bash, r"a`b\cd", "cd")
self.check(bash, r"a`b\cde\fg", "fg")
self.check(bash, r"a`b\c\\a", r"\a")
self.check(bash, r"a`b\c\\\a", "a")
self.check(bash, r"a`b\c\\\\a", r"\a")
self.check(bash, r"a`b\c\a\a", "a")
self.check(bash, "a`b\\", "")
self.check(bash, "a`b\\\\", "\\")
self.check(bash, "a`b\\\\\\", "")

def test_COMP_WORDBREAKS_dollar_backslash(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("$\\"), quote=False
)
self.check(bash, "a`b$\\", "")
self.check(bash, "a`b\\$", "$")

def test_COMP_WORDBREAKS_dollar_atmark_z(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("$@z"), quote=False
)
self.check(bash, "a$z", "")
self.check(bash, "a$$z", "")
self.check(bash, "a$$", "$")
self.check(bash, "a$@", "@")
self.check(bash, "a$$@", "@")

def test_COMP_WORDBREAKS_dollar_backquote(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote("`"), quote=False
)
self.check(bash, "a`b`", "")

@pytest.mark.parametrize("symbol", list("!#%*+,-./?[]^_}~"))
def test_COMP_WORDBREAKS_symbol(self, bash, symbol):
with bash_env_saved(bash) as bash_env:
bash_env.write_variable(
"COMP_WORDBREAKS", "%s$IFS" % quote(symbol), quote=False
)
self.check(bash, "a`b%s" % symbol, "")
self.check(bash, "a`b%sc" % symbol, "c")