From 20892fc2b14b65b4b68901ace4b6d8e321f6ead7 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 17 Jul 2025 22:29:08 -0700 Subject: [PATCH 1/2] chore(docs): format --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef42bd74aa4..ba77fb47407 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -211,11 +211,11 @@ Also, please bear the following coding guidelines in mind: code is accepted into the distribution, a lot of people will try it out, so try to do a thorough job of eradicating all the bugs before you send it to us. If at all practical, **add test cases** to our - test suite (in the test/ dir) that verify that the code does what it + test suite (in the `test/` dir) that verify that the code does what it is intended to do, fixes issues it intends to fix, etc. -- In addition to running the test suite, there are a few scripts in the test/ - dir that catch some common issues, see and use for example runLint. +- In addition to running the test suite, there are a few scripts in the `test/` + dir that catch some common issues, see and use for example `runLint`. - Make sure you have Python 3.7 or later installed. This is required for running the development tooling, linters etc. Rest of the development From 325ccd09a59e45a7c265f075a81d18c377cce685 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 17 Jul 2025 09:36:35 -0700 Subject: [PATCH 2/2] fix(_comp_initialize): complete inside $() command substitutions Since bash 4.3, $(cmd args is treated as a single opaque word in COMP_WORDS, so the outer command's completion function produces nothing. Detect when cur starts with $( in _comp_initialize, rewrite COMP_LINE / COMP_POINT / COMP_WORDS / COMP_CWORD to describe the command line inside the substitution, and dispatch to the inner command's completion function via _comp_command_offset 0. Handle nested substitutions (e.g. echo $(cmd1 arg $(cmd2 ) by looping: after splitting inner_line into words, scan backward with cumulative $( and ) counts to find the rightmost unclosed $(. Counting across words is necessary because $( and ) may land in different words after splitting (e.g. "$(echo" "hi)"). If found, rebuild inner_line from that word onward, strip its $(, and repeat until the innermost level is reached. Tests: - Closed $() does not interfere with normal completion - Single-level: echo $(csub_cmd - Single-level partial: echo $(csub_cmd a - Nested: echo $(echo foo $(csub_cmd - Nested partial: echo $(echo foo $(csub_cmd b - Closed-then-open: echo $(echo hi) $(csub_cmd - Open-then-closed: echo $(csub_cmd $(echo hi) Closes #630 --- bash_completion | 79 +++++++++++++++++++ test/t/unit/test_unit_command_substitution.py | 60 ++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 test/t/unit/test_unit_command_substitution.py diff --git a/bash_completion b/bash_completion index e67cc3d51a4..1fead4c62a0 100644 --- a/bash_completion +++ b/bash_completion @@ -1626,6 +1626,85 @@ _comp_initialize() local redir='@(?(+([0-9])|{[a-zA-Z_]*([a-zA-Z_0-9])})@(>?([>|&])|&])|<?(>))' _comp_get_words -n "$exclude<>&" cur prev words cword + # Since bash 4.3, find_cmd_start() skips over unclosed $(, + # causing the entire $( content to appear as one token in + # COMP_WORDS. For example, in "echo $(cmd1 arg $(cmd2 ", + # cur is the single blob "$(cmd1 arg $(cmd2 ". + # + # The loop below strips $( layers one at a time: split + # into words, find a word with unclosed $(, rebuild + # inner_line from that word onward, and repeat. When no + # unclosed $( remains, COMP_* describes the innermost + # command and _comp_command_offset dispatches to it. + if [[ $cur == '$('* ]]; then + local inner_line=$cur + local i nested_idx open_count close_count + + while true; do + # Empty or whitespace-only -- nothing to complete, + # matching bare-prompt behavior. + [[ ${inner_line//[[:space:]]/} ]] || return 1 + + COMP_LINE=$inner_line + COMP_POINT=${#inner_line} + _comp_split COMP_WORDS "$inner_line" + + # _comp_split discarded trailing whitespace, but if a trailing + # space was present, then the cursor is past the last word. + # Append an empty word so COMP_CWORD points to the empty position + # to correctly perform the completion there. + if [[ $inner_line == *[[:space:]] ]]; then + COMP_WORDS+=("") + fi + + # COMP_CWORD is the index of the word at the cursor. Bash provides + # the entire unclosed $( as one opaque token, so the cursor + # position within it is lost; we set COMP_POINT to end-of-line + # above and COMP_CWORD to the last word to match. + COMP_CWORD=$((${#COMP_WORDS[@]} - 1)) + + # Scan backward, accumulating $( and ) counts across + # words, to find the rightmost unclosed $(. Per-word + # counting would fail when $( and ) are in different + # words, e.g. "$(echo" "hi)". + nested_idx="" + open_count=0 + close_count=0 + for ((i = COMP_CWORD; i >= 0; i--)); do + local word=${COMP_WORDS[i]} + local without_closes=${word//)/} + ((close_count += ${#word} - ${#without_closes})) + if [[ $word == *'$('* ]]; then + local without_opens=${word//\$\(/} + ((open_count += (${#word} - ${#without_opens}) / 2)) + if ((open_count > close_count)); then + nested_idx=$i + # re-parse from this word onward; + # inner_line is assembled below + break + fi + fi + done + + # No unclosed $( found -- we're at the innermost level. + [[ $nested_idx ]] || break + + # Rebuild inner_line from the nested word onward, then + # strip its leading $( for the next iteration. + local IFS=$' \t\n' # for ${COMP_WORDS[*]:...} below + inner_line="${COMP_WORDS[*]:nested_idx}" + inner_line=${inner_line#\$\(} + done + + # Unset words so _comp_command_offset uses COMP_WORDS directly + # (same pattern as _comp_command). + local words + unset -v words + + _comp_command_offset 0 + return 1 + fi + # Complete variable names. _comp_compgen_variables && return 1 diff --git a/test/t/unit/test_unit_command_substitution.py b/test/t/unit/test_unit_command_substitution.py new file mode 100644 index 00000000000..577f18b363a --- /dev/null +++ b/test/t/unit/test_unit_command_substitution.py @@ -0,0 +1,60 @@ +import pytest + +from conftest import assert_bash_exec, assert_complete + + +@pytest.mark.bashcomp( + cmd=None, + ignore_env=r"^[+-](COMPREPLY|REPLY)=", +) +class TestUnitCommandSubstitution: + """Test completion inside $() command substitutions.""" + + wordlist = ["alpha", "bravo"] + + @pytest.fixture(scope="class") + def functions(self, bash): + assert_bash_exec( + bash, + # csub_cmd: a test command whose completion function returns + # a fixed word list, filtered by the current word prefix. + "_csub_compfunc() {" + ' COMPREPLY=($(compgen -W "%s"' + ' -- "${COMP_WORDS[COMP_CWORD]}"));' + "}; " + "complete -F _csub_compfunc csub_cmd" % " ".join(self.wordlist), + ) + + def test_closed_substitution(self, bash, functions): + assert assert_complete( + bash, "echo $(echo hi) ", cwd="shared/default" + ) == ["bar", "bar bar.d/", "foo", "foo.d/"] + + def test_single_level(self, bash, functions): + assert assert_complete(bash, "echo $(csub_cmd ") == self.wordlist + + def test_single_level_partial(self, bash, functions): + assert assert_complete(bash, "echo $(csub_cmd a") == ["lpha"] + + def test_nested(self, bash, functions): + assert ( + assert_complete(bash, "echo $(echo foo $(csub_cmd ") + == self.wordlist + ) + + def test_nested_partial(self, bash, functions): + assert assert_complete(bash, "echo $(echo foo $(csub_cmd b") == [ + "ravo" + ] + + def test_closed_then_open(self, bash, functions): + assert ( + assert_complete(bash, "echo $(echo hi) $(csub_cmd ") + == self.wordlist + ) + + def test_open_then_closed(self, bash, functions): + assert ( + assert_complete(bash, "echo $(csub_cmd $(echo hi) ") + == self.wordlist + )