Skip to content
Draft
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
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions bash_completion
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1670 to +1687
Copy link
Copy Markdown
Collaborator

@akinomyoga akinomyoga Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation falls into an infinite loop with the following completion attempt:

$ cmd1 $(cmd2 a$(cmd3)b$(cmd4 [tab]

Also, the command name is wrongly extracted to be pwd)/$(cmd3 with the following completion attempt:

$ cmd1 $(cmd2 $(pwd)/$(cmd3 [tab]

Actually, I have difficulty understanding the choice of the present algorithm. Even if the above issues are fixed somehow, is this algorithm stable and robust in general? Since I currently don't see a clear path or picture to understand the construction of the current algorithm, the algorithm appears to be a random trial currently.

Can you explain what is the assumptions of the algorithm, and how it is ensured to work under the given assumptions?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Given the failures, yes, one assumption that words do not contain unbalanced pairs is a mistake, and another being that the first $( in the COMP_WORD is correct one. Evidently not robust. :) I was trying to find the word right-adjacent to the last open $( but the pair-wise counting by line length difference won't work, and the strip is to the wrong $(. I wouldn't say "random trial" :-) but definitely too naive. I was experimenting with test cases on my command line, and hoping to avoid writing a parens matcher with character position.

I'll go through it more carefully, for another approach.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the explanation!

Thank you for thinking about another approach! If it turns out to be difficult, we don't necessarily have to support all possible cases. I think the important thing is that we want to gracefully fail in the unsupported cases (instead of causing an infinite loop or producing a strange result); for example, if the required assumptions are clear, we may examine the contents of cur and give up the special treatment when the assumptions are unsatisfied.


# 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

Expand Down
60 changes: 60 additions & 0 deletions test/t/unit/test_unit_command_substitution.py
Original file line number Diff line number Diff line change
@@ -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
)