diff --git a/README.md b/README.md index e49971d..0bd327f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Tiny, simple, but powerful CLI framework for modern Go 🚀 - [Commands](#commands) - [Sub Commands](#sub-commands) - [Flags](#flags) + - [Arguments](#arguments) - [Core Principles](#core-principles) - [😱 Well behaved libraries don't panic](#-well-behaved-libraries-dont-panic) - [🧘🏻 Keep it Simple](#-keep-it-simple) @@ -64,6 +65,7 @@ func main() { func run() error { var count int + cmd, err := cli.New( "quickstart", cli.Short("Short description of your command"), @@ -71,12 +73,14 @@ func run() error { cli.Version("v1.2.3"), cli.Commit("7bcac896d5ab67edc5b58632c821ec67251da3b8"), cli.BuildDate("2024-08-17T10:37:30Z"), - cli.Allow(cli.MinArgs(1)), // Must have at least one argument cli.Stdout(os.Stdout), cli.Example("Do a thing", "quickstart something"), cli.Example("Count the things", "quickstart something --count 3"), cli.Flag(&count, "count", 'c', 0, "Count the things"), - cli.Run(runQuickstart(&count)), + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "Hello from quickstart!, my args were: %v, count was %d\n", cmd.Args(), count) + return nil + }), ) if err != nil { return err @@ -84,13 +88,6 @@ func run() error { return cmd.Execute() } - -func runQuickstart(count *int) func(cmd *cli.Command, args []string) error { - return func(cmd *cli.Command, args []string) error { - fmt.Fprintf(cmd.Stdout(), "Hello from quickstart!, my args were: %v, count was %d\n", args, *count) - return nil - } -} ``` Will get you the following: @@ -215,6 +212,80 @@ The types you can use for flags currently are: > [!NOTE] > You basically can't get this wrong, if you try and use an unsupported type, the Go compiler will yell at you +### Arguments + +There are two approaches to positional arguments in `cli`, you can either just get the raw arguments yourself with `cmd.Args()` and do whatever you want with them: + +```go +cli.New( + "my-command", + // ... + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "Hello! My arguments were: %v\n", cmd.Args()) + return nil + }) +) +``` + +This will return a `[]string` containing all the positional arguments to your command (not flags, they've already been parsed elsewhere!) + +Or, if you want to get smarter 🧠 `cli` allows you to define *type safe* representations of your arguments, with or without default values! This follows a similar +idea to [Flags](#flags) + +That works like this: + +```go +// Define a struct to hold your arguments +type myArgs struct { + name string + age int + employed bool +} + +// Instantiate it +var args myArgs + +// Tell cli about your arguments with the Arg option +cli.New( + "my-command", + // ... other options here + cli.Arg(&args.name, "name", "The name of a person"), + cli.Arg(&args.age, "age", "How old the person is"), + cli.Arg(&args.employed, "employed", "Whether they are employed", cli.ArgDefault(true)) +) +``` + +And just like [Flags](#flags), your argument types are all inferred and parsed automatically ✨, and you get nicer `--help` output too! Have a look at the [`./examples`](https://github.com/FollowTheProcess/cli/tree/main/examples) +to see more! + +> [!NOTE] +> Just like flags, you can't really get this wrong. The types you can use for arguments are part of a generic constraint so using the wrong type results in a compiler error + +The types you can currently use for positional args are: + +- `int` +- `int8` +- `int16` +- `int32` +- `int64` +- `uint` +- `uint8` +- `uint16` +- `uint32` +- `uint64` +- `uintptr` +- `float32` +- `float64` +- `string` +- `bool` +- `[]byte` (interpreted as a hex string) +- `time.Time` +- `time.Duration` +- `net.IP` + +> [!WARNING] +> Slice types are not supported (yet), for those you need to use the `cmd.Args()` method to get the arguments manually. I'm working on this! + ## Core Principles When designing and implementing `cli`, I had some core goals and guiding principles for implementation. @@ -269,7 +340,6 @@ cmd, err := cli.New( cli.Short("Short description of your command"), cli.Long("Much longer text..."), cli.Version("v1.2.3"), - cli.Allow(cli.MinArgs(1)), cli.Stdout(os.Stdout), cli.Example("Do a thing", "test run thing --now"), cli.Flag(&count, "count", 'c', 0, "Count the things"), @@ -319,8 +389,8 @@ I built `cli` for my own uses really, so I've quickly adopted it across a number - - -- - +- [spf13/cobra]: https://github.com/spf13/cobra [spf13/pflag]: https://github.com/spf13/pflag diff --git a/arg/arg.go b/arg/arg.go new file mode 100644 index 0000000..5eecee4 --- /dev/null +++ b/arg/arg.go @@ -0,0 +1,32 @@ +// Package arg provides mechanisms for defining and configuring command line arguments. +package arg + +import ( + "net" + "time" +) + +// TODO(@FollowTheProcess): Slices of stuff + +// Argable is a type constraint that defines any type capable of being parsed as a command line arg. +type Argable interface { + int | + int8 | + int16 | + int32 | + int64 | + uint | + uint8 | + uint16 | + uint32 | + uint64 | + uintptr | + float32 | + float64 | + string | + bool | + []byte | + time.Time | + time.Duration | + net.IP +} diff --git a/args.go b/args.go deleted file mode 100644 index 74f8581..0000000 --- a/args.go +++ /dev/null @@ -1,160 +0,0 @@ -package cli - -import ( - "fmt" - "slices" -) - -// ArgValidator is a function responsible for validating the provided positional arguments -// to a [Command]. -// -// An ArgValidator should return an error if it thinks the arguments are not valid. -type ArgValidator func(cmd *Command, args []string) error - -// AnyArgs is a positional argument validator that allows any arbitrary args, -// it never returns an error. -// -// This is the default argument validator on a [Command] instantiated with cli.New. -func AnyArgs() ArgValidator { - return func(_ *Command, _ []string) error { - return nil - } -} - -// NoArgs is a positional argument validator that does not allow any arguments, -// it returns an error if there are any arguments. -func NoArgs() ArgValidator { - return func(cmd *Command, args []string) error { - if len(args) > 0 { - if len(cmd.subcommands) > 0 { - // Maybe it's a typo of a subcommand - return fmt.Errorf( - "unknown subcommand %q for command %q, available subcommands: %v", - args[0], - cmd.name, - cmd.subcommandNames(), - ) - } - - return fmt.Errorf("command %s accepts no arguments but got %v", cmd.name, args) - } - - return nil - } -} - -// MinArgs is a positional argument validator that requires at least n arguments. -func MinArgs(n int) ArgValidator { - return func(cmd *Command, args []string) error { - if len(args) < n { - return fmt.Errorf( - "command %s requires at least %d arguments, but got %d: %v", - cmd.name, - n, - len(args), - args, - ) - } - - return nil - } -} - -// MaxArgs is a positional argument validator that returns an error if there are more than n arguments. -func MaxArgs(n int) ArgValidator { - return func(cmd *Command, args []string) error { - if len(args) > n { - return fmt.Errorf( - "command %s has a limit of %d argument(s), but got %d: %v", - cmd.name, - n, - len(args), - args, - ) - } - - return nil - } -} - -// ExactArgs is a positional argument validator that allows exactly n args, any more -// or less will return an error. -func ExactArgs(n int) ArgValidator { - return func(cmd *Command, args []string) error { - if len(args) != n { - return fmt.Errorf( - "command %s requires exactly %d arguments, but got %d: %v", - cmd.name, - n, - len(args), - args, - ) - } - - return nil - } -} - -// BetweenArgs is a positional argument validator that allows between min and max arguments (inclusive), -// any outside that range will return an error. -// -//nolint:predeclared // min has same name as min function but we don't use it here and the clarity is worth it -func BetweenArgs(min, max int) ArgValidator { - return func(cmd *Command, args []string) error { - nArgs := len(args) - if nArgs < min || nArgs > max { - return fmt.Errorf( - "command %s requires between %d and %d arguments, but got %d: %v", - cmd.name, - min, - max, - nArgs, - args, - ) - } - - return nil - } -} - -// ValidArgs is a positional argument validator that only allows arguments that are contained in -// the valid slice. If any non-valid arguments are seen, an error will be returned. -func ValidArgs(valid []string) ArgValidator { - return func(cmd *Command, args []string) error { - for _, arg := range args { - if !slices.Contains(valid, arg) { - return fmt.Errorf( - "command %s got an invalid argument %s, expected one of %v", - cmd.name, - arg, - valid, - ) - } - } - - return nil - } -} - -// Combine allows multiple positional argument validators to be composed together. -// -// The first validator to fail will be the one that returns the error. -func Combine(validators ...ArgValidator) ArgValidator { - return func(cmd *Command, args []string) error { - for _, validator := range validators { - if err := validator(cmd, args); err != nil { - return err - } - } - - 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, only set by the OptionalArg option -} diff --git a/args_test.go b/args_test.go deleted file mode 100644 index 7ac5cb7..0000000 --- a/args_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package cli_test - -import ( - "bytes" - "fmt" - "slices" - "testing" - - "go.followtheprocess.codes/cli" - "go.followtheprocess.codes/test" -) - -func TestArgValidators(t *testing.T) { - tests := []struct { - name string // Identifier of the test case - stdout string // Desired output to stdout - stderr string // Desired output to stderr - errMsg string // If we wanted an error, what should it say - options []cli.Option // Options to apply to the command - wantErr bool // Whether we want an error - }{ - { - name: "anyargs", - options: []cli.Option{ - cli.OverrideArgs([]string{"some", "args", "here"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from anyargs") - - return nil - }), - cli.Allow(cli.AnyArgs()), - }, - wantErr: false, - stdout: "Hello from anyargs\n", - }, - { - name: "noargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from noargs") - - return nil - }), - cli.Allow(cli.NoArgs()), - }, - wantErr: false, - stdout: "Hello from noargs\n", - }, - { - name: "noargs fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"some", "args", "here"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from noargs") - - return nil - }), - cli.Allow(cli.NoArgs()), - }, - wantErr: true, - errMsg: "command test accepts no arguments but got [some args here]", - }, - { - name: "noargs subcommand", - options: []cli.Option{ - cli.OverrideArgs([]string{"subb", "args", "here"}), // Note: subb is typo of sub - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from noargs") - - return nil - }), - cli.Allow(cli.NoArgs()), - cli.SubCommands( - func() (*cli.Command, error) { - return cli.New( - "sub", - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), - ) - }, - ), - }, - wantErr: true, - errMsg: `unknown subcommand "subb" for command "test", available subcommands: [sub]`, - }, - { - name: "minargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"loads", "more", "args", "here"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from minargs") - - return nil - }), - cli.Allow(cli.MinArgs(3)), - }, - wantErr: false, - stdout: "Hello from minargs\n", - }, - { - name: "minargs fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"only", "two"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from minargs") - - return nil - }), - cli.Allow(cli.MinArgs(3)), - }, - wantErr: true, - errMsg: "command test requires at least 3 arguments, but got 2: [only two]", - }, - { - name: "maxargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"two", "args"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from maxargs") - - return nil - }), - cli.Allow(cli.MaxArgs(2)), - }, - wantErr: false, - stdout: "Hello from maxargs\n", - }, - { - name: "maxargs fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"loads", "of", "args", "here", "wow", "so", "many"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from maxargs") - - return nil - }), - cli.Allow(cli.MaxArgs(3)), - }, - wantErr: true, - errMsg: "command test has a limit of 3 argument(s), but got 7: [loads of args here wow so many]", - }, - { - name: "exactargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"two", "args"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from exactargs") - - return nil - }), - cli.Allow(cli.ExactArgs(2)), - }, - wantErr: false, - stdout: "Hello from exactargs\n", - }, - { - name: "exactargs fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"not", "three", "but", "four"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from exactargs") - - return nil - }), - cli.Allow(cli.ExactArgs(3)), - }, - wantErr: true, - errMsg: "command test requires exactly 3 arguments, but got 4: [not three but four]", - }, - { - name: "betweenargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"two", "args"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from betweenargs") - - return nil - }), - cli.Allow(cli.BetweenArgs(1, 4)), - }, - wantErr: false, - stdout: "Hello from betweenargs\n", - }, - { - name: "betweenargs fail high", - options: []cli.Option{ - cli.OverrideArgs([]string{"not", "three", "but", "more", "than", "four"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from betweenargs") - - return nil - }), - cli.Allow(cli.BetweenArgs(1, 4)), - }, - wantErr: true, - errMsg: "command test requires between 1 and 4 arguments, but got 6: [not three but more than four]", - }, - { - name: "betweenargs fail low", - options: []cli.Option{ - cli.OverrideArgs([]string{"not", "three"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from betweenargs") - - return nil - }), - cli.Allow(cli.BetweenArgs(3, 5)), - }, - wantErr: true, - errMsg: "command test requires between 3 and 5 arguments, but got 2: [not three]", - }, - { - name: "validargs pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"valid", "args", "only"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from validargs") - - return nil - }), - cli.Allow(cli.ValidArgs([]string{"only", "valid", "args"})), // Order doesn't matter - }, - - wantErr: false, - stdout: "Hello from validargs\n", - }, - { - name: "validargs fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"valid", "args", "only", "bad"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from validargs") - - return nil - }), - cli.Allow(cli.ValidArgs([]string{"only", "valid", "args"})), - }, - wantErr: true, - errMsg: "command test got an invalid argument bad, expected one of [only valid args]", - }, - { - name: "combine pass", - options: []cli.Option{ - cli.OverrideArgs([]string{"four", "args", "all", "valid"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from combine") - - return nil - }), - cli.Allow( - cli.Combine( - cli.ExactArgs(4), - cli.ValidArgs([]string{"valid", "all", "four", "args"}), - ), - ), - }, - wantErr: false, - stdout: "Hello from combine\n", - }, - { - name: "combine fail", - options: []cli.Option{ - cli.OverrideArgs([]string{"valid", "args", "only", "bad", "five"}), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from combine") - - return nil - }), - cli.Allow( - cli.Combine( - cli.BetweenArgs(1, 4), - cli.ValidArgs([]string{"only", "valid", "args", "here"}), - ), - ), - }, - wantErr: true, - errMsg: "command test requires between 1 and 4 arguments, but got 5: [valid args only bad five]", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stderr := &bytes.Buffer{} - stdout := &bytes.Buffer{} - - // Test specific overrides to the options in the table - options := []cli.Option{cli.Stdout(stdout), cli.Stderr(stderr)} - - cmd, err := cli.New("test", slices.Concat(tt.options, options)...) - test.Ok(t, err) - - err = cmd.Execute() - test.WantErr(t, err, tt.wantErr) - - if tt.wantErr { - test.Equal(t, err.Error(), tt.errMsg) - } - - test.Equal(t, stdout.String(), tt.stdout) - test.Equal(t, stderr.String(), tt.stderr) - }) - } -} diff --git a/command.go b/command.go index 4a03d62..9cc5fc2 100644 --- a/command.go +++ b/command.go @@ -10,6 +10,7 @@ import ( "strings" "unicode/utf8" + "go.followtheprocess.codes/cli/internal/arg" "go.followtheprocess.codes/cli/internal/flag" "go.followtheprocess.codes/cli/internal/style" @@ -47,15 +48,14 @@ func New(name string, options ...Option) (*Command, error) { // Default implementation cfg := config{ - flags: flag.NewSet(), - stdin: os.Stdin, - stdout: os.Stdout, - stderr: os.Stderr, - args: os.Args[1:], - name: name, - version: defaultVersion, - short: defaultShort, - argValidator: AnyArgs(), + flags: flag.NewSet(), + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + rawArgs: os.Args[1:], + name: name, + version: defaultVersion, + short: defaultShort, } // Apply the options, gathering up all the validation errors @@ -116,9 +116,9 @@ type Command struct { // It defaults to [os.Stderr] but can be overridden as desired e.g. for testing. stderr io.Writer - // 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. - run func(cmd *Command, args []string) error + // run is the function actually implementing the command, the command is passed into the function for access + // to things like cmd.Stdout(). + run func(cmd *Command) error // flags is the set of flags for this command. flags *flag.Set @@ -127,11 +127,6 @@ type Command struct { // and has no parent, this will be nil. parent *Command - // argValidator is a function that gets called to validate the positional arguments - // to the command. It defaults to allowing arbitrary arguments, can be overridden using - // the [AllowArgs] option. - argValidator ArgValidator - // name is the name of the command. name string @@ -153,15 +148,13 @@ type Command struct { // examples is examples of how to use the command. examples []example - // args are the raw arguments passed to the command prior to any parsing, defaulting to [os.Args] + // rawArgs 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 [OverrideArgs] option for e.g. testing. - args []string + rawArgs []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 + // args are the command line arguments declared by the user using the [cli.Args] option. + args []arg.Value // 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 @@ -214,7 +207,7 @@ func (cmd *Command) Execute() error { // we should be invoking and swap that into 'cmd'. // // Slightly magical trick but it simplifies a lot of stuff below. - cmd, args := findRequestedCommand(cmd, cmd.args) + cmd, args := findRequestedCommand(cmd, cmd.rawArgs) if err := cmd.flagSet().Parse(args); err != nil { return fmt.Errorf("failed to parse command flags: %w", err) @@ -252,40 +245,34 @@ func (cmd *Command) Execute() error { return nil } - // Validate the arguments using the command's allowedArgs function - argsWithoutFlags := cmd.flagSet().Args() - if err := cmd.argValidator(cmd, argsWithoutFlags); err != nil { - return err + nonExtraArgs := cmd.flagSet().Args() + terminatorIndex := slices.Index(nonExtraArgs, "--") + + if terminatorIndex != -1 { + nonExtraArgs = nonExtraArgs[:terminatorIndex] } - // 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 := range len(cmd.positionalArgs) { - if i >= len(argsWithoutFlags) { - arg := cmd.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 == requiredArgMarker { - return fmt.Errorf("missing required argument %q, expected at position %d", arg.name, i) - } - // It does have a default, so use that instead - cmd.positionalArgs[i].value = arg.defaultValue + for i, argument := range cmd.args { + var str string + // The argument has been provided + if len(nonExtraArgs) > i { + str = nonExtraArgs[i] } 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 - cmd.positionalArgs[i].value = argsWithoutFlags[i] + // It hasn't, use the default + str = argument.Default() + if str == "" { + return fmt.Errorf("argument %q is required and no value was provided", argument.Name()) + } + } + + if err := argument.Set(str); err != nil { + return fmt.Errorf("could not parse argument %q from provided input %q: %w", argument.Name(), str, err) } } // If the command is runnable, go and execute its run function if cmd.run != nil { - return cmd.run(cmd, argsWithoutFlags) + return cmd.run(cmd) } // The only way we get here is if the command has subcommands defined but got no arguments given to it @@ -312,22 +299,9 @@ func (cmd *Command) Stdin() io.Reader { return cmd.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 (cmd *Command) Arg(name string) string { - for _, arg := range cmd.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 "" +// Args returns the positional arguments passed to the command. +func (cmd *Command) Args() []string { + return cmd.flagSet().Args() } // ExtraArgs returns any additional arguments following a "--", and a boolean indicating @@ -394,16 +368,6 @@ func (cmd *Command) hasShortFlag(name string) bool { return flag.NoArgValue() != "" } -// subcommandNames returns a list of all the names of the current command's registered subcommands. -func (cmd *Command) subcommandNames() []string { - names := make([]string, 0, len(cmd.subcommands)) - for _, sub := range cmd.subcommands { - names = append(names, sub.name) - } - - return names -} - // findRequestedCommand uses the raw arguments and the command tree to determine what // (if any) subcommand is being requested and return that command along with the arguments // that were meant for it. @@ -537,16 +501,14 @@ func showHelp(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] ") + s.WriteString(" [OPTIONS]") - if len(cmd.positionalArgs) > 0 { + if len(cmd.args) > 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 - // TODO(@FollowTheProcess): Can we detect if cli.NoArgs was used in which case - // omit this - s.WriteString("ARGS...") + // Otherwise, the command accepts arbitrary arguments + s.WriteString(" ARGS...") } } else { // We do have subcommands, so usage will instead be: @@ -554,8 +516,8 @@ func showHelp(cmd *Command) error { s.WriteString(" [OPTIONS] COMMAND") } - // If we have named arguments, list them explicitly and use their descriptions - if len(cmd.positionalArgs) != 0 { + // If we have defined, list them explicitly and use their descriptions + if len(cmd.args) != 0 { if err := writeArgumentsSection(cmd, s); err != nil { return err } @@ -574,7 +536,7 @@ func showHelp(cmd *Command) error { } // Now options - if len(cmd.examples) != 0 || len(cmd.subcommands) != 0 || len(cmd.positionalArgs) != 0 { + if len(cmd.examples) != 0 || len(cmd.subcommands) != 0 || len(cmd.args) != 0 { // If there were examples or subcommands or named arguments, the last one would have printed a newline s.WriteString("\n") } else { @@ -601,21 +563,20 @@ func showHelp(cmd *Command) error { // 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 { - displayName := strings.ToUpper(arg.name) + for _, arg := range cmd.args { + s.WriteString(" ") + + displayName := strings.ToUpper(arg.Name()) - if arg.defaultValue != requiredArgMarker { - // If it has a default, it's an optional argument so wrap it - // in brackets e.g. [FILE] + if arg.Default() != "" { + // It has a default so is not required s.WriteString("[") s.WriteString(displayName) s.WriteString("]") } else { - // It's required, so just FILE + // It is required s.WriteString(displayName) } - - s.WriteString(" ") } } @@ -627,14 +588,14 @@ func writeArgumentsSection(cmd *Command, s *strings.Builder) error { s.WriteString(":\n\n") tw := tabwriter.NewWriter(s, style.MinWidth, style.TabWidth, style.Padding, style.PadChar, style.Flags) - for _, arg := range cmd.positionalArgs { - switch arg.defaultValue { - case requiredArgMarker: - fmt.Fprintf(tw, " %s\t%s\t[required]\n", style.Bold.Text(arg.name), arg.description) + for _, arg := range cmd.args { + switch arg.Default() { case "": - fmt.Fprintf(tw, " %s\t%s\t[default %q]\n", style.Bold.Text(arg.name), arg.description, arg.defaultValue) + // It's required + fmt.Fprintf(tw, " %s\t%s\t%s\t[required]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) default: - fmt.Fprintf(tw, " %s\t%s\t[default %s]\n", style.Bold.Text(arg.name), arg.description, arg.defaultValue) + // It has a default + fmt.Fprintf(tw, " %s\t%s\t%s\t[default %s]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), arg.Default()) } } @@ -648,7 +609,7 @@ func writeArgumentsSection(cmd *Command, s *strings.Builder) error { // 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 { + if len(cmd.args) != 0 { s.WriteString("\n") } else { // If not, we need a bit more space diff --git a/command_test.go b/command_test.go index 1157dd7..8092b4e 100644 --- a/command_test.go +++ b/command_test.go @@ -35,6 +35,8 @@ func TestExecute(t *testing.T) { stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"hello", "there"}), + cli.Arg(new(string), "first", "The first word"), // Expect the positional arguments + cli.Arg(new(string), "second", "The second word"), cli.Stdin(os.Stdin), // Set stdin for the lols }, wantErr: false, @@ -45,6 +47,8 @@ func TestExecute(t *testing.T) { stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"hello", "there", "--force"}), + cli.Arg(new(string), "first", "The first word"), // Expect the positional arguments + cli.Arg(new(string), "second", "The second word"), }, wantErr: false, }, @@ -54,6 +58,9 @@ func TestExecute(t *testing.T) { stderr: "", options: []cli.Option{ cli.OverrideArgs([]string{"arg1", "arg2", "arg3", "-]force"}), + cli.Arg(new(string), "first", "The first arg"), // Expect the positional arguments + cli.Arg(new(string), "second", "The second arg"), + cli.Arg(new(string), "third", "The third arg"), }, wantErr: true, }, @@ -70,8 +77,8 @@ func TestExecute(t *testing.T) { options := []cli.Option{ cli.Stdout(stdout), cli.Stderr(stderr), - cli.Run(func(cmd *cli.Command, args []string) error { - fmt.Fprintf(cmd.Stdout(), "My arguments were: %v\nForce was: %v\n", args, force) + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "My arguments were: %v\nForce was: %v\n", cmd.Args(), force) return nil }), @@ -92,32 +99,49 @@ func TestExecute(t *testing.T) { func TestSubCommandExecute(t *testing.T) { tests := []struct { - name string // Test case name - stdout string // Expected stdout - stderr string // Expected stderr - args []string // Args passed to root command - extra []string // Extra args after "--" if present - wantErr bool // Whether or not we wanted an error + name string // Test case name + stdout string // Expected stdout + stderr string // Expected stderr + args []string // Args passed to root command + extra []string // Extra args after "--" if present + sub1Options []cli.Option // Options for subcommand 1 + sub2Options []cli.Option // Options for subcommand 1 + wantErr bool // Whether or not we wanted an error }{ { - name: "invoke sub1 no flags", - stdout: "Hello from sub1, my args were: [my subcommand args], force was false, something was , extra args: []", - stderr: "", - args: []string{"sub1", "my", "subcommand", "args"}, + name: "invoke sub1 no flags", + stdout: "Hello from sub1, my args were: [my subcommand args], force was false, something was , extra args: []", + stderr: "", + args: []string{"sub1", "my", "subcommand", "args"}, + sub1Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + }, wantErr: false, }, { - name: "invoke sub2 no flags", - stdout: "Hello from sub2, my args were: [my different args], delete was false, number was -1", - stderr: "", - args: []string{"sub2", "my", "different", "args"}, + name: "invoke sub2 no flags", + stdout: "Hello from sub2, my args were: [my different args], delete was false, number was -1", + stderr: "", + args: []string{"sub2", "my", "different", "args"}, + sub2Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + }, wantErr: false, }, { - name: "invoke sub1 with flags", - stdout: "Hello from sub1, my args were: [my subcommand args], force was true, something was here, extra args: []", - stderr: "", - args: []string{"sub1", "my", "subcommand", "args", "--force", "--something", "here"}, + name: "invoke sub1 with flags", + stdout: "Hello from sub1, my args were: [my subcommand args], force was true, something was here, extra args: []", + stderr: "", + args: []string{"sub1", "my", "subcommand", "args", "--force", "--something", "here"}, + sub1Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + }, wantErr: false, }, { @@ -137,6 +161,14 @@ func TestSubCommandExecute(t *testing.T) { "args", "here", }, + sub1Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + cli.Arg(new(string), "four", "Fourth arg"), + cli.Arg(new(string), "five", "Fifth arg"), + cli.Arg(new(string), "six", "Sixth arg"), + }, wantErr: false, }, { @@ -156,6 +188,15 @@ func TestSubCommandExecute(t *testing.T) { "args", "here", }, + sub1Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + cli.Arg(new(string), "four", "Fourth arg"), + cli.Arg(new(string), "five", "Fifth arg"), + cli.Arg(new(string), "six", "Sixth arg"), + cli.Arg(new(string), "seven", "Seventh arg"), + }, wantErr: false, }, { @@ -174,6 +215,14 @@ func TestSubCommandExecute(t *testing.T) { "args", "here", }, + sub1Options: []cli.Option{ + cli.Arg(new(string), "one", "First arg"), + cli.Arg(new(string), "two", "Second arg"), + cli.Arg(new(string), "three", "Third arg"), + cli.Arg(new(string), "four", "Fourth arg"), + cli.Arg(new(string), "five", "Fifth arg"), + cli.Arg(new(string), "six", "Sixth arg"), + }, wantErr: false, }, { @@ -197,9 +246,10 @@ func TestSubCommandExecute(t *testing.T) { ) sub1 := func() (*cli.Command, error) { - return cli.New( - "sub1", - cli.Run(func(cmd *cli.Command, args []string) error { + defaultOpts := []cli.Option{ + cli.Flag(&force, "force", 'f', false, "Force for sub1"), + cli.Flag(&something, "something", 's', "", "Something for sub1"), + cli.Run(func(cmd *cli.Command) error { if something == "" { something = "" } @@ -212,7 +262,7 @@ func TestSubCommandExecute(t *testing.T) { fmt.Fprintf( cmd.Stdout(), "Hello from sub1, my args were: %v, force was %v, something was %s, extra args: %v", - args, + cmd.Args(), force, something, extra, @@ -220,20 +270,22 @@ func TestSubCommandExecute(t *testing.T) { return nil }), + } + opts := slices.Concat(defaultOpts, tt.sub1Options) - cli.Flag(&force, "force", 'f', false, "Force for sub1"), - cli.Flag(&something, "something", 's', "", "Something for sub1"), + return cli.New( + "sub1", + opts..., ) } sub2 := func() (*cli.Command, error) { - return cli.New( - "sub2", - cli.Run(func(cmd *cli.Command, args []string) error { + defaultOpts := []cli.Option{ + cli.Run(func(cmd *cli.Command) error { fmt.Fprintf( cmd.Stdout(), "Hello from sub2, my args were: %v, delete was %v, number was %d", - args, + cmd.Args(), deleteMe, number, ) @@ -242,6 +294,13 @@ func TestSubCommandExecute(t *testing.T) { }), cli.Flag(&deleteMe, "delete", 'd', false, "Delete for sub2"), cli.Flag(&number, "number", 'n', -1, "Number for sub2"), + } + + opts := slices.Concat(defaultOpts, tt.sub2Options) + + return cli.New( + "sub2", + opts..., ) } @@ -267,200 +326,12 @@ func TestSubCommandExecute(t *testing.T) { } } -func TestPositionalArgs(t *testing.T) { - sub := func() (*cli.Command, error) { - return cli.New( - "sub", - cli.Short("Sub command"), - cli.RequiredArg("subarg", "Argument given to a subcommand"), - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintf(cmd.Stdout(), "Hello from sub command, subarg: %s", cmd.Arg("subarg")) - - return nil - }), - ) - } - - 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.RequiredArg("file", "The path to a file"), - cli.Run(func(cmd *cli.Command, _ []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.RequiredArg("file", "The path to a file"), - cli.Run(func(cmd *cli.Command, _ []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.OptionalArg("file", "The path to a file", "default.txt"), // This time it has a default - cli.Run(func(cmd *cli.Command, _ []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 given with empty string default", - options: []cli.Option{ - cli.OptionalArg("file", "The path to a file", ""), // Default is empty string - cli.Run(func(cmd *cli.Command, _ []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 missing with empty string default", - options: []cli.Option{ - cli.OptionalArg("file", "The path to a file", ""), // Default is empty string - cli.Run(func(cmd *cli.Command, _ []string) error { - fmt.Fprintf(cmd.Stdout(), "file was %s\n", cmd.Arg("file")) - - return nil - }), - }, - stdout: "file was \n", // Empty string - args: []string{}, - wantErr: false, - }, - { - name: "optional and missing", - options: []cli.Option{ - cli.OptionalArg("file", "The path to a file", "default.txt"), // This time it has a default - cli.Run(func(cmd *cli.Command, _ []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.RequiredArg("src", "The path to the source file"), // File required as first arg - cli.OptionalArg("dest", "The destination path", "dest.txt"), // Dest has a default - cli.RequiredArg("something", "Another arg"), // Required again - cli.Run(func(cmd *cli.Command, _ []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.RequiredArg("src", "The path to the source file"), // File required as first arg - cli.OptionalArg("dest", "The destination path", "default-dest.txt"), // Dest has a default - cli.RequiredArg("something", "Another arg"), // Required again - cli.Run(func(cmd *cli.Command, _ []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`, - }, - { - name: "subcommand with named arg", - options: []cli.Option{ - cli.SubCommands(sub), - }, - stdout: "Hello from sub command, subarg: blah", - args: []string{"sub", "blah"}, // subarg should be "blah" - wantErr: false, - }, - } - - 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( "sub1", cli.Short("Do one thing"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1") return nil @@ -471,7 +342,7 @@ func TestHelp(t *testing.T) { return cli.New( "sub2", cli.Short("Do another thing"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2") return nil @@ -483,7 +354,7 @@ func TestHelp(t *testing.T) { return cli.New( "very-long-subcommand", cli.Short("Wow so long"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub3") return nil @@ -500,7 +371,7 @@ func TestHelp(t *testing.T) { name: "default long", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -508,7 +379,7 @@ func TestHelp(t *testing.T) { name: "default short", options: []cli.Option{ cli.OverrideArgs([]string{"-h"}), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -518,7 +389,7 @@ func TestHelp(t *testing.T) { cli.OverrideArgs([]string{"--help"}), cli.Example("Do a thing", "test do thing --now"), cli.Example("Do a different thing", "test do thing --different"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -526,10 +397,10 @@ func TestHelp(t *testing.T) { name: "with named arguments", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), - cli.RequiredArg("src", "The file to copy"), // This one is required - cli.OptionalArg("dest", "Destination to copy to", "./dest"), // This one is optional - cli.OptionalArg("other", "Something else", ""), // This is optional but default is empty - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Arg(new(string), "src", "The file to copy"), // This one is required + cli.Arg(new(string), "dest", "Destination to copy to", cli.ArgDefault("default.txt")), // This one is optional + cli.Arg(new(int), "other", "Something else", cli.ArgDefault(0)), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -537,10 +408,10 @@ func TestHelp(t *testing.T) { name: "with verbosity count", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), - cli.RequiredArg("src", "The file to copy"), // This one is required - cli.OptionalArg("dest", "Destination to copy to", "./dest"), // This one is optional + cli.Arg(new(string), "src", "The file to copy"), // This one is required + cli.Arg(new(string), "dest", "Destination to copy to", cli.ArgDefault("destination.txt")), // This one is optional cli.Flag(new(flag.Count), "verbosity", 'v', 0, "Increase the verbosity level"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -550,7 +421,7 @@ func TestHelp(t *testing.T) { cli.OverrideArgs([]string{"--help"}), cli.Short("A cool CLI to do things"), cli.Long("A longer, probably multiline description"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -560,7 +431,7 @@ func TestHelp(t *testing.T) { cli.OverrideArgs([]string{"--help"}), cli.Short(" \t\n A cool CLI to do things \n "), cli.Long(" \t\n\n A longer, probably multiline description \t\n\n "), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -568,7 +439,7 @@ func TestHelp(t *testing.T) { name: "with no description", options: []cli.Option{ cli.OverrideArgs([]string{"--help"}), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, wantErr: false, }, @@ -648,7 +519,7 @@ func TestVersion(t *testing.T) { "sub1", cli.Short("Do one thing"), // No version set on sub1 - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1") return nil @@ -660,7 +531,7 @@ func TestVersion(t *testing.T) { "sub2", cli.Short("Do another thing"), cli.Version("sub2 version text"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2") return nil @@ -678,7 +549,7 @@ func TestVersion(t *testing.T) { name: "default long", options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\n", wantErr: false, @@ -687,7 +558,7 @@ func TestVersion(t *testing.T) { name: "default short", options: []cli.Option{ cli.OverrideArgs([]string{"-V"}), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\n", wantErr: false, @@ -697,7 +568,7 @@ func TestVersion(t *testing.T) { options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Version("v3.1.7"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v3.1.7\n", wantErr: false, @@ -707,7 +578,7 @@ func TestVersion(t *testing.T) { options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.Commit("eedb45b"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\nCommit: eedb45b\n", wantErr: false, @@ -717,7 +588,7 @@ func TestVersion(t *testing.T) { options: []cli.Option{ cli.OverrideArgs([]string{"--version"}), cli.BuildDate("2024-04-11T02:23:42Z"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: dev\nBuildDate: 2024-04-11T02:23:42Z\n", wantErr: false, @@ -728,7 +599,7 @@ func TestVersion(t *testing.T) { cli.OverrideArgs([]string{"--version"}), cli.Version("v8.17.6"), cli.Commit("b9aaafd"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v8.17.6\nCommit: b9aaafd\n", wantErr: false, @@ -740,7 +611,7 @@ func TestVersion(t *testing.T) { cli.Version("v8.17.6"), cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, stderr: "version-test\n\nVersion: v8.17.6\nCommit: b9aaafd\nBuildDate: 2024-08-17T10:37:30Z\n", wantErr: false, @@ -753,7 +624,7 @@ func TestVersion(t *testing.T) { cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), cli.SubCommands(sub1, sub2), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, // Should show the root commands version info stderr: "sub1\n\nVersion: v8.17.6\nCommit: b9aaafd\nBuildDate: 2024-08-17T10:37:30Z\n", @@ -767,7 +638,7 @@ func TestVersion(t *testing.T) { cli.Commit("b9aaafd"), cli.BuildDate("2024-08-17T10:37:30Z"), cli.SubCommands(sub1, sub2), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), }, // Should show sub2's version text stderr: "sub2\n\nVersion: sub2 version text\n", @@ -838,11 +709,6 @@ func TestOptionValidation(t *testing.T) { options: []cli.Option{cli.Run(nil)}, errMsg: "cannot set Run to nil", }, - { - name: "nil ArgValidator", - options: []cli.Option{cli.Allow(nil)}, - errMsg: "cannot set Allow to a nil ArgValidator", - }, { name: "flag already exists", options: []cli.Option{ @@ -880,24 +746,14 @@ func TestOptionValidation(t *testing.T) { errMsg: "cannot set command long description to an empty string", }, { - name: "empty required arg name", - options: []cli.Option{cli.RequiredArg("", "empty required arg")}, - errMsg: "invalid name for positional argument, must be non-empty string", - }, - { - name: "empty required arg description", - options: []cli.Option{cli.RequiredArg("name", "")}, - errMsg: "invalid description for positional argument, must be non-empty string", - }, - { - name: "empty optional arg name", - options: []cli.Option{cli.OptionalArg("", "empty required arg", "")}, - errMsg: "invalid name for positional argument, must be non-empty string", + name: "empty arg name", + options: []cli.Option{cli.Arg(new(string), "", "empty required arg")}, + errMsg: `invalid arg name "": must not be empty`, }, { - name: "empty optional arg description", - options: []cli.Option{cli.OptionalArg("name", "", "")}, - errMsg: "invalid description for positional argument, must be non-empty string", + name: "arg name contains whitespace", + options: []cli.Option{cli.Arg(new(string), "a space", "some space things")}, + errMsg: `invalid arg name "a space": cannot contain whitespace`, }, } @@ -914,21 +770,21 @@ func TestDuplicateSubCommands(t *testing.T) { sub1 := func() (*cli.Command, error) { return cli.New( "sub1", - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), ) } sub2 := func() (*cli.Command, error) { return cli.New( "sub2", - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), ) } sub1Again := func() (*cli.Command, error) { return cli.New( "sub1", - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), ) } @@ -982,7 +838,7 @@ func TestCommandOptionOrder(t *testing.T) { sub := func() (*cli.Command, error) { return cli.New( "sub", - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub") return nil @@ -995,13 +851,15 @@ func TestCommandOptionOrder(t *testing.T) { cli.Short("Short description"), cli.Long("Long description"), cli.Example("Do a thing", "demo run something --flag"), - cli.Run(func(cmd *cli.Command, args []string) error { - fmt.Fprintf(cmd.Stdout(), "args: %v, flag: %v, count: %v\n", args, f, count) + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "args: %v, flag: %v, count: %v\n", cmd.Args(), f, count) return nil }), + cli.Arg(new(string), "first", "First arg"), // It just needs *some* args + cli.Arg(new(string), "second", "Second arg"), + cli.Arg(new(string), "third", "Third arg"), cli.Version("v1.2.3"), - cli.Allow(cli.AnyArgs()), cli.SubCommands(sub), cli.Flag(&f, "flag", 'f', false, "Set a bool flag"), cli.Flag(&count, "count", 'c', 0, "Count a thing"), @@ -1059,7 +917,7 @@ func BenchmarkExecuteHelp(b *testing.B) { return cli.New( "sub1", cli.Short("Do one thing"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub1") return nil @@ -1071,7 +929,7 @@ func BenchmarkExecuteHelp(b *testing.B) { return cli.New( "sub2", cli.Short("Do another thing"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub2") return nil @@ -1083,7 +941,7 @@ func BenchmarkExecuteHelp(b *testing.B) { return cli.New( "very-long-subcommand", cli.Short("Wow so long"), - cli.Run(func(cmd *cli.Command, _ []string) error { + cli.Run(func(cmd *cli.Command) error { fmt.Fprintln(cmd.Stdout(), "Hello from sub3") return nil @@ -1120,11 +978,10 @@ func BenchmarkNew(b *testing.B) { cli.Version("dev"), cli.Commit("dfdddaf"), cli.Example("An example", "bench --help"), - cli.Allow(cli.AnyArgs()), cli.Flag(new(bool), "force", 'f', false, "Force something"), cli.Flag(new(string), "name", 'n', "", "The name of something"), cli.Flag(new(int), "count", 'c', 1, "Count something"), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), ) if err != nil { b.Fatal(err) @@ -1142,7 +999,7 @@ func BenchmarkVersion(b *testing.B) { cli.OverrideArgs([]string{"--version"}), cli.Stderr(io.Discard), cli.Stdout(io.Discard), - cli.Run(func(cmd *cli.Command, args []string) error { return nil }), + cli.Run(func(cmd *cli.Command) error { return nil }), ) test.Ok(b, err) diff --git a/docs/img/demo.png b/docs/img/demo.png index 461267b..0f99c2a 100644 Binary files a/docs/img/demo.png and b/docs/img/demo.png differ diff --git a/docs/img/namedargs.gif b/docs/img/namedargs.gif index 61a5b51..90779f9 100644 Binary files a/docs/img/namedargs.gif and b/docs/img/namedargs.gif differ diff --git a/docs/img/quickstart.gif b/docs/img/quickstart.gif index 98bdcc4..3c28898 100644 Binary files a/docs/img/quickstart.gif and b/docs/img/quickstart.gif differ diff --git a/docs/img/subcommands.gif b/docs/img/subcommands.gif index c1c247b..9388947 100644 Binary files a/docs/img/subcommands.gif and b/docs/img/subcommands.gif differ diff --git a/examples/cover/main.go b/examples/cover/main.go index 9979fb1..c5b94a1 100644 --- a/examples/cover/main.go +++ b/examples/cover/main.go @@ -17,12 +17,11 @@ func main() { cli.Short("Short description of your command"), cli.Long("Much longer text..."), cli.Version("v1.2.3"), - cli.Allow(cli.MinArgs(1)), cli.Stdout(os.Stdout), cli.Example("Do a thing", "demo thing --count"), cli.Flag(&count, "count", 'c', 0, "Count the thing"), - cli.Run(func(cmd *cli.Command, args []string) error { - fmt.Fprintln(cmd.Stdout(), "Hello from demo") + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintln(cmd.Stdout(), "Hello from demo, my arguments were: ", cmd.Args()) return nil }), ) diff --git a/examples/namedargs/main.go b/examples/namedargs/main.go index af97455..7dd5415 100644 --- a/examples/namedargs/main.go +++ b/examples/namedargs/main.go @@ -15,19 +15,26 @@ func main() { } } +type myArgs struct { + src string + dest string +} + func run() error { + var arguments myArgs + cmd, err := cli.New( "copy", // A fictional copy command cli.Short("Copy a file from a src to a destination"), - cli.RequiredArg( - "src", - "The file to copy from", - ), // src is required, failure to provide it will error - cli.OptionalArg("dest", "The destination to copy to", "./dest"), // dest has a default if not provided cli.Stdout(os.Stdout), + cli.Arg(&arguments.src, "src", "The file to copy from"), + cli.Arg(&arguments.dest, "dest", "The file to copy to", cli.ArgDefault("dest.txt")), cli.Example("Copy a file to somewhere", "copy src.txt ./some/where/else"), cli.Example("Use the default destination", "copy src.txt"), - cli.Run(runCopy()), + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "Copying from %s to %s\n", arguments.src, arguments.dest) + return nil + }), ) if err != nil { return err @@ -35,13 +42,3 @@ func run() error { return cmd.Execute() } - -func runCopy() func(cmd *cli.Command, args []string) error { - return func(cmd *cli.Command, args []string) error { - // src is required so if not provided will be an error - // is dest is provided cmd.Arg("dest") will retrieve the value - // if it's not provided, cmd.Arg("dest") will return the default of "./dest" - fmt.Fprintf(cmd.Stdout(), "Copying from %s to %s\n", cmd.Arg("src"), cmd.Arg("dest")) - return nil - } -} diff --git a/examples/quickstart/main.go b/examples/quickstart/main.go index 42341f3..f166f9e 100644 --- a/examples/quickstart/main.go +++ b/examples/quickstart/main.go @@ -25,12 +25,14 @@ func run() error { cli.Version("v1.2.3"), cli.Commit("7bcac896d5ab67edc5b58632c821ec67251da3b8"), cli.BuildDate("2024-08-17T10:37:30Z"), - cli.Allow(cli.MinArgs(1)), // Must have at least one argument cli.Stdout(os.Stdout), cli.Example("Do a thing", "quickstart something"), cli.Example("Count the things", "quickstart something --count 3"), cli.Flag(&count, "count", 'c', 0, "Count the things"), - cli.Run(runQuickstart(&count)), + cli.Run(func(cmd *cli.Command) error { + fmt.Fprintf(cmd.Stdout(), "Hello from quickstart!, my args were: %v, count was %d\n", cmd.Args(), count) + return nil + }), ) if err != nil { return err @@ -38,10 +40,3 @@ func run() error { return cmd.Execute() } - -func runQuickstart(count *int) func(cmd *cli.Command, args []string) error { - return func(cmd *cli.Command, args []string) error { - fmt.Fprintf(cmd.Stdout(), "Hello from quickstart!, my args were: %v, count was %d\n", args, *count) - return nil - } -} diff --git a/examples/subcommands/cli.go b/examples/subcommands/cli.go index fb7549c..452b390 100644 --- a/examples/subcommands/cli.go +++ b/examples/subcommands/cli.go @@ -18,7 +18,6 @@ func BuildCLI() (*cli.Command, error) { cli.BuildDate("2024-08-17T10:37:30Z"), cli.Example("A basic subcommand", "demo say hello world"), cli.Example("Can do things", "demo do something --count 3"), - cli.Allow(cli.NoArgs()), cli.SubCommands(buildSayCommand, buildDoCommand), ) } @@ -38,11 +37,32 @@ func buildSayCommand() (*cli.Command, error) { cli.Short("Print a message"), cli.Example("Say a well known phrase", "demo say hello world"), cli.Example("Now louder", "demo say hello world --shout"), - cli.Run(runSay(&options)), cli.Flag(&options.shout, "shout", 's', false, "Say the message louder"), cli.Flag(&options.count, "count", 'c', 0, "Count the things"), cli.Flag(&options.thing, "thing", 't', "", "Name of the thing"), cli.Flag(&options.items, "item", 'i', nil, "Items to add to a list"), + cli.Run(func(cmd *cli.Command) error { + if options.shout { + for _, arg := range cmd.Args() { + fmt.Fprintln(cmd.Stdout(), strings.ToUpper(arg), " ") + } + } else { + for _, arg := range cmd.Args() { + fmt.Fprintln(cmd.Stdout(), arg, " ") + } + } + + fmt.Printf( + "Shout: %v\nCount: %v\nThing: %v\nItems: %v\n", + options.shout, + options.count, + options.thing, + options.items, + ) + fmt.Fprintln(cmd.Stdout()) + + return nil + }), ) } @@ -56,6 +76,8 @@ type doOptions struct { func buildDoCommand() (*cli.Command, error) { var options doOptions + var thing string + return cli.New( "do", cli.Short("Do a thing"), @@ -63,56 +85,27 @@ func buildDoCommand() (*cli.Command, error) { cli.Example("Do it 3 times", "demo do something --count 3"), cli.Example("Do it for a specific duration", "demo do something --duration 1m30s"), cli.Version("do version"), - cli.Allow(cli.ExactArgs(1)), // Only allowed to do one thing + cli.Arg(&thing, "thing", "Thing to do"), cli.Flag(&options.count, "count", 'c', 1, "Number of times to do the thing"), cli.Flag(&options.fast, "fast", 'f', false, "Do the thing quickly"), cli.Flag(&options.verbosity, "verbosity", 'v', 0, "Increase the verbosity level"), cli.Flag(&options.duration, "duration", 'd', 1*time.Second, "Do the thing for a specific duration"), - cli.Run(runDo(&options)), - ) -} - -func runSay(options *sayOptions) func(cmd *cli.Command, args []string) error { - return func(cmd *cli.Command, args []string) error { - if options.shout { - for _, arg := range args { - fmt.Fprintln(cmd.Stdout(), strings.ToUpper(arg), " ") + cli.Run(func(cmd *cli.Command) error { + if options.fast { + fmt.Fprintf( + cmd.Stdout(), + "Doing %s %d times, but faster! (will still take %v)\n", + thing, + options.count, + options.duration, + ) + } else { + fmt.Fprintf(cmd.Stdout(), "Doing %s %d times for %v\n", thing, options.count, options.duration) } - } else { - for _, arg := range args { - fmt.Fprintln(cmd.Stdout(), arg, " ") - } - } - - fmt.Printf( - "Shout: %v\nCount: %v\nThing: %v\nItems: %v\n", - options.shout, - options.count, - options.thing, - options.items, - ) - fmt.Fprintln(cmd.Stdout()) - - return nil - } -} -func runDo(options *doOptions) func(cmd *cli.Command, args []string) error { - return func(cmd *cli.Command, args []string) error { - if options.fast { - fmt.Fprintf( - cmd.Stdout(), - "Doing %s %d times, but faster! (will still take %v)\n", - args[0], - options.count, - options.duration, - ) - } else { - fmt.Fprintf(cmd.Stdout(), "Doing %s %d times for %v\n", args[0], options.count, options.duration) - } + fmt.Fprintf(cmd.Stdout(), "Verbosity level was %d\n", options.verbosity) - fmt.Fprintf(cmd.Stdout(), "Verbosity level was %d\n", options.verbosity) - - return nil - } + return nil + }), + ) } diff --git a/flag/flag.go b/flag/flag.go index 3454d4f..efbc7dd 100644 --- a/flag/flag.go +++ b/flag/flag.go @@ -6,7 +6,7 @@ import ( "time" ) -// NoShortHand should be passed as the "short" argument to [New] if the desired flag +// NoShortHand should be passed as the "short" value if the desired flag // should be the long hand version only e.g. --count, not -c/--count. const NoShortHand = rune(-1) diff --git a/internal/arg/arg.go b/internal/arg/arg.go new file mode 100644 index 0000000..f37db93 --- /dev/null +++ b/internal/arg/arg.go @@ -0,0 +1,571 @@ +// Package arg provides a command line arg definition and parsing library. +// +// Arg is intentionally internal so the only interaction is via the Arg option on a command. +package arg + +import ( + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + "unicode" + "unsafe" + + "go.followtheprocess.codes/cli/arg" + "go.followtheprocess.codes/cli/flag" + "go.followtheprocess.codes/cli/internal/constraints" +) + +// TODO(@FollowTheProcess): LOTS of duplicated stuff with internal/flag. +// Once we know this is the direction to go down, then we should combine all the shared +// stuff and use it from each package + +const ( + _ = 4 << iota // Unused + bits8 // 8 bit integer + bits16 // 16 bit integer + bits32 // 32 bit integer + bits64 // 64 bit integer +) + +const ( + typeInt = "int" + typeInt8 = "int8" + typeInt16 = "int16" + typeInt32 = "int32" + typeInt64 = "int64" + typeUint = "uint" + typeUint8 = "uint8" + typeUint16 = "uint16" + typeUint32 = "uint32" + typeUint64 = "uint64" + typeUintptr = "uintptr" + typeFloat32 = "float32" + typeFloat64 = "float64" + typeString = "string" + typeBool = "bool" + typeBytesHex = "bytesHex" + typeTime = "time" + typeDuration = "duration" + typeIP = "ip" +) + +var _ Value = Arg[string]{} // This will fail if we violate our Value interface + +// Arg represents a single command line argument. +type Arg[T arg.Argable] struct { + value *T // The actual stored value + config Config[T] // Additional configuration + name string // Name of the argument as it appears on the command line + usage string // One line description of the argument. +} + +// New constructs and returns a new [Arg]. +func New[T arg.Argable](p *T, name, usage string, config Config[T]) (Arg[T], error) { + if err := validateArgName(name); err != nil { + return Arg[T]{}, fmt.Errorf("invalid arg name %q: %w", name, err) + } + + if p == nil { + p = new(T) + } + + argument := Arg[T]{ + value: p, + name: name, + usage: usage, + config: config, + } + + return argument, nil +} + +// Name returns the name of the Arg. +func (a Arg[T]) Name() string { + return a.name +} + +// Usage returns the usage line of the Arg. +func (a Arg[T]) Usage() string { + return a.usage +} + +// Default returns the default value of the argument as a string +// or "" if the argument is required. +// +//nolint:cyclop // No other way of doing this +func (a Arg[T]) Default() string { + if a.config.DefaultValue == nil { + // DefaultValue is nil, therefore this is a required arg + return "" + } + + switch typ := any(*a.config.DefaultValue).(type) { + case int: + return formatInt(typ) + case int8: + return formatInt(typ) + case int16: + return formatInt(typ) + case int32: + return formatInt(typ) + case int64: + return formatInt(typ) + case uint: + return formatUint(typ) + case uint8: + return formatUint(typ) + case uint16: + return formatUint(typ) + case uint32: + return formatUint(typ) + case uint64: + return formatUint(typ) + case uintptr: + return formatUint(typ) + case float32: + return formatFloat[float32](bits32)(typ) + case float64: + return formatFloat[float64](bits64)(typ) + case string: + return typ + case bool: + return strconv.FormatBool(typ) + case []byte: + return hex.EncodeToString(typ) + case time.Time: + return typ.Format(time.RFC3339) + case time.Duration: + return typ.String() + case net.IP: + return typ.String() + default: + return fmt.Sprintf("Arg.String: unsupported arg type: %T", typ) + } +} + +// String returns the string representation of the current value of the arg. +// +//nolint:cyclop // No other way of doing this realistically +func (a Arg[T]) String() string { + if a.value == nil { + return "" + } + + switch typ := any(*a.value).(type) { + case int: + return formatInt(typ) + case int8: + return formatInt(typ) + case int16: + return formatInt(typ) + case int32: + return formatInt(typ) + case int64: + return formatInt(typ) + case uint: + return formatUint(typ) + case uint8: + return formatUint(typ) + case uint16: + return formatUint(typ) + case uint32: + return formatUint(typ) + case uint64: + return formatUint(typ) + case uintptr: + return formatUint(typ) + case float32: + return formatFloat[float32](bits32)(typ) + case float64: + return formatFloat[float64](bits64)(typ) + case string: + return typ + case bool: + return strconv.FormatBool(typ) + case []byte: + return hex.EncodeToString(typ) + case time.Time: + return typ.Format(time.RFC3339) + case time.Duration: + return typ.String() + case net.IP: + return typ.String() + default: + return fmt.Sprintf("Arg.String: unsupported arg type: %T", typ) + } +} + +// Type returns a string representation of the type of the Arg. +// +//nolint:cyclop // No other way of doing this realistically +func (a Arg[T]) Type() string { + if a.value == nil { + return "" + } + + switch typ := any(*a.value).(type) { + case int: + return typeInt + case int8: + return typeInt8 + case int16: + return typeInt16 + case int32: + return typeInt32 + case int64: + return typeInt64 + case uint: + return typeUint + case uint8: + return typeUint8 + case uint16: + return typeUint16 + case uint32: + return typeUint32 + case uint64: + return typeUint64 + case uintptr: + return typeUintptr + case float32: + return typeFloat32 + case float64: + return typeFloat64 + case string: + return typeString + case bool: + return typeBool + case []byte: + return typeBytesHex + case time.Time: + return typeTime + case time.Duration: + return typeDuration + case net.IP: + return typeIP + default: + return fmt.Sprintf("%T", typ) + } +} + +// Set sets an [Arg] value by parsing it's string value. +// +//nolint:cyclop // No other way of doing this realistically +func (a Arg[T]) Set(str string) error { + if a.value == nil { + return fmt.Errorf("cannot set value %s, arg.value was nil", str) + } + + switch typ := any(*a.value).(type) { + case int: + val, err := parseInt[int](0)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case int8: + val, err := parseInt[int8](bits8)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case int16: + val, err := parseInt[int16](bits16)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case int32: + val, err := parseInt[int32](bits32)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case int64: + val, err := parseInt[int64](bits64)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uint: + val, err := parseUint[uint](0)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uint8: + val, err := parseUint[uint8](bits8)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uint16: + val, err := parseUint[uint16](bits16)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uint32: + val, err := parseUint[uint32](bits32)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uint64: + val, err := parseUint[uint64](bits64)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case uintptr: + val, err := parseUint[uint64](bits64)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case float32: + val, err := parseFloat[float32](bits32)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case float64: + val, err := parseFloat[float64](bits64)(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case string: + val := str + *a.value = *cast[T](&val) + + return nil + case bool: + val, err := strconv.ParseBool(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case []byte: + val, err := hex.DecodeString(strings.TrimSpace(str)) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case time.Time: + val, err := time.Parse(time.RFC3339, str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case time.Duration: + val, err := time.ParseDuration(str) + if err != nil { + return errParse(a.name, str, typ, err) + } + + *a.value = *cast[T](&val) + + return nil + case net.IP: + val := net.ParseIP(str) + if val == nil { + return errParse(a.name, str, typ, errors.New("invalid IP address")) + } + + *a.value = *cast[T](&val) + + return nil + default: + return fmt.Errorf("Arg.Set: unsupported arg type: %T", typ) + } +} + +// validateArgName ensures an argument name is valid, returning an error if it's not. +// +// Arg names must be all lower case ASCII letters, a hyphen separator is allowed e.g. "workspace-dir" +// but this must be in between letters, not leading or trailing. +func validateArgName(name string) error { + if name == "" { + return errors.New("must not be empty") + } + + before, after, found := strings.Cut(name, "-") + + // Hyphen must be in between "words" like "set-default" + // we can't have "-default" or "default-" + if found && after == "" { + return errors.New("trailing hyphen") + } + + if found && before == "" { + return errors.New("leading hyphen") + } + + for _, char := range name { + // No whitespace + if unicode.IsSpace(char) { + return errors.New("cannot contain whitespace") + } + // Only ASCII characters allowed + if char > unicode.MaxASCII { + return fmt.Errorf("contains non ascii character: %q", string(char)) + } + // Only non-letter character allowed is a hyphen + if !unicode.IsLetter(char) && char != '-' { + return fmt.Errorf("contains non ascii letter: %q", string(char)) + } + // Any upper case letters are not allowed + if unicode.IsLetter(char) && !unicode.IsLower(char) { + return fmt.Errorf("contains upper case character %q", string(char)) + } + } + + return nil +} + +// formatInt is a generic helper to return a string representation of any signed integer. +func formatInt[T constraints.Signed](in T) string { + return strconv.FormatInt(int64(in), 10) +} + +// formatUint is a generic helper to return a string representation of any unsigned integer. +func formatUint[T constraints.Unsigned](in T) string { + return strconv.FormatUint(uint64(in), 10) +} + +// formatFloat is a generic helper to return a string representation of any floating point digit. +func formatFloat[T ~float32 | ~float64](bits int) func(T) string { + return func(in T) string { + return strconv.FormatFloat(float64(in), 'g', -1, bits) + } +} + +// cast converts a *T1 to a *T2, we use it here when we know (via generics and compile time checks) +// that e.g. the Flag.value is a string, but we can't directly do Flag.value = "value" because +// we can't assign a string to a generic 'T', but we *know* that the value *is* a string because when +// instantiating a Flag[T], you have to provide (or compiler has to infer) Flag[string]. +// +// # Safety +// +// This function uses [unsafe.Pointer] underneath to reassign the types but we know this is safe to do +// based on the compile time checks provided by generics. Further, it fits the following valid pattern +// specified in the docs for [unsafe.Pointer]. +// +// Conversion of a *T1 to Pointer to *T2 +// +// Provided that T2 is no larger than T1 and that the two share an equivalent +// memory layout, this conversion allows reinterpreting data of one type as +// data of another type. +// +// This describes our use case as we're converting a *T to e.g a *string but *only* when we know +// that a Flag[T] is actually Flag[string], so the memory layout and size is guaranteed by the +// compiler to be equivalent. +func cast[T2, T1 any](v *T1) *T2 { + return (*T2)(unsafe.Pointer(v)) +} + +// errParse is a helper to quickly return a consistent error in the face of flag +// value parsing errors. +func errParse[T flag.Flaggable](name, str string, typ T, err error) error { + return fmt.Errorf( + "arg %q received invalid value %q (expected %T), detail: %w", + name, + str, + typ, + err, + ) +} + +// parseInt is a generic helper to parse all signed integers, given a bit size. +// +// It returns the parsed value or an error. +func parseInt[T constraints.Signed](bits int) func(str string) (T, error) { + return func(str string) (T, error) { + val, err := strconv.ParseInt(str, 0, bits) + if err != nil { + return 0, err + } + + return T(val), nil + } +} + +// parseUint is a generic helper to parse all signed integers, given a bit size. +// +// It returns the parsed value or an error. +func parseUint[T constraints.Unsigned](bits int) func(str string) (T, error) { + return func(str string) (T, error) { + val, err := strconv.ParseUint(str, 0, bits) + if err != nil { + return 0, err + } + + return T(val), nil + } +} + +// parseFloat is a generic helper to parse floating point numbers, given a bit size. +// +// It returns the parsed value or an error. +func parseFloat[T ~float32 | ~float64](bits int) func(str string) (T, error) { + return func(str string) (T, error) { + val, err := strconv.ParseFloat(str, bits) + if err != nil { + return 0, err + } + + return T(val), nil + } +} diff --git a/internal/arg/arg_test.go b/internal/arg/arg_test.go new file mode 100644 index 0000000..63b9907 --- /dev/null +++ b/internal/arg/arg_test.go @@ -0,0 +1,548 @@ +package arg_test + +import ( + "bytes" + "net" + "testing" + "time" + + "go.followtheprocess.codes/cli/internal/arg" + "go.followtheprocess.codes/test" +) + +// TODO(@FollowTheProcess): Again a LOT of this is a straight copy paste from flag to get some confidence +// in the parsing. +// +// I think we should make an internal parse package or something and shunt all of this stuff in there as it's +// really only testing our Set logic. + +func TestArgableTypes(t *testing.T) { + // We can't do table testing here because Arg[T] is a different type for each test + // so we can't do a []Arg[T] which is needed to define the test cases + // so strap in for a bunch of copy pasta + t.Run("int valid", func(t *testing.T) { + var i int + + intArg, err := arg.New(&i, "int", "Set an int value", arg.Config[int]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, 42) + test.Equal(t, intArg.Type(), "int") + test.Equal(t, intArg.String(), "42") + test.Equal(t, intArg.Default(), "") + }) + + t.Run("int invalid", func(t *testing.T) { + var i int + + intArg, err := arg.New(&i, "int", "Set an int value", arg.Config[int]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "int" received invalid value "word" (expected int), detail: strconv.ParseInt: parsing "word": invalid syntax`, + ) + }) + + t.Run("int8 valid", func(t *testing.T) { + var i int8 + + intArg, err := arg.New(&i, "int", "Set an int8 value", arg.Config[int8]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, int8(42)) + test.Equal(t, intArg.Type(), "int8") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("int8 invalid", func(t *testing.T) { + var i int8 + + intArg, err := arg.New(&i, "int", "Set an int8 value", arg.Config[int8]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "int" received invalid value "word" (expected int8), detail: strconv.ParseInt: parsing "word": invalid syntax`, + ) + }) + + t.Run("int16 valid", func(t *testing.T) { + var i int16 + + intArg, err := arg.New(&i, "int", "Set an int16 value", arg.Config[int16]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, int16(42)) + test.Equal(t, intArg.Type(), "int16") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("int16 invalid", func(t *testing.T) { + var i int16 + + intArg, err := arg.New(&i, "int", "Set an int16 value", arg.Config[int16]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "int" received invalid value "word" (expected int16), detail: strconv.ParseInt: parsing "word": invalid syntax`, + ) + }) + + t.Run("int32 valid", func(t *testing.T) { + var i int32 + + intArg, err := arg.New(&i, "int", "Set an int32 value", arg.Config[int32]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, int32(42)) + test.Equal(t, intArg.Type(), "int32") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("int32 invalid", func(t *testing.T) { + var i int32 + + intArg, err := arg.New(&i, "int", "Set an int32 value", arg.Config[int32]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "int" received invalid value "word" (expected int32), detail: strconv.ParseInt: parsing "word": invalid syntax`, + ) + }) + + t.Run("int64 valid", func(t *testing.T) { + var i int64 + + intArg, err := arg.New(&i, "int", "Set an int64 value", arg.Config[int64]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, int64(42)) + test.Equal(t, intArg.Type(), "int64") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("int64 invalid", func(t *testing.T) { + var i int64 + + intArg, err := arg.New(&i, "int", "Set an int64 value", arg.Config[int64]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "int" received invalid value "word" (expected int64), detail: strconv.ParseInt: parsing "word": invalid syntax`, + ) + }) + + t.Run("uint valid", func(t *testing.T) { + var i uint + + intArg, err := arg.New(&i, "uint", "Set a uint value", arg.Config[uint]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, 42) + test.Equal(t, intArg.Type(), "uint") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uint invalid", func(t *testing.T) { + var i uint + + intArg, err := arg.New(&i, "uint", "Set a uint value", arg.Config[uint]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uint" received invalid value "word" (expected uint), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("uint8 valid", func(t *testing.T) { + var i uint8 + + intArg, err := arg.New(&i, "uint", "Set a uint8 value", arg.Config[uint8]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, uint8(42)) + test.Equal(t, intArg.Type(), "uint8") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uint8 invalid", func(t *testing.T) { + var i uint8 + + intArg, err := arg.New(&i, "uint", "Set a uint8 value", arg.Config[uint8]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uint" received invalid value "word" (expected uint8), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("uint16 valid", func(t *testing.T) { + var i uint16 + + intArg, err := arg.New(&i, "uint", "Set a uint16 value", arg.Config[uint16]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, uint16(42)) + test.Equal(t, intArg.Type(), "uint16") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uint16 invalid", func(t *testing.T) { + var i uint16 + + intArg, err := arg.New(&i, "uint", "Set a uint16 value", arg.Config[uint16]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uint" received invalid value "word" (expected uint16), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("uint32 valid", func(t *testing.T) { + var i uint32 + + intArg, err := arg.New(&i, "uint", "Set a uint32 value", arg.Config[uint32]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, uint32(42)) + test.Equal(t, intArg.Type(), "uint32") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uint32 invalid", func(t *testing.T) { + var i uint32 + + intArg, err := arg.New(&i, "uint", "Set a uint32 value", arg.Config[uint32]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uint" received invalid value "word" (expected uint32), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("uint64 valid", func(t *testing.T) { + var i uint64 + + intArg, err := arg.New(&i, "uint", "Set a uint64 value", arg.Config[uint64]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, uint64(42)) + test.Equal(t, intArg.Type(), "uint64") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uint64 invalid", func(t *testing.T) { + var i uint64 + + intArg, err := arg.New(&i, "uint", "Set a uint64 value", arg.Config[uint64]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uint" received invalid value "word" (expected uint64), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("uintptr valid", func(t *testing.T) { + var i uintptr + + intArg, err := arg.New(&i, "uintptr", "Set a uintptr value", arg.Config[uintptr]{}) + test.Ok(t, err) + + err = intArg.Set("42") + test.Ok(t, err) + test.Equal(t, i, uintptr(42)) + test.Equal(t, intArg.Type(), "uintptr") + test.Equal(t, intArg.String(), "42") + }) + + t.Run("uintptr invalid", func(t *testing.T) { + var i uintptr + + intArg, err := arg.New(&i, "uintptr", "Set a uintptr value", arg.Config[uintptr]{}) + test.Ok(t, err) + + err = intArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "uintptr" received invalid value "word" (expected uintptr), detail: strconv.ParseUint: parsing "word": invalid syntax`, + ) + }) + + t.Run("float32 valid", func(t *testing.T) { + var f float32 + + floatArg, err := arg.New(&f, "float", "Set a float32 value", arg.Config[float32]{}) + test.Ok(t, err) + + err = floatArg.Set("3.14159") + test.Ok(t, err) + test.Equal(t, f, 3.14159) + test.Equal(t, floatArg.Type(), "float32") + test.Equal(t, floatArg.String(), "3.14159") + }) + + t.Run("float32 invalid", func(t *testing.T) { + var f float32 + + floatArg, err := arg.New(&f, "float", "Set a float32 value", arg.Config[float32]{}) + test.Ok(t, err) + + err = floatArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "float" received invalid value "word" (expected float32), detail: strconv.ParseFloat: parsing "word": invalid syntax`, + ) + }) + + t.Run("float64 valid", func(t *testing.T) { + var f float64 + + floatArg, err := arg.New(&f, "float", "Set a float64 value", arg.Config[float64]{}) + test.Ok(t, err) + + err = floatArg.Set("3.14159") + test.Ok(t, err) + test.Equal(t, f, 3.14159) + test.Equal(t, floatArg.Type(), "float64") + test.Equal(t, floatArg.String(), "3.14159") + }) + + t.Run("float64 invalid", func(t *testing.T) { + var f float64 + + floatArg, err := arg.New(&f, "float", "Set a float64 value", arg.Config[float64]{}) + test.Ok(t, err) + + err = floatArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "float" received invalid value "word" (expected float64), detail: strconv.ParseFloat: parsing "word": invalid syntax`, + ) + }) + + t.Run("bool valid", func(t *testing.T) { + var b bool + + boolArg, err := arg.New(&b, "bool", "Set a bool value", arg.Config[bool]{}) + test.Ok(t, err) + + err = boolArg.Set("true") + test.Ok(t, err) + test.Equal(t, b, true) + test.Equal(t, boolArg.Type(), "bool") + test.Equal(t, boolArg.String(), "true") + }) + + t.Run("bool invalid", func(t *testing.T) { + var b bool + + boolArg, err := arg.New(&b, "bool", "Set a bool value", arg.Config[bool]{}) + test.Ok(t, err) + + err = boolArg.Set("word") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "bool" received invalid value "word" (expected bool), detail: strconv.ParseBool: parsing "word": invalid syntax`, + ) + }) + + // No invalid case as all command line args are strings anyway so no real way of + // getting an error here + t.Run("string", func(t *testing.T) { + var str string + + strArg, err := arg.New(&str, "string", "Set a string value", arg.Config[string]{}) + test.Ok(t, err) + + err = strArg.Set("newvalue") + test.Ok(t, err) + test.Equal(t, str, "newvalue") + test.Equal(t, strArg.Type(), "string") + test.Equal(t, strArg.String(), "newvalue") + }) + + t.Run("byte slice valid", func(t *testing.T) { + var byt []byte + + byteArg, err := arg.New(&byt, "byte", "Set a byte slice value", arg.Config[[]byte]{}) + test.Ok(t, err) + + err = byteArg.Set("5e") + test.Ok(t, err) + test.EqualFunc(t, byt, []byte("^"), bytes.Equal) + test.Equal(t, byteArg.Type(), "bytesHex") + test.Equal(t, byteArg.String(), "5e") + }) + + t.Run("byte slice invalid", func(t *testing.T) { + var byt []byte + + byteArg, err := arg.New(&byt, "byte", "Set a byte slice value", arg.Config[[]byte]{}) + test.Ok(t, err) + + err = byteArg.Set("0xF") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "byte" received invalid value "0xF" (expected []uint8), detail: encoding/hex: invalid byte: U+0078 'x'`, + ) + }) + + t.Run("time.Time valid", func(t *testing.T) { + var tyme time.Time + + timeArg, err := arg.New(&tyme, "time", "Set a time value", arg.Config[time.Time]{}) + test.Ok(t, err) + + err = timeArg.Set("2024-07-17T07:38:05Z") + test.Ok(t, err) + + want, err := time.Parse(time.RFC3339, "2024-07-17T07:38:05Z") + test.Ok(t, err) + test.Equal(t, tyme, want) + test.Equal(t, timeArg.Type(), "time") + test.Equal(t, timeArg.String(), "2024-07-17T07:38:05Z") + }) + + t.Run("time.Time invalid", func(t *testing.T) { + var tyme time.Time + + timeArg, err := arg.New(&tyme, "time", "Set a time value", arg.Config[time.Time]{}) + test.Ok(t, err) + + err = timeArg.Set("not a time") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "time" received invalid value "not a time" (expected time.Time), detail: parsing time "not a time" as "2006-01-02T15:04:05Z07:00": cannot parse "not a time" as "2006"`, + ) + }) + + t.Run("time.Duration valid", func(t *testing.T) { + var duration time.Duration + + durationArg, err := arg.New(&duration, "duration", "Set a duration value", arg.Config[time.Duration]{}) + test.Ok(t, err) + + err = durationArg.Set("300ms") + test.Ok(t, err) + + want, err := time.ParseDuration("300ms") + test.Ok(t, err) + test.Equal(t, duration, want) + test.Equal(t, durationArg.Type(), "duration") + test.Equal(t, durationArg.String(), "300ms") + }) + + t.Run("time.Duration invalid", func(t *testing.T) { + var duration time.Duration + + durationArg, err := arg.New(&duration, "duration", "Set a duration value", arg.Config[time.Duration]{}) + test.Ok(t, err) + + err = durationArg.Set("not a duration") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "duration" received invalid value "not a duration" (expected time.Duration), detail: time: invalid duration "not a duration"`, + ) + }) + + t.Run("ip valid", func(t *testing.T) { + var ip net.IP + + ipArg, err := arg.New(&ip, "ip", "Set an IP address", arg.Config[net.IP]{}) + test.Ok(t, err) + + err = ipArg.Set("192.0.2.1") + test.Ok(t, err) + test.DiffBytes(t, ip, net.ParseIP("192.0.2.1")) + test.Equal(t, ipArg.Type(), "ip") + test.Equal(t, ipArg.String(), "192.0.2.1") + }) + + t.Run("ip invalid", func(t *testing.T) { + var ip net.IP + + ipArg, err := arg.New(&ip, "ip", "Set an IP address", arg.Config[net.IP]{}) + test.Ok(t, err) + + err = ipArg.Set("not an ip") + test.Err(t, err) + test.Equal( + t, + err.Error(), + `arg "ip" received invalid value "not an ip" (expected net.IP), detail: invalid IP address`, + ) + }) +} diff --git a/internal/arg/config.go b/internal/arg/config.go new file mode 100644 index 0000000..83a3716 --- /dev/null +++ b/internal/arg/config.go @@ -0,0 +1,14 @@ +package arg + +import "go.followtheprocess.codes/cli/arg" + +// Config represents internal configuration of an [Arg]. +type Config[T arg.Argable] struct { + // DefaultValue holds the intended default value of the argument. + // + // If it is nil, the argument is required. + // + // A non-nil value indicates the argument is not required and if not + // provided on the command line, will assume the value DefaultValue points to. + DefaultValue *T +} diff --git a/internal/arg/value.go b/internal/arg/value.go new file mode 100644 index 0000000..4294b18 --- /dev/null +++ b/internal/arg/value.go @@ -0,0 +1,23 @@ +package arg + +// Value is an interface representing an Arg value that can be set from the command line. +type Value interface { + // Name returns the name of the argument. + Name() string + + // Usage returns the usage line for the argument. + Usage() string + + // String returns the stored value of the argument as a string. + String() string + + // Type returns the string representation of the argument type e.g. "bool". + Type() string + + // Set sets the stored value of an arg by parsing the string "str". + Set(str string) error + + // Default returns the default value as a string, or "" if the argument + // is required. + Default() string +} diff --git a/internal/constraints/constraints.go b/internal/constraints/constraints.go new file mode 100644 index 0000000..ab6c8af --- /dev/null +++ b/internal/constraints/constraints.go @@ -0,0 +1,19 @@ +// Package constraints provides generic constraints for cli. +// +// It is roughly like golang/x/exp/constraints except the types here are more strict (no ~) and +// only Signed and Unsigned are provided. +package constraints + +import "go.followtheprocess.codes/cli/flag" + +// Signed is the same as constraints.Signed but we don't have to depend +// on golang/x/exp. +type Signed interface { + int | int8 | int16 | int32 | int64 +} + +// Unsigned is the same as constraints.Unsigned (with Count mixed in) but we don't have to depend +// on golang/x/exp. +type Unsigned interface { + uint | uint8 | uint16 | uint32 | uint64 | uintptr | flag.Count +} diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 456b96a..f8aa711 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -16,6 +16,7 @@ import ( "unsafe" "go.followtheprocess.codes/cli/flag" + "go.followtheprocess.codes/cli/internal/constraints" ) const ( @@ -151,7 +152,9 @@ func (f Flag[T]) NoArgValue() string { // String implements [fmt.Stringer] for a [Flag], and also implements the String // part of [Value], allowing a flag to print itself. -func (f Flag[T]) String() string { //nolint:cyclop // No other way of doing this realistically +// +//nolint:cyclop // No other way of doing this realistically +func (f Flag[T]) String() string { if f.value == nil { return "" } @@ -683,18 +686,6 @@ func (f Flag[T]) Set(str string) error { //nolint:gocognit,cyclop // No other wa } } -// signed is the same as constraints.Signed but we don't have to depend -// on golang/x/exp. -type signed interface { - int | int8 | int16 | int32 | int64 -} - -// unsigned is the same as constraints.Unsigned (with Count mixed in) but we don't have to depend -// on golang/x/exp. -type unsigned interface { - uint | uint8 | uint16 | uint32 | uint64 | uintptr | flag.Count -} - // cast converts a *T1 to a *T2, we use it here when we know (via generics and compile time checks) // that e.g. the Flag.value is a string, but we can't directly do Flag.value = "value" because // we can't assign a string to a generic 'T', but we *know* that the value *is* a string because when @@ -816,7 +807,7 @@ func errBadType[T flag.Flaggable](value T) error { // parseInt is a generic helper to parse all signed integers, given a bit size. // // It returns the parsed value or an error. -func parseInt[T signed](bits int) func(str string) (T, error) { +func parseInt[T constraints.Signed](bits int) func(str string) (T, error) { return func(str string) (T, error) { val, err := strconv.ParseInt(str, 0, bits) if err != nil { @@ -830,7 +821,7 @@ func parseInt[T signed](bits int) func(str string) (T, error) { // parseUint is a generic helper to parse all signed integers, given a bit size. // // It returns the parsed value or an error. -func parseUint[T unsigned](bits int) func(str string) (T, error) { +func parseUint[T constraints.Unsigned](bits int) func(str string) (T, error) { return func(str string) (T, error) { val, err := strconv.ParseUint(str, 0, bits) if err != nil { @@ -856,12 +847,12 @@ func parseFloat[T ~float32 | ~float64](bits int) func(str string) (T, error) { } // formatInt is a generic helper to return a string representation of any signed integer. -func formatInt[T signed](in T) string { +func formatInt[T constraints.Signed](in T) string { return strconv.FormatInt(int64(in), 10) } // formatUint is a generic helper to return a string representation of any unsigned integer. -func formatUint[T unsigned](in T) string { +func formatUint[T constraints.Unsigned](in T) string { return strconv.FormatUint(uint64(in), 10) } diff --git a/internal/flag/set.go b/internal/flag/set.go index 790c467..c1b431f 100644 --- a/internal/flag/set.go +++ b/internal/flag/set.go @@ -130,7 +130,7 @@ func (s *Set) Version() (value, ok bool) { // following a "--" terminator. func (s *Set) Args() []string { if s == nil { - return nil + return []string{} } return s.args diff --git a/option.go b/option.go index 0935079..4f1940d 100644 --- a/option.go +++ b/option.go @@ -7,7 +7,9 @@ import ( "slices" "strings" + "go.followtheprocess.codes/cli/arg" "go.followtheprocess.codes/cli/flag" + internalarg "go.followtheprocess.codes/cli/internal/arg" internalflag "go.followtheprocess.codes/cli/internal/flag" "go.followtheprocess.codes/hue" ) @@ -28,33 +30,26 @@ func (o option) apply(cfg *config) error { return o(cfg) } -// requiredArgMarker is a special string designed to be used as the default value for -// a required positional argument. This is so we know the argument was required, but still -// permits the use of the empty string "" as a default. Without this marker, omitting the default -// value or setting it to the string zero value would accidentally mark it as required. -const requiredArgMarker = "" - // config represents the internal configuration of a [Command]. type config struct { - stdin io.Reader - stdout io.Writer - stderr io.Writer - run func(cmd *Command, args []string) error - flags *internalflag.Set - parent *Command - argValidator ArgValidator - name string - short string - long string - version string - commit string - buildDate string - examples []example - args []string - positionalArgs []positionalArg - subcommands []*Command - helpCalled bool - versionCalled bool + stdin io.Reader + stdout io.Writer + stderr io.Writer + run func(cmd *Command) error + flags *internalflag.Set + parent *Command + name string + short string + long string + version string + commit string + buildDate string + examples []example + rawArgs []string + args []internalarg.Value + subcommands []*Command + helpCalled bool + versionCalled bool } // build builds an returns a Command from the config. @@ -63,25 +58,24 @@ type config struct { // to the config, so is effectively immutable to the user. func (c *config) build() *Command { cmd := &Command{ - stdin: c.stdin, - stdout: c.stdout, - stderr: c.stderr, - run: c.run, - flags: c.flags, - parent: c.parent, - argValidator: c.argValidator, - name: c.name, - short: c.short, - long: c.long, - version: c.version, - commit: c.commit, - buildDate: c.buildDate, - examples: c.examples, - args: c.args, - positionalArgs: c.positionalArgs, - subcommands: c.subcommands, - helpCalled: c.helpCalled, - versionCalled: c.versionCalled, + stdin: c.stdin, + stdout: c.stdout, + stderr: c.stderr, + run: c.run, + flags: c.flags, + parent: c.parent, + name: c.name, + short: c.short, + long: c.long, + version: c.version, + commit: c.commit, + buildDate: c.buildDate, + examples: c.examples, + rawArgs: c.rawArgs, + args: c.args, + subcommands: c.subcommands, + helpCalled: c.helpCalled, + versionCalled: c.versionCalled, } // Loop through each subcommand and set this command as their immediate parent @@ -263,7 +257,7 @@ func Example(comment, command string) Option { // want it to do when invoked. // // Successive calls overwrite previous ones. -func Run(run func(cmd *Command, args []string) error) Option { +func Run(run func(cmd *Command) error) Option { f := func(cfg *config) error { if run == nil { return errors.New("cannot set Run to nil") @@ -293,7 +287,7 @@ func OverrideArgs(args []string) Option { return errors.New("cannot set Args to nil") } - cfg.args = args + cfg.rawArgs = args return nil } @@ -390,31 +384,8 @@ func SubCommands(builders ...Builder) Option { return option(f) } -// Allow is an [Option] that allows for validating positional arguments to a [Command]. -// -// You provide a validator function that returns an error if it encounters invalid arguments, and it will -// be run for you, passing in the non-flag arguments to the [Command] that was called. -// -// Successive calls overwrite previous ones, use [Combine] to compose multiple validators. -// -// // No positional arguments allowed -// cli.New("test", cli.Allow(cli.NoArgs())) -func Allow(validator ArgValidator) Option { - f := func(cfg *config) error { - if validator == nil { - return errors.New("cannot set Allow to a nil ArgValidator") - } - - cfg.argValidator = validator - - return nil - } - - return option(f) -} - -// Flag is an [Option] that adds a flag to a [Command], storing its value in a variable via it's -// pointer 'p'. +// Flag is an [Option] that adds a typed flag to a [Command], storing its value in a variable via its +// pointer 'target'. // // The variable is set when the flag is parsed during command execution. The value provided // by the 'value' argument to [Flag] is used as the default value, which will be used if the @@ -431,13 +402,13 @@ func Allow(validator ArgValidator) Option { // // Add a force flag // var force bool // cli.New("rm", cli.Flag(&force, "force", 'f', false, "Force deletion without confirmation")) -func Flag[T flag.Flaggable](p *T, name string, short rune, value T, usage string) Option { +func Flag[T flag.Flaggable](target *T, name string, short rune, value T, usage string) Option { f := func(cfg *config) error { if _, ok := cfg.flags.Get(name); ok { return fmt.Errorf("flag %q already defined", name) } - f, err := internalflag.New(p, name, short, value, usage) + f, err := internalflag.New(target, name, short, value, usage) if err != nil { return err } @@ -452,44 +423,38 @@ func Flag[T flag.Flaggable](p *T, name string, short rune, value T, usage string return option(f) } -// RequiredArg is an [Option] that adds a required named positional argument to a [Command]. -// -// A required named argument is given a name, and a description that will be shown in -// the help text. Failure to provide this argument on the command line when the command is -// invoked will result in an error from [Command.Execute]. +// Arg is an [Option] that adds a typed argument to a [Command], storing its value in a variable via its +// pointer 'target'. // -// The order of calls matters, each call to RequiredArg effectively appends a required, named -// positional argument to the command so the following: +// The variable is set when the argument is parsed during command execution. // -// cli.New( -// "cp", -// cli.RequiredArg("src", "The file to copy"), -// cli.RequiredArg("dest", "Where to copy to"), -// ) +// Args linked to slice values (e.g. []string) must be defined last as they eagerly consume +// all remaining command line arguments. // -// results in a command that will expect the following args *in order* +// The argument may be given a default value with the [ArgDefault] option. Without this option +// the argument will be required, i.e. failing to provide it on the command line is an error, but +// when a default is given and the value omitted on the command line, the default is used in +// its place. // -// cp src.txt dest.txt -// -// If the argument should have a default value if not specified on the command line, use [OptionalArg]. -// -// Arguments added to the command may be retrieved by name from within command logic with [Command.Arg]. -func RequiredArg(name, description string) Option { +// // Add an int arg that defaults to 1 +// var number int +// cli.New("add", cli.Arg(&number, "number", "Add a number", cli.ArgDefault(1))) +func Arg[T arg.Argable](p *T, name, usage string, options ...ArgOption[T]) Option { f := func(cfg *config) error { - if name == "" { - return errors.New("invalid name for positional argument, must be non-empty string") - } + var argCfg internalarg.Config[T] - if description == "" { - return errors.New("invalid description for positional argument, must be non-empty string") + for _, option := range options { + if err := option.apply(&argCfg); err != nil { + return fmt.Errorf("could not apply arg option: %w", err) + } } - arg := positionalArg{ - name: name, - description: description, - defaultValue: requiredArgMarker, // Internal marker + a, err := internalarg.New(p, name, usage, argCfg) + if err != nil { + return err } - cfg.positionalArgs = append(cfg.positionalArgs, arg) + + cfg.args = append(cfg.args, a) return nil } @@ -497,49 +462,20 @@ func RequiredArg(name, description string) Option { return option(f) } -// OptionalArg is an [Option] that adds a named positional argument, with a default value, to a [Command]. -// -// An optional named argument is given a name, a description, and a default value that will be shown in -// the help text. If the argument isn't given when the command is invoke, the default value is used -// in it's place. +// ArgDefault is a [cli.ArgOption] that sets the default value for a positional argument. // -// The order of calls matters, each call to OptionalArg effectively appends an optional, named -// positional argument to the command so the following: +// By default, positional arguments are required, but by providing a default value +// via this option, you mark the argument as not required. // -// cli.New( -// "cp", -// cli.OptionalArg("src", "The file to copy", "./default-src.txt"), -// cli.OptionalArg("dest", "Where to copy to", "./default-dest.txt"), -// ) -// -// results in a command that will expect the following args *in order* -// -// cp src.txt dest.txt -// -// If the argument should be required (e.g. no sensible default), use [RequiredArg]. -// -// Arguments added to the command may be retrieved by name from within command logic with [Command.Arg]. -func OptionalArg(name, description, value string) Option { - f := func(cfg *config) error { - if name == "" { - return errors.New("invalid name for positional argument, must be non-empty string") - } - - if description == "" { - return errors.New("invalid description for positional argument, must be non-empty string") - } - - arg := positionalArg{ - name: name, - description: description, - defaultValue: value, - } - cfg.positionalArgs = append(cfg.positionalArgs, arg) - +// If a default is given and the argument is not provided via the command line, the +// default is used in its place. +func ArgDefault[T arg.Argable](value T) ArgOption[T] { + f := func(cfg *internalarg.Config[T]) error { + cfg.DefaultValue = &value return nil } - return option(f) + return argOption[T](f) } // anyDuplicates checks the list of commands for ones with duplicate names, if a duplicate @@ -561,3 +497,21 @@ func anyDuplicates(cmds ...*Command) (string, bool) { return "", false } + +// ArgOption is a functional option for configuring an [Arg]. +type ArgOption[T arg.Argable] interface { + // Apply the option to the config, returning an error if the + // option cannot be applied for whatever reason. + apply(cfg *internalarg.Config[T]) error +} + +// option is a function adapter implementing the Option interface, analogous +// to http.HandlerFunc. +type argOption[T arg.Argable] func(cfg *internalarg.Config[T]) error + +// apply implements the Option interface for option. +// +//nolint:unused // This is a false positive, this has to be here +func (a argOption[T]) apply(cfg *internalarg.Config[T]) error { + return a(cfg) +} diff --git a/testdata/snapshots/TestHelp/with_named_arguments.snap.txt b/testdata/snapshots/TestHelp/with_named_arguments.snap.txt index 54056c3..c718c79 100644 --- a/testdata/snapshots/TestHelp/with_named_arguments.snap.txt +++ b/testdata/snapshots/TestHelp/with_named_arguments.snap.txt @@ -1,12 +1,12 @@ A placeholder for something cool -Usage: test [OPTIONS] SRC [DEST] [OTHER] +Usage: test [OPTIONS] SRC [DEST] [OTHER] Arguments: - src The file to copy [required] - dest Destination to copy to [default ./dest] - other Something else [default ""] + src string The file to copy [required] + dest string Destination to copy to [default default.txt] + other int Something else [default 0] Options: diff --git a/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt b/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt index 3e7807c..0a108f8 100644 --- a/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt +++ b/testdata/snapshots/TestHelp/with_verbosity_count.snap.txt @@ -1,11 +1,11 @@ A placeholder for something cool -Usage: test [OPTIONS] SRC [DEST] +Usage: test [OPTIONS] SRC [DEST] Arguments: - src The file to copy [required] - dest Destination to copy to [default ./dest] + src string The file to copy [required] + dest string Destination to copy to [default destination.txt] Options: