Skip to content

Commit 4455eb9

Browse files
committed
- Fix dynamic completions (regression)
1 parent 5e78eec commit 4455eb9

File tree

20 files changed

+420
-169
lines changed

20 files changed

+420
-169
lines changed

AGENTS.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# AGENTS.md
2+
3+
Guidance for coding agents working in this repository.
4+
5+
## Repo Snapshot
6+
7+
- Project: `completely` (Ruby gem that generates Bash completion scripts from YAML).
8+
- Key generation code:
9+
- `lib/completely/pattern.rb`
10+
- `lib/completely/templates/template.erb`
11+
- Core behavior tests:
12+
- `spec/completely/integration_spec.rb`
13+
- `spec/completely/commands/generate_spec.rb`
14+
15+
## Working Rules
16+
17+
- Keep changes minimal and localized, especially in:
18+
- completion-word serialization (`Pattern`)
19+
- generated script runtime behavior (`template.erb`)
20+
- Do not change generated approvals.
21+
- Do not run approval prompts interactively on behalf of the developer.
22+
- If an approval spec changes, stop and ask the developer to review/approve manually.
23+
- Prefer adding regression coverage in integration fixtures for completion behavior changes.
24+
25+
## Fast Validation Loop
26+
27+
Run these first after edits:
28+
29+
```bash
30+
respec tagged script_quality
31+
respec only integration
32+
```
33+
34+
If touching quoting/escaping or dynamic completions, also run:
35+
36+
```bash
37+
respec only pattern
38+
respec only completions
39+
```
40+
41+
## Formatting and Linting Notes
42+
43+
- `shellcheck` and `shfmt` requirements are enforced by specs tagged `:script_quality` in `spec/completely/commands/generate_spec.rb`.
44+
- `shfmt` uses flags:
45+
- `shfmt -d -i 2 -ci completely.bash`
46+
- Small whitespace differences in heredoc/redirect forms (like `<<<"$x"` vs `<<< "$x"`) can fail shfmt.
47+
48+
## Approval Specs
49+
50+
- Some specs use `rspec_approvals` and may prompt interactively if output changes.
51+
- In non-interactive runs this can fail with `Errno::ENOTTY`.
52+
- Approval decisions are always developer-owned. Agents should not approve/update snapshots.
53+
54+
## Completion Semantics to Preserve
55+
56+
- Literal YAML words with spaces/quotes must complete correctly.
57+
- Dynamic `$(...)` entries must produce multiple completion candidates when command output contains multiple words.
58+
- `<file>`, `<directory>`, and other `<...>` entries map to `compgen -A ...` actions and should remain unaffected by `-W` serialization changes.
59+
60+
## Manual Repro Pattern
61+
62+
Useful local sanity check:
63+
64+
```bash
65+
cd dev
66+
ruby -I../lib ../bin/completely test "cli "
67+
```
68+
69+
Expected: sensible mixed output for dynamic values and quoted/spaced literals.

lib/completely/pattern.rb

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
module Completely
22
class Pattern
3+
DYNAMIC_WORD_PREFIX = '__completely_dynamic__'
4+
35
attr_reader :text, :completions, :function_name
46

57
def initialize(text, completions, function_name)
@@ -54,12 +56,24 @@ def compgen
5456
def compgen!
5557
result = []
5658
result << actions.join(' ').to_s if actions.any?
57-
result << %[-W "$(#{function_name} #{quoted_words.join ' '})"] if words.any?
59+
result << %[-W "$(#{function_name} #{serialized_words.join ' '})"] if words.any?
5860
result.any? ? result.join(' ') : nil
5961
end
6062

61-
def quoted_words
62-
@quoted_words ||= words.map { |word| %("#{escape_for_double_quotes word}") }
63+
def serialized_words
64+
@serialized_words ||= words.map { |word| serialize_word(word) }
65+
end
66+
67+
def serialize_word(word)
68+
if dynamic_word?(word)
69+
return %("#{DYNAMIC_WORD_PREFIX}#{escape_for_double_quotes word}")
70+
end
71+
72+
%("#{escape_for_double_quotes word}")
73+
end
74+
75+
def dynamic_word?(word)
76+
word.match?(/\A\$\(.+\)\z/)
6377
end
6478

6579
def escape_for_double_quotes(word)

lib/completely/templates/template.erb

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
local cur=${COMP_WORDS[COMP_CWORD]}
1010
local result=()
1111
local want_options=0
12+
local dynamic_prefix="<%= Completely::Pattern::DYNAMIC_WORD_PREFIX %>"
1213

1314
# words the user already typed (excluding the command itself)
1415
local used=()
@@ -20,19 +21,29 @@
2021
# Completing a non-option: drop options and already-used words.
2122
[[ "${cur:0:1}" == "-" ]] && want_options=1
2223
for word in "${words[@]}"; do
23-
if ((!want_options)); then
24-
[[ "${word:0:1}" == "-" ]] && continue
25-
26-
for u in "${used[@]}"; do
27-
if [[ "$u" == "$word" ]]; then
28-
continue 2
29-
fi
30-
done
24+
local candidates=("$word")
25+
if [[ "$word" == "$dynamic_prefix"* ]]; then
26+
word="${word#"$dynamic_prefix"}"
27+
word="${word//$'\r'/ }"
28+
word="${word//$'\n'/ }"
29+
read -r -a candidates <<<"$word"
3130
fi
3231

33-
# compgen -W expects shell-escaped words in one space-delimited string.
34-
printf -v word '%q' "$word"
35-
result+=("$word")
32+
for candidate in "${candidates[@]}"; do
33+
if ((!want_options)); then
34+
[[ "${candidate:0:1}" == "-" ]] && continue
35+
36+
for u in "${used[@]}"; do
37+
if [[ "$u" == "$candidate" ]]; then
38+
continue 2
39+
fi
40+
done
41+
fi
42+
43+
# compgen -W expects shell-escaped words in one space-delimited string.
44+
printf -v candidate '%q' "$candidate"
45+
result+=("$candidate")
46+
done
3647
done
3748

3849
echo "${result[*]}"

spec/README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,29 @@
22

33
## Running tests
44

5+
You can run specs with `rspec` as usual.
6+
7+
We recommend using [`respec`][2], which wraps common spec workflows:
8+
59
```bash
6-
$ rspec
10+
rspec
711
# or
8-
$ run spec
9-
# or, to run just tests in a given file
10-
$ run spec zsh
11-
# or, to run just specs tagged with :focus
12-
$ run spec :focus
12+
respec
1313
```
1414

1515
You might need to prefix the commands with `bundle exec`, depending on the way
1616
Ruby is installed.
1717

18+
Useful helper shortcuts:
19+
20+
```bash
21+
# script quality checks (shellcheck + shfmt generated script tests)
22+
respec tagged script_quality
23+
24+
# integration behavior suite
25+
respec only integration
26+
```
27+
1828
## Interactive Approvals
1929

2030
Some tests may prompt you for an interactive approval of changes. This
@@ -29,4 +39,5 @@ ZSH compatibility test is done by running the completely tester script inside a
2939
zsh container. This is all done automatically by `spec/completely/zsh_spec.rb`.
3040

3141

32-
[1]: https://github.com/dannyben/rspec_approvals
42+
[1]: https://github.com/dannyben/rspec_approvals
43+
[2]: https://github.com/DannyBen/respec

spec/approvals/cli/generated-script

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ _mygit_completions_filter() {
99
local cur=${COMP_WORDS[COMP_CWORD]}
1010
local result=()
1111
local want_options=0
12+
local dynamic_prefix="__completely_dynamic__"
1213

1314
# words the user already typed (excluding the command itself)
1415
local used=()
@@ -20,19 +21,29 @@ _mygit_completions_filter() {
2021
# Completing a non-option: drop options and already-used words.
2122
[[ "${cur:0:1}" == "-" ]] && want_options=1
2223
for word in "${words[@]}"; do
23-
if ((!want_options)); then
24-
[[ "${word:0:1}" == "-" ]] && continue
25-
26-
for u in "${used[@]}"; do
27-
if [[ "$u" == "$word" ]]; then
28-
continue 2
29-
fi
30-
done
24+
local candidates=("$word")
25+
if [[ "$word" == "$dynamic_prefix"* ]]; then
26+
word="${word#"$dynamic_prefix"}"
27+
word="${word//$'\r'/ }"
28+
word="${word//$'\n'/ }"
29+
read -r -a candidates <<<"$word"
3130
fi
3231

33-
# compgen -W expects shell-escaped words in one space-delimited string.
34-
printf -v word '%q' "$word"
35-
result+=("$word")
32+
for candidate in "${candidates[@]}"; do
33+
if ((!want_options)); then
34+
[[ "${candidate:0:1}" == "-" ]] && continue
35+
36+
for u in "${used[@]}"; do
37+
if [[ "$u" == "$candidate" ]]; then
38+
continue 2
39+
fi
40+
done
41+
fi
42+
43+
# compgen -W expects shell-escaped words in one space-delimited string.
44+
printf -v candidate '%q' "$candidate"
45+
result+=("$candidate")
46+
done
3647
done
3748

3849
echo "${result[*]}"
@@ -50,11 +61,11 @@ _mygit_completions() {
5061

5162
case "$compline" in
5263
'status'*'--branch')
53-
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
64+
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "__completely_dynamic__$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
5465
;;
5566

5667
'status'*'-b')
57-
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
68+
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "__completely_dynamic__$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
5869
;;
5970

6071
'status'*)

spec/approvals/cli/generated-script-alt

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ _mycomps_filter() {
99
local cur=${COMP_WORDS[COMP_CWORD]}
1010
local result=()
1111
local want_options=0
12+
local dynamic_prefix="__completely_dynamic__"
1213

1314
# words the user already typed (excluding the command itself)
1415
local used=()
@@ -20,19 +21,29 @@ _mycomps_filter() {
2021
# Completing a non-option: drop options and already-used words.
2122
[[ "${cur:0:1}" == "-" ]] && want_options=1
2223
for word in "${words[@]}"; do
23-
if ((!want_options)); then
24-
[[ "${word:0:1}" == "-" ]] && continue
25-
26-
for u in "${used[@]}"; do
27-
if [[ "$u" == "$word" ]]; then
28-
continue 2
29-
fi
30-
done
24+
local candidates=("$word")
25+
if [[ "$word" == "$dynamic_prefix"* ]]; then
26+
word="${word#"$dynamic_prefix"}"
27+
word="${word//$'\r'/ }"
28+
word="${word//$'\n'/ }"
29+
read -r -a candidates <<<"$word"
3130
fi
3231

33-
# compgen -W expects shell-escaped words in one space-delimited string.
34-
printf -v word '%q' "$word"
35-
result+=("$word")
32+
for candidate in "${candidates[@]}"; do
33+
if ((!want_options)); then
34+
[[ "${candidate:0:1}" == "-" ]] && continue
35+
36+
for u in "${used[@]}"; do
37+
if [[ "$u" == "$candidate" ]]; then
38+
continue 2
39+
fi
40+
done
41+
fi
42+
43+
# compgen -W expects shell-escaped words in one space-delimited string.
44+
printf -v candidate '%q' "$candidate"
45+
result+=("$candidate")
46+
done
3647
done
3748

3849
echo "${result[*]}"
@@ -50,11 +61,11 @@ _mycomps() {
5061

5162
case "$compline" in
5263
'status'*'--branch')
53-
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mycomps_filter "$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
64+
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mycomps_filter "__completely_dynamic__$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
5465
;;
5566

5667
'status'*'-b')
57-
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mycomps_filter "$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
68+
while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mycomps_filter "__completely_dynamic__$(git branch --format='%(refname:short)' 2>/dev/null)")" -- "$cur")
5869
;;
5970

6071
'status'*)

spec/approvals/cli/generated-wrapped-script

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ give_comps() {
1010
echo $' local cur=${COMP_WORDS[COMP_CWORD]}'
1111
echo $' local result=()'
1212
echo $' local want_options=0'
13+
echo $' local dynamic_prefix="__completely_dynamic__"'
1314
echo $''
1415
echo $' # words the user already typed (excluding the command itself)'
1516
echo $' local used=()'
@@ -21,19 +22,29 @@ give_comps() {
2122
echo $' # Completing a non-option: drop options and already-used words.'
2223
echo $' [[ "${cur:0:1}" == "-" ]] && want_options=1'
2324
echo $' for word in "${words[@]}"; do'
24-
echo $' if ((!want_options)); then'
25-
echo $' [[ "${word:0:1}" == "-" ]] && continue'
26-
echo $''
27-
echo $' for u in "${used[@]}"; do'
28-
echo $' if [[ "$u" == "$word" ]]; then'
29-
echo $' continue 2'
30-
echo $' fi'
31-
echo $' done'
25+
echo $' local candidates=("$word")'
26+
echo $' if [[ "$word" == "$dynamic_prefix"* ]]; then'
27+
echo $' word="${word#"$dynamic_prefix"}"'
28+
echo $' word="${word//$\'\r\'/ }"'
29+
echo $' word="${word//$\'\n\'/ }"'
30+
echo $' read -r -a candidates <<<"$word"'
3231
echo $' fi'
3332
echo $''
34-
echo $' # compgen -W expects shell-escaped words in one space-delimited string.'
35-
echo $' printf -v word \'%q\' "$word"'
36-
echo $' result+=("$word")'
33+
echo $' for candidate in "${candidates[@]}"; do'
34+
echo $' if ((!want_options)); then'
35+
echo $' [[ "${candidate:0:1}" == "-" ]] && continue'
36+
echo $''
37+
echo $' for u in "${used[@]}"; do'
38+
echo $' if [[ "$u" == "$candidate" ]]; then'
39+
echo $' continue 2'
40+
echo $' fi'
41+
echo $' done'
42+
echo $' fi'
43+
echo $''
44+
echo $' # compgen -W expects shell-escaped words in one space-delimited string.'
45+
echo $' printf -v candidate \'%q\' "$candidate"'
46+
echo $' result+=("$candidate")'
47+
echo $' done'
3748
echo $' done'
3849
echo $''
3950
echo $' echo "${result[*]}"'
@@ -51,11 +62,11 @@ give_comps() {
5162
echo $''
5263
echo $' case "$compline" in'
5364
echo $' \'status\'*\'--branch\')'
54-
echo $' while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "$(git branch --format=\'%(refname:short)\' 2>/dev/null)")" -- "$cur")'
65+
echo $' while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "__completely_dynamic__$(git branch --format=\'%(refname:short)\' 2>/dev/null)")" -- "$cur")'
5566
echo $' ;;'
5667
echo $''
5768
echo $' \'status\'*\'-b\')'
58-
echo $' while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "$(git branch --format=\'%(refname:short)\' 2>/dev/null)")" -- "$cur")'
69+
echo $' while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "$(_mygit_completions_filter "__completely_dynamic__$(git branch --format=\'%(refname:short)\' 2>/dev/null)")" -- "$cur")'
5970
echo $' ;;'
6071
echo $''
6172
echo $' \'status\'*)'

0 commit comments

Comments
 (0)