Skip to content

Commit 6286e30

Browse files
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 <TAB>) by looping: after parsing inner_line into words, scan for any word with an unclosed $( (more $( than ) counts) and re-enter the loop from that word onward, stripping one $( per iteration until the innermost level is reached. Integration tests register a simple completion function (csub_cmd) and use assert_complete to send real keystrokes through the full pipeline: - Single-level: echo $(csub_cmd <TAB> matches bare csub_cmd <TAB> - Single-level partial: echo $(csub_cmd a<TAB> filters by prefix - Nested: echo $(echo foo $(csub_cmd <TAB> dispatches through nesting - Nested partial: echo $(echo foo $(csub_cmd b<TAB> filters in nesting - Closed-then-open: echo $(echo hi) $(csub_cmd <TAB> skips closed $() - Regression: csub_cmd <TAB> without $( still works Closes #630
1 parent 20892fc commit 6286e30

File tree

2 files changed

+139
-0
lines changed

2 files changed

+139
-0
lines changed

bash_completion

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,83 @@ _comp_initialize()
16261626
local redir='@(?(+([0-9])|{[a-zA-Z_]*([a-zA-Z_0-9])})@(>?([>|&])|<?([>&])|<<?([-<]))|&>?(>))'
16271627
_comp_get_words -n "$exclude<>&" cur prev words cword
16281628

1629+
# Since bash 4.3, find_cmd_start() skips over unclosed $(,
1630+
# causing the entire $( content to appear as one token in
1631+
# COMP_WORDS. For example, in "echo $(cmd1 arg $(cmd2 ",
1632+
# cur is the single blob "$(cmd1 arg $(cmd2 ".
1633+
#
1634+
# The loop below strips $( layers one at a time: split
1635+
# into words, find a word with unclosed $(, rebuild
1636+
# inner_line from that word onward, and repeat. When no
1637+
# unclosed $( remains, COMP_* describes the innermost
1638+
# command and _comp_command_offset dispatches to it.
1639+
if [[ $cur == '$('* ]]; then
1640+
local inner_line=$cur
1641+
local i nested_idx
1642+
1643+
while true; do
1644+
# Empty or whitespace-only -- nothing to complete,
1645+
# matching bare-prompt behavior.
1646+
[[ ${inner_line//[[:space:]]/} ]] || return 1
1647+
1648+
COMP_LINE=$inner_line
1649+
COMP_POINT=${#inner_line}
1650+
_comp_split COMP_WORDS "$inner_line"
1651+
1652+
# _comp_split discarded trailing whitespace, but if a trailing
1653+
# space was present, then the cursor is past the last word.
1654+
# Append an empty word so COMP_CWORD points to the empty position
1655+
# to correctly perform the completion there.
1656+
if [[ $inner_line == *[[:space:]] ]]; then
1657+
COMP_WORDS+=("")
1658+
fi
1659+
1660+
# COMP_CWORD is the index of the word at the cursor. Bash provides
1661+
# the entire unclosed $( as one opaque token, so the cursor
1662+
# position within it is lost; we set COMP_POINT to end-of-line
1663+
# above and COMP_CWORD to the last word to match.
1664+
COMP_CWORD=$((${#COMP_WORDS[@]} - 1))
1665+
1666+
# Scan backward for a word with more $( than ),
1667+
# indicating an unclosed substitution.
1668+
nested_idx=""
1669+
for ((i = COMP_CWORD; i >= 0; i--)); do
1670+
local word=${COMP_WORDS[i]}
1671+
if [[ $word == *'$('* ]]; then
1672+
# To detect unclosed substitutions, count
1673+
# occurrences of $( and ) by removing them
1674+
# and comparing lengths.
1675+
local without_opens=${word//\$\(/}
1676+
local open_count=$(((${#word} - ${#without_opens}) / 2))
1677+
local without_closes=${word//)/}
1678+
local close_count=$((${#word} - ${#without_closes}))
1679+
if ((open_count > close_count)); then
1680+
nested_idx=$i
1681+
# re-parse from this word onward; inner_line is
1682+
# assembled below for the next iteration
1683+
break
1684+
fi
1685+
fi
1686+
done
1687+
1688+
# No unclosed $( found -- we're at the innermost level.
1689+
[[ $nested_idx ]] || break
1690+
1691+
# Rebuild inner_line from the nested word onward, then
1692+
# strip its leading $( for the next iteration.
1693+
inner_line="${COMP_WORDS[*]:nested_idx}"
1694+
inner_line=${inner_line#\$\(}
1695+
done
1696+
1697+
# Unset words so _comp_command_offset uses COMP_WORDS directly
1698+
# (same pattern as _comp_command).
1699+
local words
1700+
unset -v words
1701+
1702+
_comp_command_offset 0
1703+
return 1
1704+
fi
1705+
16291706
# Complete variable names.
16301707
_comp_compgen_variables && return 1
16311708

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import pytest
2+
3+
from conftest import assert_bash_exec, assert_complete
4+
5+
6+
@pytest.mark.bashcomp(
7+
cmd=None,
8+
ignore_env=r"^[+-](COMPREPLY|REPLY)=",
9+
)
10+
class TestUnitCommandSubstitution:
11+
"""Test completion inside $() command substitutions.
12+
13+
Each test sends real keystrokes via assert_complete, comparing
14+
completions at the bare prompt with completions inside $().
15+
"""
16+
17+
wordlist = sorted(["alpha", "bravo"])
18+
19+
@pytest.fixture(scope="class")
20+
def functions(self, bash):
21+
assert_bash_exec(
22+
bash,
23+
# csub_cmd: a test command whose completion function returns
24+
# a fixed word list, filtered by the current word prefix.
25+
"_csub_compfunc() {"
26+
' COMPREPLY=($(compgen -W "%s"'
27+
' -- "${COMP_WORDS[COMP_CWORD]}"));'
28+
"}; "
29+
"complete -F _csub_compfunc csub_cmd" % " ".join(self.wordlist),
30+
)
31+
32+
def test_normal_not_affected(self, bash, functions):
33+
"""Completion without $( still works."""
34+
assert assert_complete(bash, "csub_cmd ") == self.wordlist
35+
36+
def test_single_level(self, bash, functions):
37+
"""echo $(csub_cmd <TAB> produces same completions."""
38+
assert assert_complete(bash, "echo $(csub_cmd ") == self.wordlist
39+
40+
def test_single_level_partial(self, bash, functions):
41+
"""echo $(csub_cmd a<TAB> completes the matching word."""
42+
assert assert_complete(bash, "echo $(csub_cmd a") == ["lpha"]
43+
44+
def test_nested(self, bash, functions):
45+
"""echo $(echo foo $(csub_cmd <TAB> works through nesting."""
46+
assert (
47+
assert_complete(bash, "echo $(echo foo $(csub_cmd ")
48+
== self.wordlist
49+
)
50+
51+
def test_nested_partial(self, bash, functions):
52+
"""echo $(echo foo $(csub_cmd b<TAB> completes inside nesting."""
53+
assert assert_complete(bash, "echo $(echo foo $(csub_cmd b") == [
54+
"ravo"
55+
]
56+
57+
def test_closed_then_open(self, bash, functions):
58+
"""echo $(echo hi) $(csub_cmd <TAB> -- closed $() before open one."""
59+
assert (
60+
assert_complete(bash, "echo $(echo hi) $(csub_cmd ")
61+
== self.wordlist
62+
)

0 commit comments

Comments
 (0)