Skip to content

Commit

Permalink
Merge pull request urfave#1833 from dearchap/issue_1074
Browse files Browse the repository at this point in the history
Feat:(issue_1074) Add basic support for cmd args
  • Loading branch information
dearchap authored Dec 7, 2023
2 parents 962dae8 + 250dbd2 commit 2b97d2e
Show file tree
Hide file tree
Showing 9 changed files with 343 additions and 3 deletions.
89 changes: 89 additions & 0 deletions args.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package cli

import (
"fmt"
"time"
)

type Args interface {
// Get returns the nth argument, or else a blank string
Get(n int) string
Expand Down Expand Up @@ -55,3 +60,87 @@ func (a *stringSliceArgs) Slice() []string {
copy(ret, a.v)
return ret
}

type Argument interface {
Parse([]string) ([]string, error)
Usage() string
}

type ArgumentBase[T any, C any, VC ValueCreator[T, C]] struct {
Name string // the name of this argument
Value T // the default value of this argument
Destination *T // the destination point for this argument
Values *[]T // all the values of this argument, only if multiple are supported
UsageText string // the usage text to show
Min int // the min num of occurrences of this argument
Max int // the max num of occurrences of this argument, set to -1 for unlimited
Config C // config for this argument similar to Flag Config
}

func (a *ArgumentBase[T, C, VC]) Usage() string {
if a.UsageText != "" {
return a.UsageText
}

usageFormat := ""
if a.Min == 0 {
if a.Max == 1 {
usageFormat = "[%[1]s]"
} else {
usageFormat = "[%[1]s ...]"
}
} else {
usageFormat = "%[1]s [%[1]s ...]"
}
return fmt.Sprintf(usageFormat, a.Name)
}

func (a *ArgumentBase[T, C, VC]) Parse(s []string) ([]string, error) {
tracef("calling arg%[1] parse with args %[2]", &a.Name, s)
if a.Max == 0 {
fmt.Printf("WARNING args %s has max 0, not parsing argument", a.Name)
return s, nil
}
if a.Max != -1 && a.Min > a.Max {
fmt.Printf("WARNING args %s has min[%d] > max[%d], not parsing argument", a.Name, a.Min, a.Max)
return s, nil
}

count := 0
var vc VC
var t T
value := vc.Create(a.Value, &t, a.Config)
values := []T{}

for _, arg := range s {
if err := value.Set(arg); err != nil {
return s, err
}
values = append(values, value.Get().(T))
count++
if count >= a.Max {
break
}
}
if count < a.Min {
return s, fmt.Errorf("sufficient count of arg %s not provided, given %d expected %d", a.Name, count, a.Min)
}

if a.Values == nil {
a.Values = &values
} else {
*a.Values = values
}

if a.Max == 1 && a.Destination != nil {
*a.Destination = values[0]
}
return s[count:], nil
}

type FloatArg = ArgumentBase[float64, NoConfig, floatValue]
type IntArg = ArgumentBase[int64, IntegerConfig, intValue]
type StringArg = ArgumentBase[string, StringConfig, stringValue]
type StringMapArg = ArgumentBase[map[string]string, StringConfig, StringMap]
type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue]
type UintArg = ArgumentBase[uint64, IntegerConfig, uintValue]
153 changes: 153 additions & 0 deletions args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cli

import (
"context"
"errors"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestArgumentsRootCommand(t *testing.T) {

cmd := buildMinimalTestCommand()
var ival int64
var fval float64
var fvals []float64
cmd.Arguments = []Argument{
&IntArg{
Name: "ia",
Min: 1,
Max: 1,
Destination: &ival,
},
&FloatArg{
Name: "fa",
Min: 0,
Max: 2,
Destination: &fval,
Values: &fvals,
},
}

require.NoError(t, cmd.Run(context.Background(), []string{"foo", "10"}))
require.Equal(t, int64(10), ival)

require.NoError(t, cmd.Run(context.Background(), []string{"foo", "12", "10.1"}))
require.Equal(t, int64(12), ival)
require.Equal(t, []float64{10.1}, fvals)

require.NoError(t, cmd.Run(context.Background(), []string{"foo", "13", "10.1", "11.09"}))
require.Equal(t, int64(13), ival)
require.Equal(t, []float64{10.1, 11.09}, fvals)

require.Error(t, errors.New("No help topic for '12.1"), cmd.Run(context.Background(), []string{"foo", "13", "10.1", "11.09", "12.1"}))
require.Equal(t, int64(13), ival)
require.Equal(t, []float64{10.1, 11.09}, fvals)
}

func TestArgumentsSubcommand(t *testing.T) {

cmd := buildMinimalTestCommand()
var ifval int64
var svals []string
var tval time.Time
cmd.Commands = []*Command{
{
Name: "subcmd",
Flags: []Flag{
&IntFlag{
Name: "foo",
Value: 10,
Destination: &ifval,
},
},
Arguments: []Argument{
&TimestampArg{
Name: "ta",
Min: 1,
Max: 1,
Destination: &tval,
Config: TimestampConfig{
Layout: time.RFC3339,
},
},
&StringArg{
Name: "sa",
Min: 1,
Max: 3,
Values: &svals,
},
},
},
}

require.Error(t, errors.New("sufficient count of arg sa not provided, given 0 expected 1"), cmd.Run(context.Background(), []string{"foo", "subcmd", "2006-01-02T15:04:05Z"}))

require.NoError(t, cmd.Run(context.Background(), []string{"foo", "subcmd", "2006-01-02T15:04:05Z", "fubar"}))
require.Equal(t, time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC), tval)
require.Equal(t, []string{"fubar"}, svals)

require.NoError(t, cmd.Run(context.Background(), []string{"foo", "subcmd", "--foo", "100", "2006-01-02T15:04:05Z", "fubar", "some"}))
require.Equal(t, int64(100), ifval)
require.Equal(t, time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC), tval)
require.Equal(t, []string{"fubar", "some"}, svals)
}

func TestArgsUsage(t *testing.T) {
arg := &IntArg{
Name: "ia",
Min: 0,
Max: 1,
}
tests := []struct {
name string
min int
max int
expected string
}{
{
name: "optional",
min: 0,
max: 1,
expected: "[ia]",
},
{
name: "zero or more",
min: 0,
max: 2,
expected: "[ia ...]",
},
{
name: "one",
min: 1,
max: 1,
expected: "ia [ia ...]",
},
{
name: "many",
min: 2,
max: 1,
expected: "ia [ia ...]",
},
{
name: "many2",
min: 2,
max: 0,
expected: "ia [ia ...]",
},
{
name: "unlimited",
min: 2,
max: -1,
expected: "ia [ia ...]",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
arg.Min, arg.Max = test.min, test.max
require.Equal(t, test.expected, arg.Usage())
})
}
}
20 changes: 20 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ type Command struct {
SuggestCommandFunc SuggestCommandFunc
// Flag exclusion group
MutuallyExclusiveFlags []MutuallyExclusiveFlags
// Arguments to parse for this command
Arguments []Argument

// categories contains the categorized commands and is populated on app startup
categories CommandCategories
Expand All @@ -135,6 +137,8 @@ type Command struct {
parent *Command
// the flag.FlagSet for this command
flagSet *flag.FlagSet
// parsed args
parsedArgs Args
// track state of error handling
isInError bool
// track state of defaults
Expand Down Expand Up @@ -518,6 +522,18 @@ func (cmd *Command) Run(ctx context.Context, osArgs []string) (deferErr error) {

if cmd.Action == nil {
cmd.Action = helpCommandAction
} else if len(cmd.Arguments) > 0 {
rargs := cmd.Args().Slice()
tracef("calling argparse with %[1]v", rargs)
for _, arg := range cmd.Arguments {
var err error
rargs, err = arg.Parse(rargs)
if err != nil {
tracef("calling with %[1]v (cmd=%[2]q)", err, cmd.Name)
return err
}
}
cmd.parsedArgs = &stringSliceArgs{v: rargs}
}

if err := cmd.Action(ctx, cmd); err != nil {
Expand Down Expand Up @@ -607,6 +623,7 @@ func (cmd *Command) suggestFlagFromError(err error, commandName string) (string,
func (cmd *Command) parseFlags(args Args) (Args, error) {
tracef("parsing flags from arguments %[1]q (cmd=%[2]q)", args, cmd.Name)

cmd.parsedArgs = nil
if v, err := cmd.newFlagSet(); err != nil {
return args, err
} else {
Expand Down Expand Up @@ -996,6 +1013,9 @@ func (cmd *Command) Value(name string) interface{} {
// Args returns the command line arguments associated with the
// command.
func (cmd *Command) Args() Args {
if cmd.parsedArgs != nil {
return cmd.parsedArgs
}
return &stringSliceArgs{v: cmd.flagSet.Args()}
}

Expand Down
5 changes: 3 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2026,8 +2026,9 @@ func TestCommand_Run_SubcommandFullPath(t *testing.T) {
out := &bytes.Buffer{}

subCmd := &Command{
Name: "bar",
Usage: "does bar things",
Name: "bar",
Usage: "does bar things",
ArgsUsage: "[arguments...]",
}

cmd := &Command{
Expand Down
3 changes: 3 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func ExampleCommand_Run_appHelp() {
Aliases: []string{"d"},
Usage: "use it to see a description",
Description: "This is how we describe describeit the function",
ArgsUsage: "[arguments...]",
Action: func(context.Context, *cli.Command) error {
fmt.Printf("i like to describe things")
return nil
Expand Down Expand Up @@ -162,6 +163,7 @@ func ExampleCommand_Run_commandHelp() {
Aliases: []string{"d"},
Usage: "use it to see a description",
Description: "This is how we describe describeit the function",
ArgsUsage: "[arguments...]",
Action: func(context.Context, *cli.Command) error {
fmt.Println("i like to describe things")
return nil
Expand Down Expand Up @@ -220,6 +222,7 @@ func ExampleCommand_Run_subcommandNoAction() {
Name: "describeit",
Aliases: []string{"d"},
Usage: "use it to see a description",
ArgsUsage: "[arguments...]",
Description: "This is how we describe describeit the function",
},
},
Expand Down
Loading

0 comments on commit 2b97d2e

Please sign in to comment.