From b2dcc73aa364e62bd7e0bc90a7300345e26fbb40 Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Sun, 31 Oct 2021 00:39:48 +0200 Subject: [PATCH 1/6] Add nested commands --- acmd.go | 54 +++++++++++++++++++++++++++++++--------- acmd_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/acmd.go b/acmd.go index 434deab..7232004 100644 --- a/acmd.go +++ b/acmd.go @@ -87,6 +87,9 @@ func RunnerOf(cmds []Command, cfg Config) *Runner { } func (r *Runner) init() error { + if len(r.cmds) == 0 { + return errors.New("no cmds provided") + } if r.cfg.AppName == "" { r.cfg.AppName = os.Args[0] } @@ -147,6 +150,7 @@ func (r *Runner) init() error { }) r.rootCmd.subcommands = cmds + r.rootCmd.Do = rootDo(r.cfg, cmds) return nil } @@ -156,7 +160,10 @@ func validateCommand(cmd Command) error { 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) @@ -202,25 +209,48 @@ 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 { + if len(args) == 0 { + return errors.New("no args provided") } - } - if suggestion := suggestCommand(selected, cmds); suggestion != "" { - fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) + cmds, args := cmds, args + for { + selected, params := args[0], args[1:] + + var found bool + for _, c := range cmds { + if c.Name != selected { + 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 { + if suggestion := suggestCommand(selected, cmds); suggestion != "" { + fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) + } + return fmt.Errorf("no such command %q", selected) + } + } } - return fmt.Errorf("no such command %q", selected) } // suggestCommand for not found earlier command. diff --git a/acmd_test.go b/acmd_test.go index b89cc28..b7b1af9 100644 --- a/acmd_test.go +++ b/acmd_test.go @@ -2,10 +2,13 @@ package acmd import ( "bytes" + "context" + "fmt" "os" "sort" "strings" "testing" + "time" ) func TestRunnerMustSetDefaults(t *testing.T) { @@ -95,6 +98,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{ @@ -183,12 +198,61 @@ 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) } } } + +func TestRunner(t *testing.T) { + r := RunnerOf([]Command{ + { + Name: "test", + Description: "some test command", + // Do: func(ctx context.Context, args []string) error { + // return nil + // }, + subcommands: []Command{ + { + Name: "foo", + // Do: func(ctx context.Context, args []string) error { + // fmt.Fprint(os.Stderr, "foo") + // return nil + // }, + subcommands: []Command{ + { + Name: "for", Do: func(ctx context.Context, args []string) error { + fmt.Fprint(os.Stderr, "for") + return nil + }, + }, + }, + }, + {Name: "bar", Do: func(ctx context.Context, args []string) error { + fmt.Fprint(os.Stderr, "bar") + return nil + }}, + }, + }, + { + Name: "status", + Description: "status command gives status of the state", + Do: func(ctx context.Context, args []string) error { + return nil + }, + }, + }, Config{ + Args: []string{"test", "foo", "for"}, + AppName: "acmd_test_app", + AppDescription: "acmd_test_app is a test application.", + Version: time.Now().String(), + }) + + if err := r.Run(); err != nil { + t.Fatal(err) + } +} From 93ed53c619855b669afdfc1664135667861865ad Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Sun, 31 Oct 2021 08:19:32 +0100 Subject: [PATCH 2/6] Remove unused check --- acmd.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/acmd.go b/acmd.go index 7232004..403119d 100644 --- a/acmd.go +++ b/acmd.go @@ -217,10 +217,6 @@ func (r *Runner) Run() error { func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) error { return func(ctx context.Context, args []string) error { - if len(args) == 0 { - return errors.New("no args provided") - } - cmds, args := cmds, args for { selected, params := args[0], args[1:] From a113dd82b81fd0792bf10a5db0b6c9f363bb5550 Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Sun, 31 Oct 2021 08:19:43 +0100 Subject: [PATCH 3/6] Extract error & suggest --- acmd.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/acmd.go b/acmd.go index 403119d..72d1e56 100644 --- a/acmd.go +++ b/acmd.go @@ -240,15 +240,19 @@ func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) } if !found { - if suggestion := suggestCommand(selected, cmds); suggestion != "" { - fmt.Fprintf(cfg.Output, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) - } - return fmt.Errorf("no such command %q", selected) + return errNotFoundAndSuggest(cfg.Output, selected, cmds) } } } } +func errNotFoundAndSuggest(w io.Writer, selected string, cmds []Command) error { + if suggestion := suggestCommand(selected, cmds); suggestion != "" { + fmt.Fprintf(w, "%q is not a subcommand, did you mean %q?\n", selected, suggestion) + } + return fmt.Errorf("no such command %q", selected) +} + // suggestCommand for not found earlier command. func suggestCommand(got string, cmds []Command) string { const maxMatchDist = 2 From 4dca86548512ee244221cbb07a7582d83010c2ed Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Sun, 31 Oct 2021 09:46:55 +0100 Subject: [PATCH 4/6] Fix --- acmd.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/acmd.go b/acmd.go index 72d1e56..008121e 100644 --- a/acmd.go +++ b/acmd.go @@ -87,9 +87,6 @@ func RunnerOf(cmds []Command, cfg Config) *Runner { } func (r *Runner) init() error { - if len(r.cmds) == 0 { - return errors.New("no cmds provided") - } if r.cfg.AppName == "" { r.cfg.AppName = os.Args[0] } @@ -119,7 +116,6 @@ func (r *Runner) init() error { cmds := r.cmds r.rootCmd = Command{ Name: "root", - Do: nopFunc, subcommands: cmds, } if err := validateCommand(r.rootCmd); err != nil { From a42c723199906e22b4afb2209d3994b1521d2980 Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Mon, 1 Nov 2021 12:00:24 +0100 Subject: [PATCH 5/6] Update --- acmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acmd.go b/acmd.go index 008121e..1f89647 100644 --- a/acmd.go +++ b/acmd.go @@ -219,7 +219,7 @@ func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) var found bool for _, c := range cmds { - if c.Name != selected { + if selected != c.Name && selected != c.Alias { continue } From d60c0d1fd118adc87d927322dbc477e2070cb210 Mon Sep 17 00:00:00 2001 From: Oleg Kovalov Date: Mon, 1 Nov 2021 19:31:42 +0100 Subject: [PATCH 6/6] Expose field --- acmd.go | 10 ++--- acmd_test.go | 105 +++++++++++++++++++++++++----------------------- example_test.go | 37 +++++++++++++++++ 3 files changed, 96 insertions(+), 56 deletions(-) diff --git a/acmd.go b/acmd.go index 1f89647..9f95a86 100644 --- a/acmd.go +++ b/acmd.go @@ -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. @@ -116,7 +116,7 @@ func (r *Runner) init() error { cmds := r.cmds r.rootCmd = Command{ Name: "root", - subcommands: cmds, + Subcommands: cmds, } if err := validateCommand(r.rootCmd); err != nil { return err @@ -145,14 +145,14 @@ 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: @@ -228,7 +228,7 @@ func rootDo(cfg Config, cmds []Command) func(ctx context.Context, args []string) if len(params) == 0 { return errors.New("no args for subcmd provided") } - cmds, args = c.subcommands, params + cmds, args = c.Subcommands, params found = true break } diff --git a/acmd_test.go b/acmd_test.go index b7b1af9..4d19944 100644 --- a/acmd_test.go +++ b/acmd_test.go @@ -11,6 +11,58 @@ import ( "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{}) @@ -37,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 { @@ -106,7 +158,7 @@ func TestRunnerInit(t *testing.T) { cmds: []Command{{ Name: "foobar", Do: nopFunc, - subcommands: []Command{{Name: "nested"}}, + Subcommands: []Command{{Name: "nested"}}, }}, wantErrStr: `command "foobar" function cannot be set and have subcommands`, }, @@ -207,52 +259,3 @@ func TestRunner_suggestCommand(t *testing.T) { } } } - -func TestRunner(t *testing.T) { - r := RunnerOf([]Command{ - { - Name: "test", - Description: "some test command", - // Do: func(ctx context.Context, args []string) error { - // return nil - // }, - subcommands: []Command{ - { - Name: "foo", - // Do: func(ctx context.Context, args []string) error { - // fmt.Fprint(os.Stderr, "foo") - // return nil - // }, - subcommands: []Command{ - { - Name: "for", Do: func(ctx context.Context, args []string) error { - fmt.Fprint(os.Stderr, "for") - return nil - }, - }, - }, - }, - {Name: "bar", Do: func(ctx context.Context, args []string) error { - fmt.Fprint(os.Stderr, "bar") - return nil - }}, - }, - }, - { - Name: "status", - Description: "status command gives status of the state", - Do: func(ctx context.Context, args []string) error { - return nil - }, - }, - }, Config{ - Args: []string{"test", "foo", "for"}, - AppName: "acmd_test_app", - AppDescription: "acmd_test_app is a test application.", - Version: time.Now().String(), - }) - - if err := r.Run(); err != nil { - t.Fatal(err) - } -} diff --git a/example_test.go b/example_test.go index c3b2115..c94f621 100644 --- a/example_test.go +++ b/example_test.go @@ -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 +}