Skip to content

Commit 054d94b

Browse files
authored
Merge pull request #6 from thediveo/develop
feature: shell completion
2 parents 387f440 + fd2a68c commit 054d94b

File tree

15 files changed

+603
-55
lines changed

15 files changed

+603
-55
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,35 @@ The boilerplate pattern is always the same:
123123
it: `var foomode = Bar`. It will be used correctly.
124124
5. Wire up your flag variable to its flag long and short names, et cetera.
125125

126+
### Shell Completion
127+
128+
Dynamic flag completion can be enabled by calling the `RegisterCompletion(...)`
129+
receiver of an enum flag (more precise: flag value) created using
130+
`enumflag.New(...)`. `enumflag` supports dynamic flag completion for both scalar
131+
and slice enum flags. Unfortunately, due to the cobra API design it isn't
132+
possible for `enumflag` to offer a fluent API. Instead, creation, adding, and
133+
registering have to be carried out as separate instructions.
134+
135+
```go
136+
// ⑤ Define the CLI flag parameters for your wrapped enum flag.
137+
ef := enumflag.New(&foomode, "mode", FooModeIds, enumflag.EnumCaseInsensitive)
138+
rootCmd.PersistentFlags().VarP(
139+
ef,
140+
"mode", "m",
141+
"foos the output; can be 'foo' or 'bar'")
142+
// ⑥ register completion
143+
ef.RegisterCompletion(rootCmd, "mode", enumflag.Help[FooMode]{
144+
Foo: "foos the output",
145+
Bar: "bars the output",
146+
})
147+
```
148+
149+
Please note for shell completion to work, your root command needs to have at
150+
least one (explicit) sub command. Otherwise, `cobra` won't automatically add an
151+
additional `completion` sub command. For more details, please refer to cobra's
152+
documentation on [Generating shell
153+
completions](https://github.com/spf13/cobra/blob/main/shell_completions.md).
154+
126155
### Use Existing Enum Types
127156

128157
A typical example might be your application using a 3rd party logging package

completion.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2022 Harald Albrecht.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4+
// use this file except in compliance with the License. You may obtain a copy
5+
// of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
// License for the specific language governing permissions and limitations
13+
// under the License.
14+
15+
package enumflag
16+
17+
import (
18+
"github.com/spf13/cobra"
19+
"golang.org/x/exp/constraints"
20+
)
21+
22+
// Help maps enumeration values to their corresponding help descriptions. These
23+
// descriptions should contain just the description but without any "foo\t" enum
24+
// value prefix. The reason is that enumflag will automatically register the
25+
// correct (erm, “complete”) completion text. Please note that it isn't
26+
// necessary to supply any help texts in order to register enum flag completion.
27+
type Help[E constraints.Integer] map[E]string
28+
29+
// Completor tells cobra how to complete a flag. See also cobra's [dynamic flag
30+
// completion] documentation.
31+
//
32+
// [dynamic flag completion]: https://github.com/spf13/cobra/blob/main/shell_completions.md#specify-dynamic-flag-completion
33+
type Completor func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)

completion_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2023 Harald Albrecht.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4+
// use this file except in compliance with the License. You may obtain a copy
5+
// of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
// License for the specific language governing permissions and limitations
13+
// under the License.
14+
15+
package enumflag
16+
17+
import (
18+
"io"
19+
"os"
20+
"os/exec"
21+
"path/filepath"
22+
"syscall"
23+
"time"
24+
25+
"github.com/onsi/gomega/gbytes"
26+
"github.com/onsi/gomega/gexec"
27+
"golang.org/x/exp/slices"
28+
29+
. "github.com/onsi/ginkgo/v2"
30+
. "github.com/onsi/gomega"
31+
. "github.com/thediveo/success"
32+
)
33+
34+
const dummyCommandName = "enumflag-testing"
35+
36+
// See:
37+
// https://serverfault.com/questions/506612/standard-place-for-user-defined-bash-completion-d-scripts/1013395#1013395
38+
const bashComplDirEnv = "BASH_COMPLETION_USER_DIR"
39+
40+
type writer struct {
41+
io.WriteCloser
42+
}
43+
44+
func (w *writer) WriteString(s string) {
45+
GinkgoHelper()
46+
Expect(w.WriteCloser.Write([]byte(s))).Error().NotTo(HaveOccurred())
47+
}
48+
49+
var _ = Describe("flag enum completions end-to-end", Ordered, func() {
50+
51+
var enumflagTestingPath string
52+
var completionsUserDir string
53+
54+
BeforeAll(func() {
55+
By("building a CLI binary for testing")
56+
enumflagTestingPath = Successful(gexec.Build("./test/enumflag-testing"))
57+
DeferCleanup(func() {
58+
gexec.CleanupBuildArtifacts()
59+
})
60+
61+
By("creating a temporary directory for storing completion scripts")
62+
completionsUserDir = Successful(os.MkdirTemp("", "bash-completions-*"))
63+
DeferCleanup(func() {
64+
os.RemoveAll(completionsUserDir)
65+
})
66+
// Notice how the bash-completion FAQ
67+
// https://github.com/scop/bash-completion/blob/master/README.md#faq
68+
// says that the completions must be inside a "completions" sub
69+
// directory of $BASH_COMPLETION_USER_DIR, and not inside
70+
// $BASH_COMPLETION_USER_DIR itself ... yeah, 🤷
71+
Expect(os.Mkdir(filepath.Join(completionsUserDir, "completions"), 0770)).To(Succeed())
72+
73+
By("telling the CLI binary to give us a completion script that we then store away")
74+
session := Successful(
75+
gexec.Start(exec.Command(enumflagTestingPath, "completion", "bash"),
76+
GinkgoWriter, GinkgoWriter))
77+
Eventually(session).Within(5 * time.Second).ProbeEvery(100 * time.Millisecond).
78+
Should(gexec.Exit(0))
79+
completionScript := session.Out.Contents()
80+
Expect(completionScript).To(MatchRegexp(`^# bash completion V2 for`))
81+
Expect(os.WriteFile(filepath.Join(completionsUserDir, "completions", "enumflag-testing"),
82+
completionScript, 0770)).
83+
To(Succeed())
84+
})
85+
86+
Bash := func() (*gexec.Session, *writer) {
87+
GinkgoHelper()
88+
By("creating a new test bash session")
89+
bashCmd := exec.Command("/bin/bash", "--rcfile", "/etc/profile", "-i")
90+
// Run the silly interactive subshell in its own session so we don't get
91+
// funny surprises such as the subshell getting suspended by its parent
92+
// shell...
93+
bashCmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
94+
bashCmd.Env = append(slices.Clone(os.Environ()),
95+
bashComplDirEnv+"="+completionsUserDir,
96+
"PATH="+filepath.Dir(enumflagTestingPath)+":"+os.Getenv("PATH"),
97+
)
98+
stdin := &writer{Successful(bashCmd.StdinPipe())}
99+
session := Successful(
100+
gexec.Start(bashCmd, GinkgoWriter, GinkgoWriter))
101+
DeferCleanup(func() {
102+
By("killing the test bash session")
103+
stdin.Close()
104+
session.Kill().Wait(2 * time.Second)
105+
})
106+
return session, stdin
107+
}
108+
109+
var bash *gexec.Session
110+
var bashin *writer
111+
112+
BeforeEach(func() {
113+
bash, bashin = Bash()
114+
})
115+
116+
It("tab-completes the canary's name in $PATH", func() {
117+
By("checking BASH_COMPLETION_USER_DIR")
118+
bashin.WriteString("echo $" + bashComplDirEnv + "\n")
119+
Eventually(bash.Out).Should(gbytes.Say(completionsUserDir))
120+
121+
By("listing the canary in the first search PATH directory")
122+
bashin.WriteString("ls -l ${PATH%%:*}\n")
123+
Eventually(bash.Out).Should(gbytes.Say(dummyCommandName))
124+
125+
By("ensuring the canary is in the PATH and gets completed")
126+
bashin.WriteString(dummyCommandName[:len(dummyCommandName)-4] + "\t")
127+
Eventually(bash.Err).Should(gbytes.Say(dummyCommandName))
128+
})
129+
130+
It("completes canary's test subcommand", func() {
131+
bashin.WriteString(dummyCommandName + " t\t")
132+
Eventually(bash.Err).Should(gbytes.Say(dummyCommandName + " test"))
133+
})
134+
135+
It("completes canary's \"mode\" enum flag name", func() {
136+
bashin.WriteString(dummyCommandName + " test --\t\t")
137+
Eventually(bash.Err).Should(gbytes.Say(
138+
`--help\s+\(help for test\)\s+--mode\s+\(sets foo mode\)`))
139+
})
140+
141+
It("lists enum flag's values", func() {
142+
bashin.WriteString(dummyCommandName + " test --mode \t\t")
143+
Eventually(bash.Err).Should(gbytes.Say(
144+
`bar\s+\(bars the output\)\s+baz\s+\(bazs the output\)\s+foo\s+\(foos the output\)`))
145+
bashin.WriteString("\b=\t\t")
146+
Eventually(bash.Err).Should(gbytes.Say(
147+
`bar\s+\(bars the output\)\s+baz\s+\(bazs the output\)\s+foo\s+\(foos the output\)`))
148+
})
149+
150+
It("completes enum flag's values", func() {
151+
bashin.WriteString(dummyCommandName + " test --mode ba\t\t")
152+
Eventually(bash.Err).Should(gbytes.Say(
153+
`bar\s+\(bars the output\)\s+baz\s+\(bazs the output\)`))
154+
bashin.WriteString("\b\bf\t")
155+
Eventually(bash.Err).Should(gbytes.Say(
156+
`oo `))
157+
})
158+
159+
})

flag.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package enumflag
1717
import (
1818
"fmt"
1919

20+
"github.com/spf13/cobra"
2021
"golang.org/x/exp/constraints"
2122
)
2223

@@ -58,14 +59,15 @@ type EnumFlagValue[E constraints.Integer] struct {
5859
// enumValue supports getting, setting, and stringifying an scalar or slice enum
5960
// enumValue.
6061
//
61-
// Do I smell preemptive interfacing here...? Now watch the magic of cleanest
62-
// code: by just moving the interface type from the source file with the struct
62+
// Do I smell preemptive interfacing here...? Now watch the magic of cleanest
63+
// code: by just moving the interface type from the source file with the struct
6364
// types to the source file with the consumer we achieve immediate Go
64-
// perfectness!
65+
// perfectness! Strike!
6566
type enumValue[E constraints.Integer] interface {
6667
Get() any
6768
Set(val string, names enumMapper[E]) error
6869
String(names enumMapper[E]) string
70+
NewCompletor(enums EnumIdentifiers[E], help Help[E]) Completor
6971
}
7072

7173
// New wraps a given enum variable (satisfying [constraints.Integer]) so that it
@@ -82,8 +84,8 @@ func New[E constraints.Integer](flag *E, typename string, mapping EnumIdentifier
8284
// NewWithoutDefault wraps a given enum variable (satisfying
8385
// [constraints.Integer]) so that it can be used as a flag Value with
8486
// [github.com/spf13/pflag.Var] and [github.com/spf13/pflag.VarP]. Please note
85-
// that zero enum value must not be mapped and thus not be assigned to any enum
86-
// value textual representation.
87+
// that the zero enum value must not be mapped and thus not be assigned to any
88+
// enum value textual representation.
8789
//
8890
// [spf13/cobra] won't show any default value in its help for CLI enum flags
8991
// created with NewWithoutDefault.
@@ -145,3 +147,10 @@ func (e *EnumFlagValue[E]) Type() string { return e.enumtype }
145147
// Get returns the current enum value for convenience. Please note that the enum
146148
// value is either scalar or slice, depending on how the enum flag was created.
147149
func (e *EnumFlagValue[E]) Get() any { return e.value.Get() }
150+
151+
// RegisterCompletion registers completions for the specified (flag) name, with
152+
// optional help texts.
153+
func (e *EnumFlagValue[E]) RegisterCompletion(cmd *cobra.Command, name string, help Help[E]) error {
154+
return cmd.RegisterFlagCompletionFunc(
155+
name, e.value.NewCompletor(e.names.Mapping(), help))
156+
}

flag_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package enumflag
1717
import (
1818
. "github.com/onsi/ginkgo/v2"
1919
. "github.com/onsi/gomega"
20+
"github.com/spf13/cobra"
2021
)
2122

2223
var _ = Describe("flag", func() {
@@ -81,4 +82,16 @@ var _ = Describe("flag", func() {
8182

8283
})
8384

85+
It("returns completors", func() {
86+
cmd := &cobra.Command{}
87+
foomodes := []FooModeTest{fmBar, fmFoo}
88+
val := NewSlice(&foomodes, "modes", FooModeIdentifiersTest, EnumCaseInsensitive)
89+
cmd.PersistentFlags().Var(val, "mode", "blahblah")
90+
Expect(val.RegisterCompletion(cmd, "mode", Help[FooModeTest]{
91+
fmFoo: "gives a foo",
92+
fmBar: "gives a bar",
93+
fmBaz: "gives a baz",
94+
})).To(Succeed())
95+
})
96+
8497
})

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/google/go-cmp v0.5.9 // indirect
2121
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2222
github.com/spf13/pflag v1.0.5 // indirect
23+
github.com/thediveo/success v1.0.1
2324
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
2425
golang.org/x/net v0.10.0 // indirect
2526
golang.org/x/sys v0.8.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
4949
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
5050
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
5151
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
52+
github.com/thediveo/success v1.0.1 h1:NVwUOwKUwaN8szjkJ+vsiM2L3sNBFscldoDJ2g2tAPg=
53+
github.com/thediveo/success v1.0.1/go.mod h1:AZ8oUArgbIsCuDEWrzWNQHdKnPbDOLQsWOFj9ynwLt0=
5254
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w=
5355
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
5456
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=

mapper.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ func (m enumMapper[E]) ValueOf(name string) (E, error) {
8383
sort.Strings(allids)
8484
return 0, fmt.Errorf("must be %s", strings.Join(allids, ", "))
8585
}
86+
87+
// Mapping returns the mapping of enum values to their names.
88+
func (m enumMapper[E]) Mapping() EnumIdentifiers[E] {
89+
return m.m
90+
}

package_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ var FooModeIdentifiersTest = map[FooModeTest][]string{
3838
fmBaz: {"baz"},
3939
}
4040

41+
var FooModeHelp = map[FooModeTest]string{
42+
fmFoo: "foo it",
43+
fmBar: "bar IT!",
44+
fmBaz: "baz nit!!",
45+
}
46+
4147
func TestEnumFlag(t *testing.T) {
4248
RegisterFailHandler(Fail)
4349
RunSpecs(t, "enumflag")

0 commit comments

Comments
 (0)