Skip to content

Commit 2cac635

Browse files
authored
Add nested commands (#2)
1 parent 97f90a5 commit 2cac635

File tree

3 files changed

+148
-18
lines changed

3 files changed

+148
-18
lines changed

acmd.go

+40-14
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type Command struct {
4444
Do func(ctx context.Context, args []string) error
4545

4646
// subcommands of the command.
47-
subcommands []Command
47+
Subcommands []Command
4848
}
4949

5050
// Config for the runner.
@@ -116,8 +116,7 @@ func (r *Runner) init() error {
116116
cmds := r.cmds
117117
r.rootCmd = Command{
118118
Name: "root",
119-
Do: nopFunc,
120-
subcommands: cmds,
119+
Subcommands: cmds,
121120
}
122121
if err := validateCommand(r.rootCmd); err != nil {
123122
return err
@@ -146,17 +145,21 @@ func (r *Runner) init() error {
146145
return cmds[i].Name < cmds[j].Name
147146
})
148147

149-
r.rootCmd.subcommands = cmds
148+
r.rootCmd.Subcommands = cmds
149+
r.rootCmd.Do = rootDo(r.cfg, cmds)
150150

151151
return nil
152152
}
153153

154154
func validateCommand(cmd Command) error {
155-
cmds := cmd.subcommands
155+
cmds := cmd.Subcommands
156156

157157
switch {
158158
case cmd.Do == nil && len(cmds) == 0:
159-
return fmt.Errorf("command %q function cannot be nil", cmd.Name)
159+
return fmt.Errorf("command %q function cannot be nil or must have subcommands", cmd.Name)
160+
161+
case cmd.Do != nil && len(cmds) != 0:
162+
return fmt.Errorf("command %q function cannot be set and have subcommands", cmd.Name)
160163

161164
case cmd.Name == "help" || cmd.Name == "version":
162165
return fmt.Errorf("command %q is reserved", cmd.Name)
@@ -209,23 +212,46 @@ func (r *Runner) Run() error {
209212
if r.errInit != nil {
210213
return fmt.Errorf("acmd: cannot init runner: %w", r.errInit)
211214
}
212-
if err := run(r.ctx, r.cfg, r.rootCmd.subcommands, r.args); err != nil {
215+
if err := r.rootCmd.Do(r.ctx, r.args); err != nil {
213216
return fmt.Errorf("acmd: cannot run command: %w", err)
214217
}
215218
return nil
216219
}
217220

218-
func run(ctx context.Context, cfg Config, cmds []Command, args []string) error {
219-
selected, params := args[0], args[1:]
220-
221-
for _, c := range cmds {
222-
if selected == c.Name || selected == c.Alias {
223-
return c.Do(ctx, params)
221+
func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) error {
222+
return func(ctx context.Context, args []string) error {
223+
cmds, args := cmds, args
224+
for {
225+
selected, params := args[0], args[1:]
226+
227+
var found bool
228+
for _, c := range cmds {
229+
if selected != c.Name && selected != c.Alias {
230+
continue
231+
}
232+
233+
// go deeper into subcommands
234+
if c.Do == nil {
235+
if len(params) == 0 {
236+
return errors.New("no args for subcmd provided")
237+
}
238+
cmds, args = c.Subcommands, params
239+
found = true
240+
break
241+
}
242+
return c.Do(ctx, params)
243+
}
244+
245+
if !found {
246+
return errNotFoundAndSuggest(cfg.Output, selected, cmds)
247+
}
224248
}
225249
}
250+
}
226251

252+
func errNotFoundAndSuggest(w io.Writer, selected string, cmds []Command) error {
227253
if suggestion := suggestCommand(selected, cmds); suggestion != "" {
228-
fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion)
254+
fmt.Fprintf(w, "%q is not a subcommand, did you mean %q?\n", selected, suggestion)
229255
}
230256
return fmt.Errorf("no such command %q", selected)
231257
}

acmd_test.go

+71-4
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,67 @@ package acmd
22

33
import (
44
"bytes"
5+
"context"
6+
"fmt"
57
"os"
68
"sort"
79
"strings"
810
"testing"
11+
"time"
912
)
1013

14+
func TestRunner(t *testing.T) {
15+
buf := &bytes.Buffer{}
16+
17+
cmds := []Command{
18+
{
19+
Name: "test",
20+
Description: "some test command",
21+
Subcommands: []Command{
22+
{
23+
Name: "foo",
24+
Subcommands: []Command{
25+
{
26+
Name: "for", Do: func(ctx context.Context, args []string) error {
27+
fmt.Fprint(buf, "for")
28+
return nil
29+
},
30+
},
31+
},
32+
},
33+
{
34+
Name: "bar",
35+
Do: func(ctx context.Context, args []string) error {
36+
fmt.Fprint(buf, "bar")
37+
return nil
38+
},
39+
},
40+
},
41+
},
42+
{
43+
Name: "status",
44+
Description: "status command gives status of the state",
45+
Do: func(ctx context.Context, args []string) error {
46+
return nil
47+
},
48+
},
49+
}
50+
r := RunnerOf(cmds, Config{
51+
Args: []string{"test", "foo", "for"},
52+
AppName: "acmd_test_app",
53+
AppDescription: "acmd_test_app is a test application.",
54+
Version: time.Now().String(),
55+
Output: buf,
56+
})
57+
58+
if err := r.Run(); err != nil {
59+
t.Fatal(err)
60+
}
61+
if got := buf.String(); got != "for" {
62+
t.Fatalf("want %q got %q", "for", got)
63+
}
64+
}
65+
1166
func TestRunnerMustSetDefaults(t *testing.T) {
1267
cmds := []Command{{Name: "foo", Do: nopFunc}}
1368
r := RunnerOf(cmds, Config{})
@@ -34,7 +89,7 @@ func TestRunnerMustSetDefaults(t *testing.T) {
3489
}
3590

3691
gotCmds := map[string]struct{}{}
37-
for _, c := range r.rootCmd.subcommands {
92+
for _, c := range r.rootCmd.Subcommands {
3893
gotCmds[c.Name] = struct{}{}
3994
}
4095
if _, ok := gotCmds["help"]; !ok {
@@ -95,6 +150,18 @@ func TestRunnerInit(t *testing.T) {
95150
cmds: []Command{{Name: "foo%", Do: nil}},
96151
wantErrStr: `command "foo%" function cannot be nil`,
97152
},
153+
{
154+
cmds: []Command{{Name: "foo", Do: nil}},
155+
wantErrStr: `command "foo" function cannot be nil or must have subcommands`,
156+
},
157+
{
158+
cmds: []Command{{
159+
Name: "foobar",
160+
Do: nopFunc,
161+
Subcommands: []Command{{Name: "nested"}},
162+
}},
163+
wantErrStr: `command "foobar" function cannot be set and have subcommands`,
164+
},
98165
{
99166
cmds: []Command{{Name: "foo", Do: nopFunc}},
100167
cfg: Config{
@@ -183,12 +250,12 @@ func TestRunner_suggestCommand(t *testing.T) {
183250
Args: tc.args,
184251
Output: buf,
185252
})
186-
if err := r.Run(); err == nil {
187-
t.Fatal()
253+
if err := r.Run(); err != nil && !strings.Contains(err.Error(), "no such command") {
254+
t.Fatal(err)
188255
}
189256

190257
if got := buf.String(); got != tc.want {
191-
t.Logf("want %q got %q", tc.want, got)
258+
t.Fatalf("want %q got %q", tc.want, got)
192259
}
193260
}
194261
}

example_test.go

+37
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,40 @@ func ExampleAutosuggestion() {
194194

195195
// Output: "baz" is not a subcommand, did you mean "bar"?
196196
}
197+
198+
func ExampleNestedCommands() {
199+
testOut := os.Stdout
200+
testArgs := []string{"foo", "qux"}
201+
202+
cmds := []acmd.Command{
203+
{
204+
Name: "foo",
205+
Subcommands: []acmd.Command{
206+
{Name: "bar", Do: nopFunc},
207+
{Name: "baz", Do: nopFunc},
208+
{
209+
Name: "qux",
210+
Do: func(ctx context.Context, args []string) error {
211+
fmt.Fprint(testOut, "qux")
212+
return nil
213+
},
214+
},
215+
},
216+
},
217+
{Name: "boom", Do: nopFunc},
218+
}
219+
220+
r := acmd.RunnerOf(cmds, acmd.Config{
221+
AppName: "acmd-example",
222+
AppDescription: "Example of acmd package",
223+
Version: "the best v0.x.y",
224+
Output: testOut,
225+
Args: testArgs,
226+
})
227+
228+
if err := r.Run(); err != nil {
229+
panic(err)
230+
}
231+
232+
// Output: qux
233+
}

0 commit comments

Comments
 (0)