Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions command.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package command

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -18,13 +19,42 @@ type HelpConfig struct {
Help bool `inherited:"true" desc:"Show this help screen and exit."`
}

type Action interface {
Run(context.Context) error
}

type ActionFunc func(context.Context) error

func (i ActionFunc) Run(ctx context.Context) error {
if i != nil {
return i(ctx)
} else {
return nil
}
}

type PreRunHook interface {
PreRun(context.Context) error
}

type PreRunHookFunc func(context.Context) error

func (i PreRunHookFunc) PreRun(ctx context.Context) error {
if i != nil {
return i(ctx)
} else {
return nil
}
}

// Command is a command instance, created by [New] and can be composed with more Command instances to form a CLI command
// hierarchy.
type Command struct {
name string
shortDescription string
longDescription string
executor Executor
preRunHooks []PreRunHook
action Action
flags *flagSet
parent *Command
subCommands []*Command
Expand All @@ -34,8 +64,8 @@ type Command struct {
// MustNew creates a new command using [New], but will panic if it returns an error.
//
//goland:noinspection GoUnusedExportedFunction
func MustNew(name, shortDescription, longDescription string, executor Executor, subCommands ...*Command) *Command {
cmd, err := New(name, shortDescription, longDescription, executor, subCommands...)
func MustNew(name, shortDescription, longDescription string, action Action, preRunHooks []PreRunHook, subCommands ...*Command) *Command {
cmd, err := New(name, shortDescription, longDescription, action, preRunHooks, subCommands...)
if err != nil {
panic(err)
}
Expand All @@ -44,21 +74,20 @@ func MustNew(name, shortDescription, longDescription string, executor Executor,

// New creates a new command with the given name, short & long descriptions, and the given executor. The executor object
// is also scanned for configuration structs via reflection.
func New(name, shortDescription, longDescription string, executor Executor, subCommands ...*Command) (*Command, error) {
func New(name, shortDescription, longDescription string, action Action, preRunHooks []PreRunHook, subCommands ...*Command) (*Command, error) {
if name == "" {
return nil, fmt.Errorf("%w: empty name", ErrInvalidCommand)
} else if shortDescription == "" {
return nil, fmt.Errorf("%w: empty short description", ErrInvalidCommand)
} else if executor == nil {
return nil, fmt.Errorf("%w: nil executor", ErrInvalidCommand)
}

// Create the command instance
cmd := &Command{
name: name,
shortDescription: shortDescription,
longDescription: longDescription,
executor: executor,
action: action,
preRunHooks: preRunHooks,
HelpConfig: &HelpConfig{},
}

Expand All @@ -84,14 +113,21 @@ func (c *Command) setParent(parent *Command) error {
var parentFlags *flagSet
if parent != nil {
parentFlags = parent.flags
} else if fs, err := newFlagSet(nil, reflect.ValueOf(c).Elem().FieldByName("HelpConfig")); err != nil {
} else if parentFlagSet, err := newFlagSet(nil, reflect.ValueOf(c).Elem().FieldByName("HelpConfig")); err != nil {
return fmt.Errorf("failed creating Help flag set: %w", err)
} else {
parentFlags = fs
parentFlags = parentFlagSet
}

// Create the flag-set
if fs, err := newFlagSet(parentFlags, reflect.ValueOf(c.executor)); err != nil {
var configObjects []reflect.Value
if c.action != nil {
configObjects = append(configObjects, reflect.ValueOf(c.action))
}
for _, hook := range c.preRunHooks {
configObjects = append(configObjects, reflect.ValueOf(hook))
}
if fs, err := newFlagSet(parentFlags, configObjects...); err != nil {
return fmt.Errorf("failed creating flag-set for command '%s': %w", c.name, err)
} else {
c.parent = parent
Expand Down
97 changes: 51 additions & 46 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,19 @@ func TestNew(t *testing.T) {
testCases := map[string]testCase{
"empty name": {
commandFactory: func(t T, tc *testCase) (*Command, error) {
return New("", "short desc", "long desc", InlineExecutor{})
return New("", "short desc", "long desc", nil, nil)
},
expectedError: `^invalid command: empty name$`,
},
"empty short description": {
commandFactory: func(t T, tc *testCase) (*Command, error) {
return New("cmd", "", "long desc", InlineExecutor{})
return New("cmd", "", "long desc", nil, nil)
},
expectedError: `^invalid command: empty short description$`,
},
"nil executor": {
commandFactory: func(t T, tc *testCase) (*Command, error) {
return New("cmd", "desc", "long desc", nil)
},
expectedError: `^invalid command: nil executor$`,
},
"no flags": {
commandFactory: func(t T, tc *testCase) (*Command, error) {
return New("cmd", "desc", "long desc", InlineExecutor{})
return New("cmd", "desc", "long desc", nil, nil)
},
expectedName: "cmd",
expectedShortDescription: "desc",
Expand All @@ -56,9 +50,10 @@ func TestNew(t *testing.T) {
"desc",
"long desc",
&struct {
InlineExecutor
Action
MyFlag string `flag:"true"`
}{},
nil,
)
},
expectedFlagSet: &flagSet{
Expand Down Expand Up @@ -101,13 +96,13 @@ func TestNew(t *testing.T) {
func TestAddSubCommand(t *testing.T) {
t.Parallel()

root, err := New("root", "desc", "description", &InlineExecutor{})
root, err := New("root", "desc", "description", nil, nil)
With(t).Verify(err).Will(BeNil()).OrFail()

sub1, err := New("sub1", "sub1 desc", "sub1 description", &InlineExecutor{})
sub1, err := New("sub1", "sub1 desc", "sub1 description", nil, nil)
With(t).Verify(err).Will(BeNil()).OrFail()

sub2, err := New("sub2", "sub2 desc", "sub2 description", &InlineExecutor{})
sub2, err := New("sub2", "sub2 desc", "sub2 description", nil, nil)
With(t).Verify(err).Will(BeNil()).OrFail()

With(t).Verify(root.AddSubCommand(sub1)).Will(BeNil()).OrFail()
Expand All @@ -128,10 +123,10 @@ func Test_inferCommandAndArgs(t *testing.T) {
testCases := map[string]testCase{
"No arguments": {
root: MustNew(
"root", "desc", "description", &InlineExecutor{},
MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{},
MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{},
MustNew("sub3", "sub3 desc", "sub3 description", &InlineExecutor{}),
"root", "desc", "description", nil, nil,
MustNew("sub1", "sub1 desc", "sub1 description", nil, nil,
MustNew("sub2", "sub2 desc", "sub2 description", nil, nil,
MustNew("sub3", "sub3 desc", "sub3 description", nil, nil),
),
),
),
Expand All @@ -142,9 +137,9 @@ func Test_inferCommandAndArgs(t *testing.T) {
},
"Flags for root command": {
root: MustNew(
"root", "desc", "description", &InlineExecutor{},
MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{},
MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}),
"root", "desc", "description", nil, nil,
MustNew("sub1", "sub1 desc", "sub1 description", nil, nil,
MustNew("sub2", "sub2 desc", "sub2 description", nil, nil),
),
),
args: strings.Split("-f1 -f2", " "),
Expand All @@ -154,9 +149,9 @@ func Test_inferCommandAndArgs(t *testing.T) {
},
"Flags and positionals for root command": {
root: MustNew(
"root", "desc", "description", &InlineExecutor{},
MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{},
MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}),
"root", "desc", "description", nil, nil,
MustNew("sub1", "sub1 desc", "sub1 description", nil, nil,
MustNew("sub2", "sub2 desc", "sub2 description", nil, nil),
),
),
args: strings.Split("-f1 a -f2 b", " "),
Expand All @@ -166,9 +161,9 @@ func Test_inferCommandAndArgs(t *testing.T) {
},
"Flags and positionals for sub1 command": {
root: MustNew(
"root", "desc", "description", &InlineExecutor{},
MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{},
MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}),
"root", "desc", "description", nil, nil,
MustNew("sub1", "sub1 desc", "sub1 description", nil, nil,
MustNew("sub2", "sub2 desc", "sub2 description", nil, nil),
),
),
args: strings.Split("-f1 sub1 -f2 a b", " "),
Expand All @@ -178,9 +173,9 @@ func Test_inferCommandAndArgs(t *testing.T) {
},
"Flags and positionals for sub2 command": {
root: MustNew(
"root", "desc", "description", &InlineExecutor{},
MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{},
MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}),
"root", "desc", "description", nil, nil,
MustNew("sub1", "sub1 desc", "sub1 description", nil, nil,
MustNew("sub2", "sub2 desc", "sub2 description", nil, nil),
),
),
args: strings.Split("-f1 sub1 -f2 a b sub2 c", " "),
Expand All @@ -205,10 +200,10 @@ func Test_getFullName(t *testing.T) {
cmd *Command
expectedFullName string
}
sub3 := MustNew("sub3", "sub3 desc", "sub3 description", &InlineExecutor{})
sub2 := MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}, sub3)
sub1 := MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{}, sub2)
root := MustNew("root", "desc", "description", &InlineExecutor{}, sub1)
sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil)
sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, sub3)
sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, sub2)
root := MustNew("root", "desc", "description", nil, nil, sub1)
testCases := map[string]testCase{
"root": {
cmd: root,
Expand Down Expand Up @@ -240,10 +235,10 @@ func Test_getChain(t *testing.T) {
cmd *Command
expectedChain []string
}
sub3 := MustNew("sub3", "sub3 desc", "sub3 description", &InlineExecutor{})
sub2 := MustNew("sub2", "sub2 desc", "sub2 description", &InlineExecutor{}, sub3)
sub1 := MustNew("sub1", "sub1 desc", "sub1 description", &InlineExecutor{}, sub2)
root := MustNew("root", "desc", "description", &InlineExecutor{}, sub1)
sub3 := MustNew("sub3", "sub3 desc", "sub3 description", nil, nil)
sub2 := MustNew("sub2", "sub2 desc", "sub2 description", nil, nil, sub3)
sub1 := MustNew("sub1", "sub1 desc", "sub1 description", nil, nil, sub2)
root := MustNew("root", "desc", "description", nil, nil, sub1)
testCases := map[string]testCase{
"root": {
cmd: root,
Expand Down Expand Up @@ -286,7 +281,7 @@ func TestPrintHelp(t *testing.T) {
"no flags & no positionals": {
commandFactory: func(*testCase) *Command {
ligen := loremipsum.NewWithSeed(4321)
return MustNew("cmd", ligen.Sentence(), ligen.Sentences(2), InlineExecutor{})
return MustNew("cmd", ligen.Sentence(), ligen.Sentences(2), nil, nil)
},
expectedHelpUsageOutput: `
Usage: cmd [--help]
Expand Down Expand Up @@ -320,11 +315,16 @@ Flags:
"with flags, args": {
commandFactory: func(*testCase) *Command {
ligen := loremipsum.NewWithSeed(4321)
return MustNew("cmd", ligen.Sentence(), ligen.Sentences(2), &struct {
InlineExecutor
MyFlag string `desc:"flag description"`
Args []string `args:"true"`
}{})
return MustNew(
"cmd",
ligen.Sentence(),
ligen.Sentences(2),
&struct {
Action
MyFlag string `desc:"flag description"`
Args []string `args:"true"`
}{},
nil)
},
expectedHelpUsageOutput: `
Usage: cmd [--help]
Expand Down Expand Up @@ -369,16 +369,21 @@ Flags:
ligen.Sentence(),
ligen.Sentences(2),
&struct {
InlineExecutor
Action
MyFlag string `desc:"flag description"`
Args []string `args:"true"`
}{},
nil,
MustNew(
"child1", ligen.Sentence(), ligen.Sentences(2), &struct {
InlineExecutor
"child1",
ligen.Sentence(),
ligen.Sentences(2),
&struct {
Action
SubFlag string `desc:"sub flag description"`
Args []string `args:"true"`
}{},
nil,
),
)
},
Expand Down
53 changes: 18 additions & 35 deletions execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,6 @@ const (
ExitCodeMisconfiguration ExitCode = 2
)

// Executor is the interface to be implemented by custom commands.
type Executor interface {
PreRun(ctx context.Context) error
Run(ctx context.Context) error
}

type InlineExecutor struct {
PreRunFunc func(context.Context) error
RunFunc func(context.Context) error
}

func (i InlineExecutor) PreRun(ctx context.Context) error {
if i.PreRunFunc != nil {
return i.PreRunFunc(ctx)
} else {
return nil
}
}

func (i InlineExecutor) Run(ctx context.Context) error {
if i.PreRunFunc != nil {
return i.RunFunc(ctx)
} else {
return nil
}
}

// Execute the correct command in the given command hierarchy (starting at "root"), configured from the given CLI args
// and environment variables. The command will be executed with the given context after all pre-RunFunc hooks have been
// successfully executed in the command hierarchy.
Expand Down Expand Up @@ -75,17 +48,27 @@ func Execute(ctx context.Context, w io.Writer, root *Command, args []string, env

// Invoke all "PreRun" hooks on the whole chain of commands (starting at the root)
for _, c := range cmd.getChain() {
if err := c.executor.PreRun(ctx); err != nil {
_, _ = fmt.Fprintln(w, err)
return ExitCodeError
for _, hook := range c.preRunHooks {
if err := hook.PreRun(ctx); err != nil {
_, _ = fmt.Fprintln(w, err)
return ExitCodeError
}
}
}

// Run the command
if err := cmd.executor.Run(ctx); err != nil {
_, _ = fmt.Fprintln(w, err)
return ExitCodeError
// Run the command or print help screen if it's not a command
if cmd.action != nil {
if err := cmd.action.Run(ctx); err != nil {
_, _ = fmt.Fprintln(w, err)
return ExitCodeError
}
} else {
// Command is not a runner - print help
if err := cmd.PrintHelp(w, getTerminalWidth()); err != nil {
_, _ = fmt.Fprintf(w, "%s\n", err)
return ExitCodeError
}
}

return ExitCodeSuccess

}
Loading