Skip to content

Commit

Permalink
Add a trivial implementation of the default --help flag (#5)
Browse files Browse the repository at this point in the history
* WIP

* Get the help tests working

* Correctly use tt.golden
  • Loading branch information
FollowTheProcess authored Jul 12, 2024
1 parent 6a2a013 commit d601ed9
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 12 deletions.
94 changes: 85 additions & 9 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/spf13/pflag"
)
Expand All @@ -24,14 +25,19 @@ func New(name string, options ...Option) *Command {
fmt.Fprintf(cmd.stdout, "Hello from %s\n", name)
return nil
},
flags: pflag.NewFlagSet(name, pflag.ContinueOnError),
stdin: os.Stdin,
stdout: os.Stdout,
stderr: os.Stderr,
args: os.Args[1:],
name: name,
flags: pflag.NewFlagSet(name, pflag.ContinueOnError),
stdin: os.Stdin,
stdout: os.Stdout,
stderr: os.Stderr,
args: os.Args[1:],
name: name,
helpFunc: defaultHelp,
short: "A placeholder for something cool",
}

cmd.Flags().BoolP("help", "h", false, fmt.Sprintf("Show help for %s", name))

// Apply the options
for _, option := range options {
option(cmd)
}
Expand All @@ -41,7 +47,7 @@ func New(name string, options ...Option) *Command {

// Command represents a CLI command. In terms of an example, in the line
// git commit -m <msg>; 'commit' is the command. It can have any number of subcommands
// which themselves can have subcommands etc.
// which themselves can have subcommands etc. The root command in this example is 'git'.
type Command struct {
// run is the function actually implementing the command, the command and arguments to it, are passed into the function, flags
// are parsed out before the arguments are passed to Run, so `args` here are the command line arguments minus flags.
Expand All @@ -50,6 +56,12 @@ type Command struct {
// flags is the set of flags for this command.
flags *pflag.FlagSet

// helpFunc is the function that gets called when the user calls -h/--help.
//
// It can be overridden by the user to customise their help output using
// the [HelpFunc] [Option].
helpFunc func(cmd *Command) error

// stdin is an [io.Reader] from which command input is read.
//
// It defaults to [os.Stdin] but can be overridden as desired e.g. for testing.
Expand Down Expand Up @@ -79,7 +91,7 @@ type Command struct {

// args is the arguments passed to the command, default to [os.Args]
// (excluding the command name, so os.Args[1:]), can be overridden using
// the [Args] option.
// the [Args] option for e.g. testing.
args []string
}

Expand Down Expand Up @@ -126,7 +138,23 @@ func (c *Command) Execute() error {
return fmt.Errorf("failed to parse command flags: %w", err)
}

argsWithoutFlags := c.flags.Args()
// Check if we should be responding to -h/--help
helpCalled, err := c.Flags().GetBool("help")
if err != nil {
// We shouldn't ever get here because we define a default for help
return fmt.Errorf("help was called for but unset: %w", err)
}

// If -h/--help was called, call the defined helpFunc and exit so that
// the run function is never called.
if helpCalled {
if err := c.helpFunc(c); err != nil {
return fmt.Errorf("help function returned an error: %w", err)
}
return nil
}

argsWithoutFlags := c.Flags().Args()

return c.run(c, argsWithoutFlags)
}
Expand All @@ -152,3 +180,51 @@ func (c *Command) Stderr() io.Writer {
func (c *Command) Stdin() io.Reader {
return c.stdin
}

// defaultHelp is the default for a command's helpFunc.
func defaultHelp(cmd *Command) error {
// Note: The decision to not use text/template here is intentional, template calls
// reflect.Value.MethodByName() and/or reflect.Type.MethodByName() disables dead
// code elimination in the compiler, meaning any application that uses cli for it's
// command line interface will not be run through dead code elimination which could cause
// significant increase in memory consumption and disk space.
// See https://github.com/spf13/cobra/issues/2015
s := &strings.Builder{}
s.WriteString(cmd.short)
s.WriteString("\n\n")
s.WriteString("Usage: ")
s.WriteString(cmd.name)

// TODO: Check here if there are subcommands (when we get to adding those)
// Yes -> "Usage: {name} [COMMAND]"
// No -> "Usage: {name} [OPTIONS] ARGS..."
// See if we can be clever about dynamically generating the syntax for e.g. variadic args
// required args etc.

// If the user defined some examples, show those
if len(cmd.example) != 0 {
s.WriteString("\n\nExamples:\n\n")
for _, example := range cmd.example {
s.WriteString(example.String())
}
}

// Now we'd be onto subcommands... haven't got those yet

// Now options
s.WriteString("\n\nOptions:\n")
s.WriteString(cmd.Flags().FlagUsages())

// Subcommand help
s.WriteString("\n")
s.WriteString(`Use "`)
s.WriteString(cmd.name)
s.WriteString(" [command] -h/--help")
s.WriteString(`" `)
s.WriteString("for more information about a command.")
s.WriteString("\n")

fmt.Fprint(cmd.Stderr(), s.String())

return nil
}
49 changes: 49 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func TestExecute(t *testing.T) {
cli.Args([]string{"arg1", "arg2", "--force"}),
),
customiser: func(t *testing.T, cmd *cli.Command) {
// Set flags in the customiser
t.Helper()
cmd.Flags().BoolP("force", "f", false, "Force something")
},
Expand Down Expand Up @@ -85,6 +86,54 @@ func TestExecute(t *testing.T) {
test.WantErr(t, err, tt.wantErr)

test.Equal(t, stdout.String(), tt.stdout)
test.Equal(t, stderr.String(), tt.stderr)
})
}
}

func TestHelp(t *testing.T) {
tests := []struct {
cmd *cli.Command // The command under test
name string // Identifier of the test case
golden string // The name of the file relative to testdata containing to expected output
wantErr bool // Whether we want an error
}{
{
name: "default",
cmd: cli.New(
"test",
cli.Args([]string{"--help"}),
),
golden: "default-help.txt",
wantErr: false,
},
{
name: "default short",
cmd: cli.New(
"test",
cli.Args([]string{"-h"}),
),
golden: "default-help.txt",
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stderr := &bytes.Buffer{}
stdout := &bytes.Buffer{}

cli.Stderr(stderr)(tt.cmd)
cli.Stdout(stdout)(tt.cmd)

err := tt.cmd.Execute()
test.WantErr(t, err, tt.wantErr)

// Should have no output to stdout
test.Equal(t, stdout.String(), "")

// --help output should be as per the golden file
test.File(t, stderr.String(), tt.golden)
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/FollowTheProcess/cli
go 1.22

require (
github.com/FollowTheProcess/test v0.9.0
github.com/FollowTheProcess/test v0.10.1
github.com/spf13/pflag v1.0.5
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/FollowTheProcess/test v0.9.0 h1:X7/cqUi6qwbB8Z8PGHI5Hk4RJJF/wRZ+3Mko3QlL/VU=
github.com/FollowTheProcess/test v0.9.0/go.mod h1:9oxZcKkTAgz3bZMiHPtYCytdPcvICS+AAp5mzZzB2oA=
github.com/FollowTheProcess/test v0.10.1 h1:0L9ci7g5tTrz0iOYLNytSWPJnSbMOOeGBmCbg3ei3us=
github.com/FollowTheProcess/test v0.10.1/go.mod h1:oIqlUoS8wKFmKBFBMJH/+asP7lQXE2D3YFhmUEubTUw=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
Expand Down
13 changes: 13 additions & 0 deletions option.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func Examples(examples ...Example) Option {
}

// Run is an [Option] that sets the run function for a [Command].
//
// The run function is the actual implementation of your command i.e. what you
// want it to do when invoked.
func Run(run func(cmd *Command, args []string) error) Option {
return func(cmd *Command) {
cmd.run = run
Expand All @@ -62,3 +65,13 @@ func Args(args []string) Option {
cmd.args = args
}
}

// HelpFunc is an [Option] that allows for a custom implementation of the -h/--help flag.
//
// A [Command] will have a default implementation of this function that prints a default
// format of the help to [os.Stderr].
func HelpFunc(fn func(cmd *Command) error) Option {
return func(cmd *Command) {
cmd.helpFunc = fn
}
}
8 changes: 8 additions & 0 deletions testdata/default-help.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
A placeholder for something cool

Usage: test

Options:
-h, --help Show help for test

Use "test [command] -h/--help" for more information about a command.

0 comments on commit d601ed9

Please sign in to comment.