Skip to content

Commit c550930

Browse files
authored
Merge pull request #77 from salmonumbrella/fix/shell-completions-65
fix(completion): enable shell completions in release builds
2 parents 22147e1 + 410ea2f commit c550930

8 files changed

Lines changed: 408 additions & 44 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Auth: account manager upgrade respects managed services and skips Keep OAuth scopes. (#73) — thanks @salmonumbrella.
1010
- Classroom: normalize assignee updates + fix grade update masks. (#74) — thanks @salmonumbrella.
1111
- Classroom: scan pages when filtering coursework/materials by topic. (#73) — thanks @salmonumbrella.
12+
- CLI: enable shell completions and stop flag suggestions after `--`. (#77) — thanks @salmonumbrella.
1213

1314
### Build
1415

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: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
terminatorIndex := -1
46+
for i := start; i < cword && i < len(words); {
47+
word := words[i]
48+
if word == "--" {
49+
terminatorIndex = i
50+
break
51+
}
52+
if strings.HasPrefix(word, "-") {
53+
flagToken, hasValue := splitFlagToken(word)
54+
if hasValue {
55+
i++
56+
continue
57+
}
58+
if spec, ok := node.flags[flagToken]; ok && spec.takesValue {
59+
if i+1 == cword {
60+
return nil, nil
61+
}
62+
i += 2
63+
continue
64+
}
65+
i++
66+
continue
67+
}
68+
if child, ok := node.children[word]; ok {
69+
node = child
70+
i++
71+
continue
72+
}
73+
i++
74+
}
75+
76+
if terminatorIndex != -1 && cword >= terminatorIndex {
77+
return nil, nil
78+
}
79+
80+
if cword < len(words) && words[cword] == "--" {
81+
return nil, nil
82+
}
83+
84+
if cword > start && cword <= len(words) {
85+
prev := words[cword-1]
86+
if strings.HasPrefix(prev, "-") {
87+
flagToken, hasValue := splitFlagToken(prev)
88+
if hasValue {
89+
return nil, nil
90+
}
91+
if spec, ok := node.flags[flagToken]; ok && spec.takesValue {
92+
return nil, nil
93+
}
94+
}
95+
}
96+
97+
current := ""
98+
if cword < len(words) {
99+
current = words[cword]
100+
}
101+
102+
suggestions := make([]string, 0)
103+
if strings.HasPrefix(current, "-") {
104+
suggestions = append(suggestions, matchingFlags(node, current)...)
105+
} else {
106+
suggestions = append(suggestions, matchingCommands(node, current)...)
107+
suggestions = append(suggestions, matchingFlags(node, current)...)
108+
}
109+
sort.Strings(suggestions)
110+
return suggestions, nil
111+
}
112+
113+
func isProgramName(word string) bool {
114+
if word == "gog" {
115+
return true
116+
}
117+
if strings.HasSuffix(word, "/gog") || strings.HasSuffix(word, `\gog`) {
118+
return true
119+
}
120+
if strings.HasSuffix(word, "/gog.exe") || strings.HasSuffix(word, `\gog.exe`) {
121+
return true
122+
}
123+
return false
124+
}
125+
126+
func buildCompletionNode(node *kong.Node) *completionNode {
127+
current := &completionNode{
128+
children: make(map[string]*completionNode),
129+
flags: make(map[string]completionFlag),
130+
}
131+
132+
for _, group := range node.AllFlags(true) {
133+
for _, flag := range group {
134+
addFlagTokens(current.flags, flag)
135+
}
136+
}
137+
138+
for _, child := range node.Children {
139+
if child.Hidden {
140+
continue
141+
}
142+
childNode := buildCompletionNode(child)
143+
for _, name := range append([]string{child.Name}, child.Aliases...) {
144+
if name == "" {
145+
continue
146+
}
147+
if _, exists := current.children[name]; !exists {
148+
current.children[name] = childNode
149+
}
150+
}
151+
}
152+
153+
return current
154+
}
155+
156+
func addFlagTokens(flags map[string]completionFlag, flag *kong.Flag) {
157+
takesValue := !(flag.IsBool() || flag.IsCounter())
158+
addFlag(flags, "--"+flag.Name, takesValue)
159+
for _, alias := range flag.Aliases {
160+
addFlag(flags, "--"+alias, takesValue)
161+
}
162+
if flag.Short != 0 {
163+
addFlag(flags, "-"+string(flag.Short), takesValue)
164+
}
165+
if negated := negatedFlagName(flag); negated != "" {
166+
addFlag(flags, negated, false)
167+
}
168+
}
169+
170+
func negatedFlagName(flag *kong.Flag) string {
171+
switch flag.Tag.Negatable {
172+
case "":
173+
return ""
174+
case "_":
175+
return "--no-" + flag.Name
176+
default:
177+
return "--" + flag.Tag.Negatable
178+
}
179+
}
180+
181+
func addFlag(flags map[string]completionFlag, token string, takesValue bool) {
182+
if token == "" {
183+
return
184+
}
185+
if _, exists := flags[token]; exists {
186+
return
187+
}
188+
flags[token] = completionFlag{takesValue: takesValue}
189+
}
190+
191+
func splitFlagToken(word string) (string, bool) {
192+
if idx := strings.Index(word, "="); idx != -1 {
193+
return word[:idx], true
194+
}
195+
return word, false
196+
}
197+
198+
func matchingCommands(node *completionNode, prefix string) []string {
199+
results := make([]string, 0, len(node.children))
200+
for name := range node.children {
201+
if strings.HasPrefix(name, prefix) {
202+
results = append(results, name)
203+
}
204+
}
205+
return results
206+
}
207+
208+
func matchingFlags(node *completionNode, prefix string) []string {
209+
results := make([]string, 0, len(node.flags))
210+
for name := range node.flags {
211+
if strings.HasPrefix(name, prefix) {
212+
results = append(results, name)
213+
}
214+
}
215+
return results
216+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestCompleteWordsStopsAfterTerminator(t *testing.T) {
6+
cases := []struct {
7+
name string
8+
cword int
9+
words []string
10+
}{
11+
{
12+
name: "current-is-terminator",
13+
cword: 1,
14+
words: []string{"gog", "--"},
15+
},
16+
{
17+
name: "after-terminator",
18+
cword: 3,
19+
words: []string{"gog", "auth", "--", "-"},
20+
},
21+
}
22+
23+
for _, tc := range cases {
24+
tc := tc
25+
t.Run(tc.name, func(t *testing.T) {
26+
got, err := completeWords(tc.cword, tc.words)
27+
if err != nil {
28+
t.Fatalf("completeWords: %v", err)
29+
}
30+
if len(got) != 0 {
31+
t.Fatalf("expected no suggestions, got %v", got)
32+
}
33+
})
34+
}
35+
}

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]

0 commit comments

Comments
 (0)