Skip to content

Commit c0c36c5

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 c0c36c5

File tree

2 files changed

+106
-0
lines changed

2 files changed

+106
-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 $(git describe $(ls <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+
read -ra 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=""
1657+
for ((_i = COMP_CWORD; _i >= 0; _i--)); do
1658+
local _w=${COMP_WORDS[_i]}
1659+
if [[ $_w == *'$('* ]]; then
1660+
# Count $( pairs vs ) to detect unclosed subst
1661+
local _tmp=${_w//\$\(/}
1662+
local _opens=$(((${#_w} - ${#_tmp}) / 2))
1663+
_tmp=${_w//)/}
1664+
local _closes=$((${#_w} - ${#_tmp}))
1665+
if ((_opens > _closes)); then
1666+
_nested=$_i
1667+
break
1668+
fi
1669+
fi
1670+
done
1671+
1672+
# No unclosed $( found -- we're at the innermost level.
1673+
[[ $_nested ]] || 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}"
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
3+
from conftest import TestUnitBase
4+
5+
6+
@pytest.mark.bashcomp(
7+
cmd=None,
8+
ignore_env=r"^[+-](COMP(_(WORDS|CWORD|LINE|POINT)|REPLY)|"
9+
r"cur|prev|cword|words)=",
10+
)
11+
class TestUnitCommandSubstitution(TestUnitBase):
12+
def test_command_substitution_completion(self, bash):
13+
"""Test that command substitution completion works correctly"""
14+
# Test basic command substitution: $(echo
15+
output = self._test_unit(
16+
"_comp_initialize %s; echo $COMP_LINE,$COMP_CWORD,$cur,$prev",
17+
bash,
18+
"(echo '$(echo')",
19+
1,
20+
"echo '$(echo'",
21+
12,
22+
)
23+
assert output == "echo,0,echo,"
24+
25+
# Test command substitution with arguments: $(ls -l
26+
output = self._test_unit(
27+
"_comp_initialize %s; echo $COMP_LINE,$COMP_CWORD,$cur,$prev",
28+
bash,
29+
"(echo '$(ls -l')",
30+
1,
31+
"echo '$(ls -l'",
32+
13,
33+
)
34+
assert output == "ls -l,1,-l,ls"
35+
36+
# Test that normal completion is not affected
37+
output = self._test_unit(
38+
"_comp_initialize %s; echo $COMP_LINE,$COMP_CWORD,$cur,$prev",
39+
bash,
40+
"(echo hello)",
41+
1,
42+
"echo hello",
43+
10,
44+
)
45+
assert output == "echo hello,1,hello,echo"

0 commit comments

Comments
 (0)