Skip to content

Commit

Permalink
Add nested commands (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
cristaloleg authored Nov 3, 2021
1 parent 97f90a5 commit 2cac635
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 18 deletions.
54 changes: 40 additions & 14 deletions acmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type Command struct {
Do func(ctx context.Context, args []string) error

// subcommands of the command.
subcommands []Command
Subcommands []Command
}

// Config for the runner.
Expand Down Expand Up @@ -116,8 +116,7 @@ func (r *Runner) init() error {
cmds := r.cmds
r.rootCmd = Command{
Name: "root",
Do: nopFunc,
subcommands: cmds,
Subcommands: cmds,
}
if err := validateCommand(r.rootCmd); err != nil {
return err
Expand Down Expand Up @@ -146,17 +145,21 @@ func (r *Runner) init() error {
return cmds[i].Name < cmds[j].Name
})

r.rootCmd.subcommands = cmds
r.rootCmd.Subcommands = cmds
r.rootCmd.Do = rootDo(r.cfg, cmds)

return nil
}

func validateCommand(cmd Command) error {
cmds := cmd.subcommands
cmds := cmd.Subcommands

switch {
case cmd.Do == nil && len(cmds) == 0:
return fmt.Errorf("command %q function cannot be nil", cmd.Name)
return fmt.Errorf("command %q function cannot be nil or must have subcommands", cmd.Name)

case cmd.Do != nil && len(cmds) != 0:
return fmt.Errorf("command %q function cannot be set and have subcommands", cmd.Name)

case cmd.Name == "help" || cmd.Name == "version":
return fmt.Errorf("command %q is reserved", cmd.Name)
Expand Down Expand Up @@ -209,23 +212,46 @@ func (r *Runner) Run() error {
if r.errInit != nil {
return fmt.Errorf("acmd: cannot init runner: %w", r.errInit)
}
if err := run(r.ctx, r.cfg, r.rootCmd.subcommands, r.args); err != nil {
if err := r.rootCmd.Do(r.ctx, r.args); err != nil {
return fmt.Errorf("acmd: cannot run command: %w", err)
}
return nil
}

func run(ctx context.Context, cfg Config, cmds []Command, args []string) error {
selected, params := args[0], args[1:]

for _, c := range cmds {
if selected == c.Name || selected == c.Alias {
return c.Do(ctx, params)
func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) error {
return func(ctx context.Context, args []string) error {
cmds, args := cmds, args
for {
selected, params := args[0], args[1:]

var found bool
for _, c := range cmds {
if selected != c.Name && selected != c.Alias {
continue
}

// go deeper into subcommands
if c.Do == nil {
if len(params) == 0 {
return errors.New("no args for subcmd provided")
}
cmds, args = c.Subcommands, params
found = true
break
}
return c.Do(ctx, params)
}

if !found {
return errNotFoundAndSuggest(cfg.Output, selected, cmds)
}
}
}
}

func errNotFoundAndSuggest(w io.Writer, selected string, cmds []Command) error {
if suggestion := suggestCommand(selected, cmds); suggestion != "" {
fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion)
fmt.Fprintf(w, "%q is not a subcommand, did you mean %q?\n", selected, suggestion)
}
return fmt.Errorf("no such command %q", selected)
}
Expand Down
75 changes: 71 additions & 4 deletions acmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,67 @@ package acmd

import (
"bytes"
"context"
"fmt"
"os"
"sort"
"strings"
"testing"
"time"
)

func TestRunner(t *testing.T) {
buf := &bytes.Buffer{}

cmds := []Command{
{
Name: "test",
Description: "some test command",
Subcommands: []Command{
{
Name: "foo",
Subcommands: []Command{
{
Name: "for", Do: func(ctx context.Context, args []string) error {
fmt.Fprint(buf, "for")
return nil
},
},
},
},
{
Name: "bar",
Do: func(ctx context.Context, args []string) error {
fmt.Fprint(buf, "bar")
return nil
},
},
},
},
{
Name: "status",
Description: "status command gives status of the state",
Do: func(ctx context.Context, args []string) error {
return nil
},
},
}
r := RunnerOf(cmds, Config{
Args: []string{"test", "foo", "for"},
AppName: "acmd_test_app",
AppDescription: "acmd_test_app is a test application.",
Version: time.Now().String(),
Output: buf,
})

if err := r.Run(); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "for" {
t.Fatalf("want %q got %q", "for", got)
}
}

func TestRunnerMustSetDefaults(t *testing.T) {
cmds := []Command{{Name: "foo", Do: nopFunc}}
r := RunnerOf(cmds, Config{})
Expand All @@ -34,7 +89,7 @@ func TestRunnerMustSetDefaults(t *testing.T) {
}

gotCmds := map[string]struct{}{}
for _, c := range r.rootCmd.subcommands {
for _, c := range r.rootCmd.Subcommands {
gotCmds[c.Name] = struct{}{}
}
if _, ok := gotCmds["help"]; !ok {
Expand Down Expand Up @@ -95,6 +150,18 @@ func TestRunnerInit(t *testing.T) {
cmds: []Command{{Name: "foo%", Do: nil}},
wantErrStr: `command "foo%" function cannot be nil`,
},
{
cmds: []Command{{Name: "foo", Do: nil}},
wantErrStr: `command "foo" function cannot be nil or must have subcommands`,
},
{
cmds: []Command{{
Name: "foobar",
Do: nopFunc,
Subcommands: []Command{{Name: "nested"}},
}},
wantErrStr: `command "foobar" function cannot be set and have subcommands`,
},
{
cmds: []Command{{Name: "foo", Do: nopFunc}},
cfg: Config{
Expand Down Expand Up @@ -183,12 +250,12 @@ func TestRunner_suggestCommand(t *testing.T) {
Args: tc.args,
Output: buf,
})
if err := r.Run(); err == nil {
t.Fatal()
if err := r.Run(); err != nil && !strings.Contains(err.Error(), "no such command") {
t.Fatal(err)
}

if got := buf.String(); got != tc.want {
t.Logf("want %q got %q", tc.want, got)
t.Fatalf("want %q got %q", tc.want, got)
}
}
}
37 changes: 37 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,40 @@ func ExampleAutosuggestion() {

// Output: "baz" is not a subcommand, did you mean "bar"?
}

func ExampleNestedCommands() {
testOut := os.Stdout
testArgs := []string{"foo", "qux"}

cmds := []acmd.Command{
{
Name: "foo",
Subcommands: []acmd.Command{
{Name: "bar", Do: nopFunc},
{Name: "baz", Do: nopFunc},
{
Name: "qux",
Do: func(ctx context.Context, args []string) error {
fmt.Fprint(testOut, "qux")
return nil
},
},
},
},
{Name: "boom", Do: nopFunc},
}

r := acmd.RunnerOf(cmds, acmd.Config{
AppName: "acmd-example",
AppDescription: "Example of acmd package",
Version: "the best v0.x.y",
Output: testOut,
Args: testArgs,
})

if err := r.Run(); err != nil {
panic(err)
}

// Output: qux
}

0 comments on commit 2cac635

Please sign in to comment.