Skip to content

Commit

Permalink
Add the ability to name positional arguments (#100)
Browse files Browse the repository at this point in the history
* Add named arguments to the command

* Add named arguments to the help

* Add tests for the named args help

* Improve documentation on Arg option

* Remove colon from args help
  • Loading branch information
FollowTheProcess authored Sep 27, 2024
1 parent 2782dc3 commit 0ff93a9
Show file tree
Hide file tree
Showing 13 changed files with 422 additions and 71 deletions.
8 changes: 8 additions & 0 deletions args.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,11 @@ func Combine(validators ...ArgValidator) ArgValidator {
return nil
}
}

// positionalArg is a named positional argument to a command.
type positionalArg struct {
name string // The name of the argument
description string // A short description of the argument
value string // The actual parsed value from the command line
defaultValue string // The default value to be used if not set, a default of "" marks the arg as required
}
170 changes: 147 additions & 23 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ type Command struct {
// examples is examples of how to use the command.
examples []example

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

// positionalArgs are the named positional arguments to the command, positional arguments
// may be retrieved from within command logic by name and this also significantly
// enhances the help message.
positionalArgs []positionalArg

// subcommands is the list of subcommands this command has directly underneath it,
// these may have any number of subcommands under them, this is how we form nested
// command structures.
Expand Down Expand Up @@ -246,6 +251,31 @@ func (c *Command) Execute() error {
return err
}

// Now we have the actual positional arguments to the command, we can use our
// named arguments to assign the given values (or the defaults) to the arguments
// so they may be retrieved by name.
//
// We're modifying the slice in place here, hence not using a range loop as it
// would take a copy of the c.positionalArgs slice
for i := 0; i < len(c.positionalArgs); i++ {
if i >= len(argsWithoutFlags) {
arg := c.positionalArgs[i]

// If we've fallen off the end of argsWithoutFlags and the positionalArg at this
// index does not have a default, it means the arg was required but not provided
if arg.defaultValue == "" {
return fmt.Errorf("missing required argument %q, expected at position %d", arg.name, i)
}
// It does have a default, so use that instead
c.positionalArgs[i].value = arg.defaultValue
} else {
// We are in a valid index in both slices which means the named positional
// argument at this index was provided on the command line, so all we need
// to do is set its value
c.positionalArgs[i].value = argsWithoutFlags[i]
}
}

// If the command is runnable, go and execute its run function
if cmd.run != nil {
return cmd.run(cmd, argsWithoutFlags)
Expand Down Expand Up @@ -287,6 +317,24 @@ func (c *Command) Stdin() io.Reader {
return c.root().stdin
}

// Arg looks up a named positional argument by name.
//
// If the argument was defined with a default, and it was not provided on the command line
// then the value returned will be the default value.
//
// If no named argument exists with the given name, it will return "".
func (c *Command) Arg(name string) string {
for _, arg := range c.positionalArgs {
if arg.name == name {
// arg.value will have been set to the default already during command line parsing
// if the arg was not provided
return arg.value
}
}

return ""
}

// ExtraArgs returns any additional arguments following a "--", and a boolean indicating
// whether or not they were present. This is useful for when you want to implement argument
// pass through in your commands.
Expand Down Expand Up @@ -471,39 +519,43 @@ func defaultHelp(cmd *Command) error {
if len(cmd.subcommands) == 0 {
// We don't have any subcommands so usage will be:
// "Usage: {name} [OPTIONS] ARGS..."
s.WriteString(" [OPTIONS] ARGS...")
s.WriteString(" [OPTIONS] ")

if len(cmd.positionalArgs) > 0 {
// If we have named args, use the names in the help text
writePositionalArgs(cmd, s)
} else {
// We have no named arguments so do the best we can
s.WriteString("ARGS...")
}
} else {
// We do have subcommands, so usage will instead be:
// "Usage: {name} [OPTIONS] COMMAND"
s.WriteString(" [OPTIONS] COMMAND")
}

// If we have named arguments, list them explicitly and use their descriptions
if len(cmd.positionalArgs) != 0 {
if err := writeArgumentsSection(cmd, s); err != nil {
return err
}
}

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

// Now show subcommands
if len(cmd.subcommands) != 0 {
s.WriteString("\n\n")
s.WriteString(colour.Title("Commands:"))
s.WriteString("\n")
tab := table.New(s)
for _, subcommand := range cmd.subcommands {
tab.Row(" %s\t%s\n", colour.Bold(subcommand.name), subcommand.short)
}
if err := tab.Flush(); err != nil {
return fmt.Errorf("could not format subcommands: %w", err)
if err := writeSubcommands(cmd, s); err != nil {
return err
}
}

// Now options
if len(cmd.examples) != 0 || len(cmd.subcommands) != 0 {
// If there were examples or subcommands, the last one would have printed a newline
if len(cmd.examples) != 0 || len(cmd.subcommands) != 0 || len(cmd.positionalArgs) != 0 {
// If there were examples or subcommands or named arguments, the last one would have printed a newline
s.WriteString("\n")
} else {
// If there weren't, we need some more space
Expand All @@ -514,17 +566,89 @@ func defaultHelp(cmd *Command) error {
s.WriteString(usage)

// Subcommand help
if len(cmd.subcommands) != 0 {
writeFooter(cmd, s)
}

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

return nil
}

// writePositionalArgs writes any positional arguments in the correct
// format for the top level usage string in the help text string builder.
func writePositionalArgs(cmd *Command, s *strings.Builder) {
for _, arg := range cmd.positionalArgs {
if arg.defaultValue != "" {
s.WriteString(strings.ToUpper(arg.name))
} else {
s.WriteString("[")
s.WriteString(strings.ToUpper(arg.name))
s.WriteString("]")
}
s.WriteString(" ")
}
}

// writeArgumentsSection writes the entire positional arguments block to the help
// text string builder.
func writeArgumentsSection(cmd *Command, s *strings.Builder) error {
s.WriteString("\n\n")
s.WriteString(colour.Title("Arguments:"))
s.WriteString("\n")
tab := table.New(s)
for _, arg := range cmd.positionalArgs {
if arg.defaultValue != "" {
tab.Row(" %s\t%s [default %s]\n", colour.Bold(arg.name), arg.description, arg.defaultValue)
} else {
tab.Row(" %s\t%s\n", colour.Bold(arg.name), arg.description)
}
}
if err := tab.Flush(); err != nil {
return fmt.Errorf("could not format arguments: %w", err)
}
return nil
}

// writeExamples writes the examples block to the help text string builder.
func writeExamples(cmd *Command, s *strings.Builder) {
// If there were positional args, the last one would have printed a newline
if len(cmd.positionalArgs) != 0 {
s.WriteString("\n")
} else {
// If not, we need a bit more space
s.WriteString("\n\n")
}
s.WriteString(colour.Title("Examples:"))
for _, example := range cmd.examples {
s.WriteString(example.String())
}
}

// writeSubcommands writes the subcommand block to the help text string builder.
func writeSubcommands(cmd *Command, s *strings.Builder) error {
s.WriteString("\n\n")
s.WriteString(colour.Title("Commands:"))
s.WriteString("\n")
tab := table.New(s)
for _, subcommand := range cmd.subcommands {
tab.Row(" %s\t%s\n", colour.Bold(subcommand.name), subcommand.short)
}
if err := tab.Flush(); err != nil {
return fmt.Errorf("could not format subcommands: %w", err)
}
return nil
}

// writeFooter writes the footer to the help text string builder.
func writeFooter(cmd *Command, s *strings.Builder) {
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
}

// defaultVersion is the default for a command's versionFunc.
Expand Down
134 changes: 132 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,126 @@ func TestSubCommandExecute(t *testing.T) {
}
}

func TestPositionalArgs(t *testing.T) {
tests := []struct {
name string // The name of the test case
stdout string // The expected stdout
errMsg string // If we did want an error, what should it say
options []cli.Option // Options to apply to the command under test
args []string // Arguments to be passed to the command
wantErr bool // Whether we want an error
}{
{
name: "required and given",
options: []cli.Option{
cli.Arg("file", "The path to a file", ""), // "" means required
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "file was %s\n", cmd.Arg("file"))
return nil
}),
},
stdout: "file was something.txt\n",
args: []string{"something.txt"},
wantErr: false,
},
{
name: "required but missing",
options: []cli.Option{
cli.Arg("file", "The path to a file", ""), // "" means required
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "file was %s\n", cmd.Arg("file"))
return nil
}),
},
stdout: "",
args: []string{},
wantErr: true,
errMsg: `missing required argument "file", expected at position 0`, // Comes from command.Execute
},
{
name: "optional and given",
options: []cli.Option{
cli.Arg("file", "The path to a file", "default.txt"), // This time it has a default
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "file was %s\n", cmd.Arg("file"))
return nil
}),
},
stdout: "file was something.txt\n",
args: []string{"something.txt"},
wantErr: false,
},
{
name: "optional and missing",
options: []cli.Option{
cli.Arg("file", "The path to a file", "default.txt"), // This time it has a default
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "file was %s\n", cmd.Arg("file"))
return nil
}),
},
stdout: "file was default.txt\n", // Should fall back to the default
args: []string{},
wantErr: false,
},
{
name: "several args all given",
options: []cli.Option{
cli.Arg("src", "The path to the source file", ""), // File required as first arg
cli.Arg("dest", "The destination path", "dest.txt"), // Dest has a default
cli.Arg("something", "Another arg", ""), // Required again
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "src: %s, dest: %s, something: %s\n", cmd.Arg("src"), cmd.Arg("dest"), cmd.Arg("something"))
return nil
}),
},
stdout: "src: src.txt, dest: other-dest.txt, something: yes\n",
args: []string{"src.txt", "other-dest.txt", "yes"}, // Give all 3 args
wantErr: false,
},
{
name: "several args one missing",
options: []cli.Option{
cli.Arg("src", "The path to the source file", ""), // File required as first arg
cli.Arg("dest", "The destination path", "default-dest.txt"), // Dest has a default
cli.Arg("something", "Another arg", ""), // Required again
cli.Run(func(cmd *cli.Command, args []string) error {
fmt.Fprintf(cmd.Stdout(), "src: %s, dest: %s, something: %s\n", cmd.Arg("src"), cmd.Arg("dest"), cmd.Arg("something"))
return nil
}),
},
stdout: "",
args: []string{"src.txt"}, // arg 'something' is missing, dest will use its default
wantErr: true,
errMsg: `missing required argument "something", expected at position 2`,
},
}

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

// Test specific overrides to the options in the table
options := []cli.Option{
cli.Stdout(stdout),
cli.OverrideArgs(tt.args),
}

cmd, err := cli.New("posargs", slices.Concat(options, tt.options)...)
test.Ok(t, err) // cli.New returned an error

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

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

if err != nil {
test.Equal(t, err.Error(), tt.errMsg) // Error messages don't match
}
})
}
}

func TestHelp(t *testing.T) {
sub1 := func() (*cli.Command, error) {
return cli.New(
Expand Down Expand Up @@ -295,6 +415,17 @@ func TestHelp(t *testing.T) {
golden: "with-examples.txt",
wantErr: false,
},
{
name: "with named arguments",
options: []cli.Option{
cli.OverrideArgs([]string{"--help"}),
cli.Arg("src", "The file to copy", ""), // This one is required
cli.Arg("dest", "Destination to copy to", "./dest"), // This one is optional
cli.Run(func(cmd *cli.Command, args []string) error { return nil }),
},
golden: "with-named-arguments.txt",
wantErr: false,
},
{
name: "with full description",
options: []cli.Option{
Expand Down Expand Up @@ -364,9 +495,8 @@ func TestHelp(t *testing.T) {

// Test specific overrides to the options in the table
options := []cli.Option{cli.Stdout(stdout), cli.Stderr(stderr)}
options = append(options, tt.options...)

cmd, err := cli.New("test", options...)
cmd, err := cli.New("test", slices.Concat(options, tt.options)...)

test.Ok(t, err)

Expand Down
Binary file modified docs/img/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/quickstart.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 0ff93a9

Please sign in to comment.