Skip to content

Commit 802129c

Browse files
committed
feat: add completion install api
1 parent 91966a2 commit 802129c

File tree

14 files changed

+455
-55
lines changed

14 files changed

+455
-55
lines changed

command.go

+14-4
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,16 @@ func copyFlagSetWithout(fs *pflag.FlagSet, without string) *pflag.FlagSet {
296296
}
297297

298298
func (inv *Invocation) CurWords() (prev string, cur string) {
299-
if len(inv.Args) == 1 {
299+
switch len(inv.Args) {
300+
// All the shells we support will supply at least one argument (empty string),
301+
// but we don't want to panic.
302+
case 0:
303+
cur = ""
304+
prev = ""
305+
case 1:
300306
cur = inv.Args[0]
301307
prev = ""
302-
} else {
308+
default:
303309
cur = inv.Args[len(inv.Args)-1]
304310
prev = inv.Args[len(inv.Args)-2]
305311
}
@@ -645,9 +651,13 @@ func (inv *Invocation) completeFlag(word string) []string {
645651
if opt.CompletionHandler != nil {
646652
return opt.CompletionHandler(inv)
647653
}
648-
val, ok := opt.Value.(*Enum)
654+
enum, ok := opt.Value.(*Enum)
655+
if ok {
656+
return enum.Choices
657+
}
658+
enumArr, ok := opt.Value.(*EnumArray)
649659
if ok {
650-
return val.Choices
660+
return enumArr.Choices
651661
}
652662
return nil
653663
}

command_test.go

+14-8
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ func fakeIO(i *serpent.Invocation) *ioBufs {
3434
func sampleCommand(t *testing.T) *serpent.Command {
3535
t.Helper()
3636
var (
37-
verbose bool
38-
lower bool
39-
prefix string
40-
reqBool bool
41-
reqStr string
42-
reqArr []string
43-
fileArr []string
44-
enumStr string
37+
verbose bool
38+
lower bool
39+
prefix string
40+
reqBool bool
41+
reqStr string
42+
reqArr []string
43+
reqEnumArr []string
44+
fileArr []string
45+
enumStr string
4546
)
4647
enumChoices := []string{"foo", "bar", "qux"}
4748
return &serpent.Command{
@@ -94,6 +95,11 @@ func sampleCommand(t *testing.T) *serpent.Command {
9495
FlagShorthand: "a",
9596
Value: serpent.StringArrayOf(&reqArr),
9697
},
98+
serpent.Option{
99+
Name: "req-enum-array",
100+
Flag: "req-enum-array",
101+
Value: serpent.EnumArrayOf(&reqEnumArr, enumChoices...),
102+
},
97103
},
98104
HelpHandler: func(i *serpent.Invocation) error {
99105
_, _ = i.Stdout.Write([]byte("help text.png"))

completion.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package serpent
22

3+
import "strings"
4+
35
// CompletionModeEnv is a special environment variable that is
46
// set when the command is being run in completion mode.
57
const CompletionModeEnv = "COMPLETION_MODE"
@@ -18,7 +20,9 @@ func DefaultCompletionHandler(inv *Invocation) []string {
1820
allResps = append(allResps, cmd.Name())
1921
}
2022
for _, opt := range inv.Command.Options {
21-
if opt.ValueSource == ValueSourceNone || opt.ValueSource == ValueSourceDefault || opt.Value.Type() == "string-array" {
23+
if opt.ValueSource == ValueSourceNone ||
24+
opt.ValueSource == ValueSourceDefault ||
25+
strings.Contains(opt.Value.Type(), "array") {
2226
allResps = append(allResps, "--"+opt.Flag)
2327
}
2428
}

completion/all.go

+66-27
Original file line numberDiff line numberDiff line change
@@ -6,74 +6,81 @@ import (
66
"os"
77
"os/user"
88
"path/filepath"
9+
"runtime"
910
"strings"
1011
"text/template"
1112

1213
"github.com/coder/serpent"
1314
)
1415

16+
type Shell interface {
17+
Name() string
18+
InstallPath() (string, error)
19+
UsesOwnFile() bool
20+
WriteCompletion(io.Writer) error
21+
}
22+
1523
const (
16-
BashShell string = "bash"
17-
FishShell string = "fish"
18-
ZShell string = "zsh"
19-
Powershell string = "powershell"
24+
ShellBash string = "bash"
25+
ShellFish string = "fish"
26+
ShellZsh string = "zsh"
27+
ShellPowershell string = "powershell"
2028
)
2129

22-
var shellCompletionByName = map[string]func(io.Writer, string) error{
23-
BashShell: generateCompletion(bashCompletionTemplate),
24-
FishShell: generateCompletion(fishCompletionTemplate),
25-
ZShell: generateCompletion(zshCompletionTemplate),
26-
Powershell: generateCompletion(pshCompletionTemplate),
30+
func ShellByName(shell, programName string) (Shell, error) {
31+
switch shell {
32+
case ShellBash:
33+
return Bash(runtime.GOOS, programName), nil
34+
case ShellFish:
35+
return Fish(runtime.GOOS, programName), nil
36+
case ShellZsh:
37+
return Zsh(runtime.GOOS, programName), nil
38+
case ShellPowershell:
39+
return Powershell(runtime.GOOS, programName), nil
40+
default:
41+
return nil, fmt.Errorf("unsupported shell %q", shell)
42+
}
2743
}
2844

2945
func ShellOptions(choice *string) *serpent.Enum {
30-
return serpent.EnumOf(choice, BashShell, FishShell, ZShell, Powershell)
31-
}
32-
33-
func WriteCompletion(writer io.Writer, shell string, cmdName string) error {
34-
fn, ok := shellCompletionByName[shell]
35-
if !ok {
36-
return fmt.Errorf("unknown shell %q", shell)
37-
}
38-
fn(writer, cmdName)
39-
return nil
46+
return serpent.EnumOf(choice, ShellBash, ShellFish, ShellZsh, ShellPowershell)
4047
}
4148

42-
func DetectUserShell() (string, error) {
49+
func DetectUserShell(programName string) (Shell, error) {
4350
// Attempt to get the SHELL environment variable first
4451
if shell := os.Getenv("SHELL"); shell != "" {
45-
return filepath.Base(shell), nil
52+
return ShellByName(filepath.Base(shell), "")
4653
}
4754

4855
// Fallback: Look up the current user and parse /etc/passwd
4956
currentUser, err := user.Current()
5057
if err != nil {
51-
return "", err
58+
return nil, err
5259
}
5360

5461
// Open and parse /etc/passwd
5562
passwdFile, err := os.ReadFile("/etc/passwd")
5663
if err != nil {
57-
return "", err
64+
return nil, err
5865
}
5966

6067
lines := strings.Split(string(passwdFile), "\n")
6168
for _, line := range lines {
6269
if strings.HasPrefix(line, currentUser.Username+":") {
6370
parts := strings.Split(line, ":")
6471
if len(parts) > 6 {
65-
return filepath.Base(parts[6]), nil // The shell is typically the 7th field
72+
return ShellByName(filepath.Base(parts[6]), programName) // The shell is typically the 7th field
6673
}
6774
}
6875
}
6976

70-
return "", fmt.Errorf("default shell not found")
77+
return nil, fmt.Errorf("default shell not found")
7178
}
7279

7380
func generateCompletion(
7481
scriptTemplate string,
7582
) func(io.Writer, string) error {
76-
return func(w io.Writer, rootCmdName string) error {
83+
return func(w io.Writer, programName string) error {
7784
tmpl, err := template.New("script").Parse(scriptTemplate)
7885
if err != nil {
7986
return fmt.Errorf("parse template: %w", err)
@@ -82,7 +89,7 @@ func generateCompletion(
8289
err = tmpl.Execute(
8390
w,
8491
map[string]string{
85-
"Name": rootCmdName,
92+
"Name": programName,
8693
},
8794
)
8895
if err != nil {
@@ -92,3 +99,35 @@ func generateCompletion(
9299
return nil
93100
}
94101
}
102+
103+
func InstallShellCompletion(shell Shell) error {
104+
path, err := shell.InstallPath()
105+
if err != nil {
106+
return fmt.Errorf("get install path: %w", err)
107+
}
108+
109+
err = os.MkdirAll(filepath.Dir(path), 0o755)
110+
if err != nil {
111+
return fmt.Errorf("create directories: %w", err)
112+
}
113+
114+
if shell.UsesOwnFile() {
115+
err := os.WriteFile(path, nil, 0o644)
116+
if err != nil {
117+
return fmt.Errorf("create file: %w", err)
118+
}
119+
}
120+
121+
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
122+
if err != nil {
123+
return fmt.Errorf("open file for appending: %w", err)
124+
}
125+
defer f.Close()
126+
127+
err = shell.WriteCompletion(f)
128+
if err != nil {
129+
return fmt.Errorf("write completion script: %w", err)
130+
}
131+
132+
return nil
133+
}

completion/bash.go

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,53 @@
11
package completion
22

3+
import (
4+
"io"
5+
"path/filepath"
6+
7+
home "github.com/mitchellh/go-homedir"
8+
)
9+
10+
type bash struct {
11+
goos string
12+
programName string
13+
}
14+
15+
var _ Shell = &bash{}
16+
17+
func Bash(goos string, programName string) Shell {
18+
return &bash{goos: goos, programName: programName}
19+
}
20+
21+
// Name implements Shell.
22+
func (b *bash) Name() string {
23+
return "bash"
24+
}
25+
26+
// UsesOwnFile implements Shell.
27+
func (b *bash) UsesOwnFile() bool {
28+
return false
29+
}
30+
31+
// InstallPath implements Shell.
32+
func (b *bash) InstallPath() (string, error) {
33+
homeDir, err := home.Dir()
34+
if err != nil {
35+
return "", err
36+
}
37+
if b.goos == "darwin" {
38+
return filepath.Join(homeDir, ".bash_profile"), nil
39+
}
40+
return filepath.Join(homeDir, ".bashrc"), nil
41+
}
42+
43+
// WriteCompletion implements Shell.
44+
func (b *bash) WriteCompletion(w io.Writer) error {
45+
return generateCompletion(bashCompletionTemplate)(w, b.programName)
46+
}
47+
348
const bashCompletionTemplate = `
49+
50+
# === BEGIN {{.Name}} COMPLETION ===
451
_generate_{{.Name}}_completions() {
552
# Capture the line excluding the command, and everything after the current word
653
local args=("${COMP_WORDS[@]:1:COMP_CWORD}")
@@ -16,7 +63,8 @@ _generate_{{.Name}}_completions() {
1663
COMPREPLY=()
1764
fi
1865
}
19-
2066
# Setup Bash to use the function for completions for '{{.Name}}'
2167
complete -F _generate_{{.Name}}_completions {{.Name}}
68+
# === END {{.Name}} COMPLETION ===
69+
2270
`

completion/fish.go

+42
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,47 @@
11
package completion
22

3+
import (
4+
"io"
5+
"path/filepath"
6+
7+
home "github.com/mitchellh/go-homedir"
8+
)
9+
10+
type fish struct {
11+
goos string
12+
programName string
13+
}
14+
15+
var _ Shell = &fish{}
16+
17+
func Fish(goos string, programName string) Shell {
18+
return &fish{goos: goos, programName: programName}
19+
}
20+
21+
// UsesOwnFile implements Shell.
22+
func (f *fish) UsesOwnFile() bool {
23+
return true
24+
}
25+
26+
// Name implements Shell.
27+
func (f *fish) Name() string {
28+
return "fish"
29+
}
30+
31+
// InstallPath implements Shell.
32+
func (f *fish) InstallPath() (string, error) {
33+
homeDir, err := home.Dir()
34+
if err != nil {
35+
return "", err
36+
}
37+
return filepath.Join(homeDir, ".config/fish/completions/", f.programName+".fish"), nil
38+
}
39+
40+
// WriteCompletion implements Shell.
41+
func (f *fish) WriteCompletion(w io.Writer) error {
42+
return generateCompletion(fishCompletionTemplate)(w, f.programName)
43+
}
44+
345
const fishCompletionTemplate = `
446
function _{{.Name}}_completions
547
# Capture the full command line as an array

0 commit comments

Comments
 (0)