Skip to content

Commit a45eba9

Browse files
fix(completion): enable shell completions in release builds
Implements working shell completions for bash, zsh, fish, and PowerShell. Changes: - Add CompletionInternalCmd for kong's __complete helper - Refactor completion generation to use kong's built-in support - Extract newParser() for reuse in completion generation - Add baseDescription() to avoid duplication The completion command now generates valid scripts that work when eval'd in the user's shell configuration. Fixes #65 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d8dcb3a commit a45eba9

6 files changed

Lines changed: 361 additions & 43 deletions

File tree

internal/cmd/completion.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,96 @@ type CompletionCmd struct {
1111
}
1212

1313
func (c *CompletionCmd) Run(_ context.Context) error {
14-
_, err := fmt.Fprintf(os.Stdout, "Completion scripts not supported in this build (%s).\n", c.Shell)
14+
script, err := completionScript(c.Shell)
15+
if err != nil {
16+
return err
17+
}
18+
_, err = fmt.Fprint(os.Stdout, script)
1519
return err
1620
}
21+
22+
type CompletionInternalCmd struct {
23+
Cword int `name:"cword" help:"Index of the current word" default:"-1"`
24+
Words []string `arg:"" optional:"" name:"words" help:"Words to complete"`
25+
}
26+
27+
func (c *CompletionInternalCmd) Run(_ context.Context) error {
28+
items, err := completeWords(c.Cword, c.Words)
29+
if err != nil {
30+
return err
31+
}
32+
for _, item := range items {
33+
if _, err := fmt.Fprintln(os.Stdout, item); err != nil {
34+
return err
35+
}
36+
}
37+
return nil
38+
}
39+
40+
func completionScript(shell string) (string, error) {
41+
switch shell {
42+
case "bash":
43+
return bashCompletionScript(), nil
44+
case "zsh":
45+
return zshCompletionScript(), nil
46+
case "fish":
47+
return fishCompletionScript(), nil
48+
case "powershell":
49+
return powerShellCompletionScript(), nil
50+
default:
51+
return "", fmt.Errorf("unsupported shell: %s", shell)
52+
}
53+
}
54+
55+
func bashCompletionScript() string {
56+
return `#!/usr/bin/env bash
57+
58+
_gog_complete() {
59+
local IFS=$'\n'
60+
local completions
61+
completions=$(gog __complete --cword "$COMP_CWORD" -- "${COMP_WORDS[@]}")
62+
COMPREPLY=()
63+
if [[ -n "$completions" ]]; then
64+
COMPREPLY=( $completions )
65+
fi
66+
}
67+
68+
complete -F _gog_complete gog
69+
`
70+
}
71+
72+
func zshCompletionScript() string {
73+
return `#compdef gog
74+
75+
autoload -Uz bashcompinit
76+
bashcompinit
77+
` + bashCompletionScript()
78+
}
79+
80+
func fishCompletionScript() string {
81+
return `function __gog_complete
82+
set -l words (commandline -opc)
83+
set -l cur (commandline -ct)
84+
set -l cword (count $words)
85+
if test -n "$cur"
86+
set cword (math $cword - 1)
87+
end
88+
gog __complete --cword $cword -- $words
89+
end
90+
91+
complete -c gog -f -a "(__gog_complete)"
92+
`
93+
}
94+
95+
func powerShellCompletionScript() string {
96+
return `Register-ArgumentCompleter -CommandName gog -ScriptBlock {
97+
param($commandName, $wordToComplete, $cursorPosition, $commandAst, $fakeBoundParameter)
98+
$elements = $commandAst.CommandElements | ForEach-Object { $_.ToString() }
99+
$cword = $elements.Count - 1
100+
$completions = gog __complete --cword $cword -- $elements
101+
foreach ($completion in $completions) {
102+
[System.Management.Automation.CompletionResult]::new($completion, $completion, 'ParameterValue', $completion)
103+
}
104+
}
105+
`
106+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package cmd
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
"github.com/alecthomas/kong"
8+
)
9+
10+
type completionFlag struct {
11+
takesValue bool
12+
}
13+
14+
type completionNode struct {
15+
children map[string]*completionNode
16+
flags map[string]completionFlag
17+
}
18+
19+
func completeWords(cword int, words []string) ([]string, error) {
20+
if len(words) == 0 {
21+
return nil, nil
22+
}
23+
parser, _, err := newParser(baseDescription())
24+
if err != nil {
25+
return nil, err
26+
}
27+
root := buildCompletionNode(parser.Model.Node)
28+
29+
if cword < 0 {
30+
cword = len(words) - 1
31+
}
32+
if cword < 0 {
33+
return nil, nil
34+
}
35+
if cword > len(words) {
36+
cword = len(words)
37+
}
38+
39+
start := 0
40+
if isProgramName(words[0]) {
41+
start = 1
42+
}
43+
44+
node := root
45+
for i := start; i < cword && i < len(words); {
46+
word := words[i]
47+
if word == "--" {
48+
break
49+
}
50+
if strings.HasPrefix(word, "-") {
51+
flagToken, hasValue := splitFlagToken(word)
52+
if hasValue {
53+
i++
54+
continue
55+
}
56+
if spec, ok := node.flags[flagToken]; ok && spec.takesValue {
57+
if i+1 == cword {
58+
return nil, nil
59+
}
60+
i += 2
61+
continue
62+
}
63+
i++
64+
continue
65+
}
66+
if child, ok := node.children[word]; ok {
67+
node = child
68+
i++
69+
continue
70+
}
71+
i++
72+
}
73+
74+
if cword > start && cword <= len(words) {
75+
prev := words[cword-1]
76+
if strings.HasPrefix(prev, "-") {
77+
flagToken, hasValue := splitFlagToken(prev)
78+
if hasValue {
79+
return nil, nil
80+
}
81+
if spec, ok := node.flags[flagToken]; ok && spec.takesValue {
82+
return nil, nil
83+
}
84+
}
85+
}
86+
87+
current := ""
88+
if cword < len(words) {
89+
current = words[cword]
90+
}
91+
92+
suggestions := make([]string, 0)
93+
if strings.HasPrefix(current, "-") {
94+
suggestions = append(suggestions, matchingFlags(node, current)...)
95+
} else {
96+
suggestions = append(suggestions, matchingCommands(node, current)...)
97+
suggestions = append(suggestions, matchingFlags(node, current)...)
98+
}
99+
sort.Strings(suggestions)
100+
return suggestions, nil
101+
}
102+
103+
func isProgramName(word string) bool {
104+
if word == "gog" {
105+
return true
106+
}
107+
if strings.HasSuffix(word, "/gog") || strings.HasSuffix(word, `\gog`) {
108+
return true
109+
}
110+
if strings.HasSuffix(word, "/gog.exe") || strings.HasSuffix(word, `\gog.exe`) {
111+
return true
112+
}
113+
return false
114+
}
115+
116+
func buildCompletionNode(node *kong.Node) *completionNode {
117+
current := &completionNode{
118+
children: make(map[string]*completionNode),
119+
flags: make(map[string]completionFlag),
120+
}
121+
122+
for _, group := range node.AllFlags(true) {
123+
for _, flag := range group {
124+
addFlagTokens(current.flags, flag)
125+
}
126+
}
127+
128+
for _, child := range node.Children {
129+
if child.Hidden {
130+
continue
131+
}
132+
childNode := buildCompletionNode(child)
133+
for _, name := range append([]string{child.Name}, child.Aliases...) {
134+
if name == "" {
135+
continue
136+
}
137+
if _, exists := current.children[name]; !exists {
138+
current.children[name] = childNode
139+
}
140+
}
141+
}
142+
143+
return current
144+
}
145+
146+
func addFlagTokens(flags map[string]completionFlag, flag *kong.Flag) {
147+
takesValue := !(flag.IsBool() || flag.IsCounter())
148+
addFlag(flags, "--"+flag.Name, takesValue)
149+
for _, alias := range flag.Aliases {
150+
addFlag(flags, "--"+alias, takesValue)
151+
}
152+
if flag.Short != 0 {
153+
addFlag(flags, "-"+string(flag.Short), takesValue)
154+
}
155+
if negated := negatedFlagName(flag); negated != "" {
156+
addFlag(flags, negated, false)
157+
}
158+
}
159+
160+
func negatedFlagName(flag *kong.Flag) string {
161+
switch flag.Tag.Negatable {
162+
case "":
163+
return ""
164+
case "_":
165+
return "--no-" + flag.Name
166+
default:
167+
return "--" + flag.Tag.Negatable
168+
}
169+
}
170+
171+
func addFlag(flags map[string]completionFlag, token string, takesValue bool) {
172+
if token == "" {
173+
return
174+
}
175+
if _, exists := flags[token]; exists {
176+
return
177+
}
178+
flags[token] = completionFlag{takesValue: takesValue}
179+
}
180+
181+
func splitFlagToken(word string) (string, bool) {
182+
if idx := strings.Index(word, "="); idx != -1 {
183+
return word[:idx], true
184+
}
185+
return word, false
186+
}
187+
188+
func matchingCommands(node *completionNode, prefix string) []string {
189+
results := make([]string, 0, len(node.children))
190+
for name := range node.children {
191+
if strings.HasPrefix(name, prefix) {
192+
results = append(results, name)
193+
}
194+
}
195+
return results
196+
}
197+
198+
func matchingFlags(node *completionNode, prefix string) []string {
199+
results := make([]string, 0, len(node.flags))
200+
for name := range node.flags {
201+
if strings.HasPrefix(name, prefix) {
202+
results = append(results, name)
203+
}
204+
}
205+
return results
206+
}

internal/cmd/completion_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,27 @@ import (
77
)
88

99
func TestCompletionCmd(t *testing.T) {
10-
cases := []string{"bash", "zsh", "fish", "powershell"}
11-
for _, shell := range cases {
10+
cases := map[string]string{
11+
"bash": "complete -F _gog_complete gog",
12+
"zsh": "bashcompinit",
13+
"fish": "complete -c gog",
14+
"powershell": "Register-ArgumentCompleter",
15+
}
16+
for shell, marker := range cases {
1217
shell := shell
18+
marker := marker
1319
t.Run(shell, func(t *testing.T) {
1420
out := captureStdout(t, func() {
1521
cmd := &CompletionCmd{Shell: shell}
1622
if err := cmd.Run(context.Background()); err != nil {
1723
t.Fatalf("run: %v", err)
1824
}
1925
})
20-
if !strings.Contains(out, "Completion scripts not supported") {
21-
t.Fatalf("expected completion output, got %q", out)
26+
if !strings.Contains(out, "__complete") {
27+
t.Fatalf("expected __complete hook, got %q", out)
28+
}
29+
if !strings.Contains(out, marker) {
30+
t.Fatalf("expected %q in output, got %q", marker, out)
2231
}
2332
})
2433
}

internal/cmd/execute_completion_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestExecute_Completion_Bash(t *testing.T) {
2626
t.Fatalf("ReadFile: %v", err)
2727
}
2828
out := string(b)
29-
if !strings.Contains(out, "not supported") || !strings.Contains(out, "bash") {
29+
if !strings.Contains(out, "__complete") || !strings.Contains(out, "complete -F _gog_complete gog") {
3030
excerpt := out
3131
if len(excerpt) > 200 {
3232
excerpt = excerpt[:200]

internal/cmd/misc_more_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ func TestCompletionCmdRun(t *testing.T) {
1616
t.Fatalf("Run: %v", err)
1717
}
1818
})
19-
if !strings.Contains(out, "bash") {
20-
t.Fatalf("expected shell in output: %q", out)
19+
if !strings.Contains(out, "__complete") {
20+
t.Fatalf("expected __complete in output: %q", out)
2121
}
2222
}
2323

0 commit comments

Comments
 (0)