From da0df5638b52b1f70ce469043eefc18286f76e26 Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Sat, 5 Apr 2025 22:14:35 +0900 Subject: [PATCH 1/2] fix(java,rsync,ssh): complete syntactically incomplete cur https://github.com/scop/bash-completion/issues/1255#issuecomment-2437499379 `cur` is the word that is currently input by the user and can be syntactically incomplete when the completion is requested. For example, it is typical to attempt a completion with an opening quotation `'` but without a closing quotation: $ javadoc 'a[tab] Or, if there are two candidates `a\ b.txt` and `a\\\ c.txt`, the first attempt of the completion would insert the common part `a\`, where the escape target of `\` is missing. To handle these cases, in dequoting values coming from `cur`, we need to deal with the cases with incomplete values. This patch adds a new utility `_comp_dequote_incomplete` and replace `_comp_dequote` with it everywhere `_comp_dequote` is used for values coming from `cur`. Co-authored-by: Yedaya Katsman --- bash_completion | 30 +++++++ completions/java | 2 +- completions/ssh | 4 +- .../fixtures/scp/local_path/backslash-a b.txt | 0 .../scp/local_path/backslash-a\\ b.txt" | 0 test/t/test_javadoc.py | 4 + test/t/test_scp.py | 23 ++++- test/t/unit/Makefile.am | 1 + test/t/unit/test_unit_dequote_incomplete.py | 83 +++++++++++++++++++ 9 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/scp/local_path/backslash-a b.txt create mode 100644 "test/fixtures/scp/local_path/backslash-a\\ b.txt" create mode 100644 test/t/unit/test_unit_dequote_incomplete.py diff --git a/bash_completion b/bash_completion index 555619a2cf0..a773d96ac9a 100644 --- a/bash_completion +++ b/bash_completion @@ -208,6 +208,11 @@ _comp_dequote__initialize # - Quotes \?, '...', "...", $'...', and $"...". In the double # quotations, parameter expansions are allowed. # +# Note: To dequote values coming from `cur`, please use the +# function `_comp_dequote_incomplete` instead because `cur` is +# the word that is currently input and can be syntactically +# incomplete. +# # @var[out] REPLY Array that contains the expanded results. Multiple words or # no words may be generated through pathname expansions. If # $1 is not a safe word, REPLY contains the literal value of @@ -252,6 +257,31 @@ _comp_dequote() fi } +# Try to reconstruct an incomplete word and apply _comp_dequote. In +# particular, this function can be used to dequote `cur`, which is currently +# input and can be syntactically incomplete. +# +# @param $1 String to be expanded. The same as _comp_dequote, but +# incomplete backslash, single quotation, and double quotation +# are allowed. +# @var[out] REPLY Result. The same as _comp_dequote. +# @since 2.17 +_comp_dequote_incomplete() +{ + local _word=${1-} + if ! [[ $_word =~ $_comp_dequote__regex_safe_word ]]; then + # shellcheck disable=SC1003 + if [[ ${_word%'\'} =~ $_comp_dequote__regex_safe_word ]]; then + _word=${_word%'\'} + elif [[ $_word\' =~ $_comp_dequote__regex_safe_word ]]; then + _word=$_word\' + elif [[ $_word\" =~ $_comp_dequote__regex_safe_word ]]; then + _word=$_word\" + fi + fi + _comp_dequote "$_word" +} + # Unset the given variables across a scope boundary. Useful for unshadowing # global scoped variables. Note that simply calling unset on a local variable # will not unshadow the global variable. Rather, the result will be a local diff --git a/completions/java b/completions/java index 4ea6b817635..1a3630ded49 100644 --- a/completions/java +++ b/completions/java @@ -114,7 +114,7 @@ _comp_cmd_java__packages() local -a sourcepaths=("${REPLY[@]}") local REPLY - _comp_dequote "$cur" + _comp_dequote_incomplete "$cur" local cur_val=${REPLY-} # convert package syntax to path syntax diff --git a/completions/ssh b/completions/ssh index 301ff1545c4..84fcf53e75d 100644 --- a/completions/ssh +++ b/completions/ssh @@ -543,7 +543,7 @@ _comp_xfunc_scp_compgen_remote_files() # unescape (3 backslashes to 1 for chars we escaped) REPLY=$(command sed -e 's/\\\\\\\('"$_comp_cmd_scp__path_esc"'\)/\\\1/g' <<<"$REPLY") fi - _comp_dequote "$REPLY" + _comp_dequote_incomplete "$REPLY" local cur_val=${REPLY-} local _userhost=${cur_val%%:*} @@ -587,7 +587,7 @@ _comp_xfunc_scp_compgen_local_files() fi local REPLY - _comp_dequote "$cur" + _comp_dequote_incomplete "$cur" local cur_val=${REPLY-} local files diff --git a/test/fixtures/scp/local_path/backslash-a b.txt b/test/fixtures/scp/local_path/backslash-a b.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git "a/test/fixtures/scp/local_path/backslash-a\\ b.txt" "b/test/fixtures/scp/local_path/backslash-a\\ b.txt" new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/t/test_javadoc.py b/test/t/test_javadoc.py index 395d196dd49..3659aa4fe8d 100644 --- a/test/t/test_javadoc.py +++ b/test/t/test_javadoc.py @@ -15,3 +15,7 @@ def test_2(self, completion): ) def test_3(self, completion): assert completion == ["bar bar.d/", "foo.d/"] + + @pytest.mark.complete("javadoc 'shared.d") + def test_4_comp_dequote_incomplete(self, completion): + assert completion == "efault'" diff --git a/test/t/test_scp.py b/test/t/test_scp.py index 972c345e272..d9765c729ca 100644 --- a/test/t/test_scp.py +++ b/test/t/test_scp.py @@ -33,7 +33,7 @@ def test_basic(self, hosts, completion): ) ), # Local filenames - ["config", "known_hosts", r"spaced\ \ conf"], + ["config", "known_hosts", "local_path/", r"spaced\ \ conf"], ) ) assert completion == expected @@ -53,7 +53,7 @@ def test_basic_spaced_conf(self, hosts, completion): ) ), # Local filenames - ["config", "known_hosts", r"spaced\ \ conf"], + ["config", "known_hosts", "local_path/", r"spaced\ \ conf"], ) ) assert completion == expected @@ -120,6 +120,19 @@ def test_remote_path_with_spaces(self, bash): assert_bash_exec(bash, "unset -f ssh") assert completion == r"\\\ in\\\ filename.txt" + def test_remote_path_with_backslash(self, bash): + assert_bash_exec( + bash, r"ssh() { printf '%s\n' 'abc def.txt' 'abc\ def.txt'; }" + ) + completion = assert_complete(bash, "scp remote_host:abc\\") + assert_bash_exec(bash, "unset -f ssh") + + # Note: The number of backslash escaping differs depending on the scp + # version. + assert completion == sorted( + [r"abc\ def.txt", r"abc\\\ def.txt"] + ) or completion == sorted([r"abc\\\ def.txt", r"abc\\\\\\\ def.txt"]) + def test_xfunc_remote_files(self, live_pwd, bash): def prefix_paths(prefix, paths): return [f"{prefix}{path}" for path in paths] @@ -233,3 +246,9 @@ def test_local_path_with_spaces_1(self, completion): @pytest.mark.complete(r"scp spaced\ ", cwd="scp") def test_local_path_with_spaces_2(self, completion): assert completion == r"\ conf" + + @pytest.mark.complete("scp backslash-a\\", cwd="scp/local_path") + def test_local_path_backslash(self, completion): + assert completion == sorted( + [r"backslash-a\ b.txt", r"backslash-a\\\ b.txt"] + ) diff --git a/test/t/unit/Makefile.am b/test/t/unit/Makefile.am index fc4cdec3a68..cb5967deab4 100644 --- a/test/t/unit/Makefile.am +++ b/test/t/unit/Makefile.am @@ -19,6 +19,7 @@ EXTRA_DIST = \ test_unit_delimited.py \ test_unit_deprecate_func.py \ test_unit_dequote.py \ + test_unit_dequote_incomplete.py \ test_unit_expand.py \ test_unit_expand_glob.py \ test_unit_expand_tilde.py \ diff --git a/test/t/unit/test_unit_dequote_incomplete.py b/test/t/unit/test_unit_dequote_incomplete.py new file mode 100644 index 00000000000..e82e60b38a5 --- /dev/null +++ b/test/t/unit/test_unit_dequote_incomplete.py @@ -0,0 +1,83 @@ +import pytest + +from conftest import assert_bash_exec + + +@pytest.mark.bashcomp( + cmd=None, + cwd="_filedir", + ignore_env=r"^\+declare -f __tester$", +) +class TestDequoteIncomplete: + @pytest.fixture + def functions(self, bash): + assert_bash_exec( + bash, + '__tester() { local REPLY=dummy v=var;_comp_dequote_incomplete "$1";local ext=$?;((${#REPLY[@]}))&&printf \'<%s>\' "${REPLY[@]}";echo;return $ext;}', + ) + + def test_basic_1(self, bash, functions): + output = assert_bash_exec(bash, "__tester a", want_output=True) + assert output.strip() == "" + + def test_basic_2(self, bash, functions): + output = assert_bash_exec(bash, "__tester abc", want_output=True) + assert output.strip() == "" + + def test_basic_3_null(self, bash, functions): + output = assert_bash_exec(bash, "! __tester ''", want_output=True) + assert output.strip() == "" + + def test_basic_4_empty(self, bash, functions): + output = assert_bash_exec(bash, "__tester \"''\"", want_output=True) + assert output.strip() == "<>" + + def test_basic_5_brace(self, bash, functions): + output = assert_bash_exec(bash, "__tester 'a{1..3}'", want_output=True) + assert output.strip() == "" + + def test_basic_6_glob(self, bash, functions): + output = assert_bash_exec(bash, "__tester 'a?b'", want_output=True) + assert output.strip() == "" + + def test_quote_1(self, bash, functions): + output = assert_bash_exec( + bash, "__tester '\"a\"'\\'b\\'\\$\\'c\\'", want_output=True + ) + assert output.strip() == "" + + def test_quote_2(self, bash, functions): + output = assert_bash_exec( + bash, "__tester '\\\"\\'\\''\\$\\`'", want_output=True + ) + assert output.strip() == "<\"'$`>" + + def test_quote_3(self, bash, functions): + output = assert_bash_exec( + bash, "__tester \\$\\'a\\\\tb\\'", want_output=True + ) + assert output.strip() == "" + + def test_quote_4(self, bash, functions): + output = assert_bash_exec( + bash, '__tester \'"abc\\"def"\'', want_output=True + ) + assert output.strip() == '' + + def test_quote_5(self, bash, functions): + output = assert_bash_exec( + bash, "__tester \\'abc\\'\\\\\\'\\'def\\'", want_output=True + ) + assert output.strip() == "" + + def test_incomplete_1(self, bash, functions): + output = assert_bash_exec(bash, "__tester 'a\\'", want_output=True) + assert output.strip() == "" + + def test_incomplete_2(self, bash, functions): + output = assert_bash_exec(bash, '__tester "\'a b "', want_output=True) + assert output.strip() == "" + + def test_incomplete_3(self, bash, functions): + output = assert_bash_exec(bash, "__tester '\"a b '", want_output=True) + assert output.strip() == "" From 455f8c133ba1783c98e5c5f753c400a247cfef3b Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Mon, 7 Apr 2025 07:27:30 +0900 Subject: [PATCH 2/2] test(dequote{,_incomplete}): use raw stringliteral r"" --- test/t/unit/test_unit_dequote.py | 4 ++-- test/t/unit/test_unit_dequote_incomplete.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/t/unit/test_unit_dequote.py b/test/t/unit/test_unit_dequote.py index 5082f60af06..392b54266f7 100644 --- a/test/t/unit/test_unit_dequote.py +++ b/test/t/unit/test_unit_dequote.py @@ -54,7 +54,7 @@ def test_7_quote_2(self, bash, functions): def test_7_quote_3(self, bash, functions): output = assert_bash_exec( - bash, "__tester \\$\\'a\\\\tb\\'", want_output=True + bash, r"__tester \$\'a\\tb\'", want_output=True ) assert output.strip() == "" @@ -66,7 +66,7 @@ def test_7_quote_4(self, bash, functions): def test_7_quote_5(self, bash, functions): output = assert_bash_exec( - bash, "__tester \\'abc\\'\\\\\\'\\'def\\'", want_output=True + bash, r"__tester \'abc\'\\\'\'def\'", want_output=True ) assert output.strip() == "" diff --git a/test/t/unit/test_unit_dequote_incomplete.py b/test/t/unit/test_unit_dequote_incomplete.py index e82e60b38a5..33e6db8f608 100644 --- a/test/t/unit/test_unit_dequote_incomplete.py +++ b/test/t/unit/test_unit_dequote_incomplete.py @@ -54,7 +54,7 @@ def test_quote_2(self, bash, functions): def test_quote_3(self, bash, functions): output = assert_bash_exec( - bash, "__tester \\$\\'a\\\\tb\\'", want_output=True + bash, r"__tester \$\'a\\tb\'", want_output=True ) assert output.strip() == "" @@ -66,12 +66,12 @@ def test_quote_4(self, bash, functions): def test_quote_5(self, bash, functions): output = assert_bash_exec( - bash, "__tester \\'abc\\'\\\\\\'\\'def\\'", want_output=True + bash, r"__tester \'abc\'\\\'\'def\'", want_output=True ) assert output.strip() == "" def test_incomplete_1(self, bash, functions): - output = assert_bash_exec(bash, "__tester 'a\\'", want_output=True) + output = assert_bash_exec(bash, r"__tester 'a\'", want_output=True) assert output.strip() == "" def test_incomplete_2(self, bash, functions):