Skip to content

Commit c77070a

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. Installing bash-completion makes it worse than bare readline (which still does filename completion). 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 $(git describe $(ls <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 5bba93c commit c77070a

File tree

2 files changed

+123
-0
lines changed

2 files changed

+123
-0
lines changed

bash_completion

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,67 @@ _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+
# If the current word is a command substitution $(, rewrite the
1630+
# completion context to the command line *inside* the substitution
1631+
# and dispatch to that command's completion function.
1632+
if [[ $cur == '$('* ]]; then
1633+
local inner_line=${cur#\$\(}
1634+
1635+
# Handle nested substitutions: strip one $( at a time, parse
1636+
# into words, and check if any word contains an unclosed $(.
1637+
# If so, take the text from that word onward as a new inner
1638+
# line and repeat. This recurses through nesting levels like
1639+
# echo $(cmd1 arg $(cmd2 <TAB>.
1640+
while true; do
1641+
# Empty or whitespace-only -- nothing to complete,
1642+
# matching bare-prompt behavior.
1643+
[[ ${inner_line//[[:space:]]/} ]] || return 1
1644+
1645+
COMP_LINE=$inner_line
1646+
COMP_POINT=${#inner_line}
1647+
_comp_split COMP_WORDS "$inner_line"
1648+
# A trailing space means the user is starting a new word.
1649+
if [[ $inner_line == *[[:space:]] ]]; then
1650+
COMP_WORDS+=("")
1651+
fi
1652+
COMP_CWORD=$((${#COMP_WORDS[@]} - 1))
1653+
1654+
# Scan backward for the last word containing an unclosed
1655+
# $( (more $( than ) characters).
1656+
local i nested_idx=""
1657+
for ((i = COMP_CWORD; i >= 0; i--)); do
1658+
local word=${COMP_WORDS[i]}
1659+
if [[ $word == *'$('* ]]; then
1660+
# Count $( pairs vs ) to detect unclosed subst
1661+
local stripped=${word//\$\(/}
1662+
local open_count=$(((${#word} - ${#stripped}) / 2))
1663+
stripped=${word//)/}
1664+
local close_count=$((${#word} - ${#stripped}))
1665+
if ((open_count > close_count)); then
1666+
nested_idx=$i
1667+
break
1668+
fi
1669+
fi
1670+
done
1671+
1672+
# No unclosed $( found -- we're at the innermost level.
1673+
[[ $nested_idx ]] || break
1674+
1675+
# Rebuild inner_line from the nested word onward, then
1676+
# strip its leading $( for the next iteration.
1677+
inner_line="${COMP_WORDS[*]:nested_idx}"
1678+
inner_line=${inner_line#\$\(}
1679+
done
1680+
1681+
# Unset words so _comp_command_offset uses COMP_WORDS directly
1682+
# (same pattern as _comp_command).
1683+
local words
1684+
unset -v words
1685+
1686+
_comp_command_offset 0
1687+
return 1
1688+
fi
1689+
16291690
# Complete variable names.
16301691
_comp_compgen_variables && return 1
16311692

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)