Skip to content

Commit 460dd41

Browse files
committed
feat: add completion install api
1 parent 91966a2 commit 460dd41

File tree

14 files changed

+335
-54
lines changed

14 files changed

+335
-54
lines changed

command.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -645,9 +645,13 @@ func (inv *Invocation) completeFlag(word string) []string {
645645
if opt.CompletionHandler != nil {
646646
return opt.CompletionHandler(inv)
647647
}
648-
val, ok := opt.Value.(*Enum)
648+
enum, ok := opt.Value.(*Enum)
649649
if ok {
650-
return val.Choices
650+
return enum.Choices
651+
}
652+
enumArr, ok := opt.Value.(*EnumArray)
653+
if ok {
654+
return enumArr.Choices
651655
}
652656
return nil
653657
}

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

+47-27
Original file line numberDiff line numberDiff line change
@@ -6,74 +6,80 @@ 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+
WriteCompletion(io.Writer) error
20+
}
21+
1522
const (
16-
BashShell string = "bash"
17-
FishShell string = "fish"
18-
ZShell string = "zsh"
19-
Powershell string = "powershell"
23+
ShellBash string = "bash"
24+
ShellFish string = "fish"
25+
ShellZsh string = "zsh"
26+
ShellPowershell string = "powershell"
2027
)
2128

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),
29+
func ShellByName(shell, programName string) Shell {
30+
switch shell {
31+
case ShellBash:
32+
return Bash(runtime.GOOS, programName)
33+
case ShellFish:
34+
return Fish(runtime.GOOS, programName)
35+
case ShellZsh:
36+
return Zsh(runtime.GOOS, programName)
37+
case ShellPowershell:
38+
return Powershell(runtime.GOOS, programName)
39+
default:
40+
return nil
41+
}
2742
}
2843

2944
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
45+
return serpent.EnumOf(choice, ShellBash, ShellFish, ShellZsh, ShellPowershell)
4046
}
4147

42-
func DetectUserShell() (string, error) {
48+
func DetectUserShell(programName string) (Shell, error) {
4349
// Attempt to get the SHELL environment variable first
4450
if shell := os.Getenv("SHELL"); shell != "" {
45-
return filepath.Base(shell), nil
51+
return ShellByName(filepath.Base(shell), ""), nil
4652
}
4753

4854
// Fallback: Look up the current user and parse /etc/passwd
4955
currentUser, err := user.Current()
5056
if err != nil {
51-
return "", err
57+
return nil, err
5258
}
5359

5460
// Open and parse /etc/passwd
5561
passwdFile, err := os.ReadFile("/etc/passwd")
5662
if err != nil {
57-
return "", err
63+
return nil, err
5864
}
5965

6066
lines := strings.Split(string(passwdFile), "\n")
6167
for _, line := range lines {
6268
if strings.HasPrefix(line, currentUser.Username+":") {
6369
parts := strings.Split(line, ":")
6470
if len(parts) > 6 {
65-
return filepath.Base(parts[6]), nil // The shell is typically the 7th field
71+
return ShellByName(filepath.Base(parts[6]), programName), nil // The shell is typically the 7th field
6672
}
6773
}
6874
}
6975

70-
return "", fmt.Errorf("default shell not found")
76+
return nil, fmt.Errorf("default shell not found")
7177
}
7278

7379
func generateCompletion(
7480
scriptTemplate string,
7581
) func(io.Writer, string) error {
76-
return func(w io.Writer, rootCmdName string) error {
82+
return func(w io.Writer, programName string) error {
7783
tmpl, err := template.New("script").Parse(scriptTemplate)
7884
if err != nil {
7985
return fmt.Errorf("parse template: %w", err)
@@ -82,7 +88,7 @@ func generateCompletion(
8288
err = tmpl.Execute(
8389
w,
8490
map[string]string{
85-
"Name": rootCmdName,
91+
"Name": programName,
8692
},
8793
)
8894
if err != nil {
@@ -92,3 +98,17 @@ func generateCompletion(
9298
return nil
9399
}
94100
}
101+
102+
func InstallShellCompletion(shell Shell) error {
103+
path, err := shell.InstallPath()
104+
if err != nil {
105+
return fmt.Errorf("get install path: %w", err)
106+
}
107+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
108+
if err != nil {
109+
return fmt.Errorf("create and append to file: %w", err)
110+
}
111+
defer f.Close()
112+
113+
return shell.WriteCompletion(f)
114+
}

completion/bash.go

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +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 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+
// InstallPath implements Shell.
27+
func (b *bash) InstallPath() (string, error) {
28+
homeDir, err := home.Dir()
29+
if err != nil {
30+
return "", err
31+
}
32+
if b.goos == "darwin" {
33+
return filepath.Join(homeDir, ".bash_profile"), nil
34+
}
35+
return filepath.Join(homeDir, ".bashrc"), nil
36+
}
37+
38+
// WriteCompletion implements Shell.
39+
func (b *bash) WriteCompletion(w io.Writer) error {
40+
return generateCompletion(bashCompletionTemplate)(w, b.programName)
41+
}
42+
343
const bashCompletionTemplate = `
44+
# === BEGIN {{.Name}} COMPLETION ===
445
_generate_{{.Name}}_completions() {
546
# Capture the line excluding the command, and everything after the current word
647
local args=("${COMP_WORDS[@]:1:COMP_CWORD}")
@@ -16,7 +57,7 @@ _generate_{{.Name}}_completions() {
1657
COMPREPLY=()
1758
fi
1859
}
19-
2060
# Setup Bash to use the function for completions for '{{.Name}}'
2161
complete -F _generate_{{.Name}}_completions {{.Name}}
62+
# === END {{.Name}} COMPLETION ===
2263
`

completion/fish.go

+37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
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+
// Name implements Shell.
22+
func (f *fish) Name() string {
23+
return "fish"
24+
}
25+
26+
// InstallPath implements Shell.
27+
func (f *fish) InstallPath() (string, error) {
28+
homeDir, err := home.Dir()
29+
if err != nil {
30+
return "", err
31+
}
32+
return filepath.Join(homeDir, ".config/fish/completions/", f.programName+".fish"), nil
33+
}
34+
35+
// WriteCompletion implements Shell.
36+
func (f *fish) WriteCompletion(w io.Writer) error {
37+
return generateCompletion(fishCompletionTemplate)(w, f.programName)
38+
}
39+
340
const fishCompletionTemplate = `
441
function _{{.Name}}_completions
542
# Capture the full command line as an array

completion/powershell.go

+46-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,51 @@
11
package completion
22

3-
const pshCompletionTemplate = `
3+
import (
4+
"io"
5+
"os/exec"
6+
)
7+
8+
type powershell struct {
9+
goos string
10+
programName string
11+
}
12+
13+
// Name implements Shell.
14+
func (p *powershell) Name() string {
15+
return "powershell"
16+
}
17+
18+
func Powershell(goos string, programName string) Shell {
19+
return &powershell{goos: goos, programName: programName}
20+
}
421

22+
// InstallPath implements Shell.
23+
func (p *powershell) InstallPath() (string, error) {
24+
var (
25+
path []byte
26+
err error
27+
)
28+
cmd := "$PROFILE.CurrentUserAllHosts"
29+
if p.goos == "windows" {
30+
path, err = exec.Command("powershell", cmd).CombinedOutput()
31+
} else {
32+
path, err = exec.Command("pwsh", "-Command", cmd).CombinedOutput()
33+
}
34+
if err != nil {
35+
return "", err
36+
}
37+
return string(path), nil
38+
}
39+
40+
// WriteCompletion implements Shell.
41+
func (p *powershell) WriteCompletion(w io.Writer) error {
42+
return generateCompletion(pshCompletionTemplate)(w, p.programName)
43+
}
44+
45+
var _ Shell = &powershell{}
46+
47+
const pshCompletionTemplate = `
48+
# === BEGIN {{.Name}} COMPLETION ===
549
# Escaping output sourced from:
650
# https://github.com/spf13/cobra/blob/e94f6d0dd9a5e5738dca6bce03c4b1207ffbc0ec/powershell_completions.go#L47
751
filter _{{.Name}}_escapeStringWithSpecialChars {
@@ -37,6 +81,6 @@ $_{{.Name}}_completions = {
3781
}
3882
rm env:COMPLETION_MODE
3983
}
40-
4184
Register-ArgumentCompleter -CommandName {{.Name}} -ScriptBlock $_{{.Name}}_completions
85+
# === END {{.Name}} COMPLETION ===
4286
`

0 commit comments

Comments
 (0)