Skip to content

Commit

Permalink
feat(core): positional argument (#759)
Browse files Browse the repository at this point in the history
  • Loading branch information
kindermoumoute authored Mar 11, 2020
1 parent 86a8ca4 commit 2dbe16f
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 2 deletions.
20 changes: 20 additions & 0 deletions internal/core/arg_specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ import (

type ArgSpecs []*ArgSpec

func (s ArgSpecs) GetPositionalArg() *ArgSpec {
var positionalArg *ArgSpec
for _, argSpec := range s {
if argSpec.Positional {
if positionalArg != nil {
panic(fmt.Errorf("more than one positional parameter detected: %s and %s are flagged as positional arg", positionalArg.Name, argSpec.Name))
}
positionalArg = argSpec
}
}
return positionalArg
}

func (s ArgSpecs) GetByName(name string) *ArgSpec {
for _, spec := range s {
if spec.Name == name {
Expand Down Expand Up @@ -62,6 +75,13 @@ type ArgSpec struct {

// ValidateFunc validates an argument.
ValidateFunc ArgSpecValidateFunc

// Positional defines whether the argument is a positional argument. NB: a positional argument is required.
Positional bool
}

func (a *ArgSpec) Prefix() string {
return a.Name + "="
}

type DefaultFunc func() (value string, doc string)
Expand Down
3 changes: 3 additions & 0 deletions internal/core/cobra_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ func (b *cobraBuilder) hydrateCobra(cobraCmd *cobra.Command, cmd *Command) {
if cobraCmd.HasAvailableSubCommands() || len(cobraCmd.Commands()) > 0 {
cobraCmd.Annotations["CommandUsage"] += " <command>"
}
if positionalArg := cmd.ArgSpecs.GetPositionalArg(); positionalArg != nil {
cobraCmd.Annotations["CommandUsage"] += " <" + positionalArg.Name + ">"
}
if cobraCmd.HasAvailableLocalFlags() || cobraCmd.HasAvailableFlags() || cobraCmd.LocalFlags() != nil {
cobraCmd.Annotations["CommandUsage"] += " [flags]"
}
Expand Down
2 changes: 1 addition & 1 deletion internal/core/cobra_usage_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func _buildUsageArgs(w io.Writer, argSpecs ArgSpecs) error {
_, doc := argSpec.Default()
argSpecUsageLeftPart = fmt.Sprintf("%s=%s", argSpecUsageLeftPart, doc)
}
if !argSpec.Required {
if !argSpec.Required && !argSpec.Positional {
argSpecUsageLeftPart = fmt.Sprintf("[%s]", argSpecUsageLeftPart)
}

Expand Down
64 changes: 64 additions & 0 deletions internal/core/cobra_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
// unmarshalled arguments will be store in this interface
cmdArgs := reflect.New(cmd.ArgsType).Interface()

// Handle positional argument by catching first argument `<value>` and rewrite it to `<arg-name>=<value>`.
if err = handlePositionalArg(cmd, rawArgs); err != nil {
return err
}

// Apply default values on missing args.
rawArgs = ApplyDefaultValues(cmd.ArgSpecs, rawArgs)

Expand Down Expand Up @@ -78,6 +83,65 @@ func cobraRun(ctx context.Context, cmd *Command) func(*cobra.Command, []string)
}
}

// handlePositionalArg will catch positional argument if command has one.
// When a positional argument is found it will mutate its value in rawArgs to match the argument unmarshaller format.
// E.g.: '[value b=true c=1]' will be mutated to '[a=value b=true c=1]'.
// It returns errors when:
// - no positional argument is found.
// - an unknown positional argument exists in the comand.
// - an argument duplicates a positional argument.
func handlePositionalArg(cmd *Command, rawArgs []string) error {
positionalArg := cmd.ArgSpecs.GetPositionalArg()

// Command does not have a positional argument.
if positionalArg == nil {
return nil
}

// Positional argument is found condition.
positionalArgumentFound := len(rawArgs) > 0 && !strings.Contains(rawArgs[0], "=")

// Argument exists but is not positional.
for i, arg := range rawArgs {
if strings.HasPrefix(arg, positionalArg.Prefix()) {
argumentValue := strings.TrimLeft(arg, positionalArg.Prefix())
otherArgs := append(rawArgs[:i], rawArgs[i+1:]...)
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, argumentValue, otherArgs, positionalArgumentFound),
}
}
}

// If positional argument is found, prefix it with `arg-name=`.
if positionalArgumentFound {
rawArgs[0] = positionalArg.Prefix() + rawArgs[0]
return nil
}

// No positional argument found.
return &CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: positionalArgHint(cmd, "<"+positionalArg.Name+">", rawArgs, false),
}
}

// positionalArgHint formats the positional argument error hint.
func positionalArgHint(cmd *Command, hintValue string, otherArgs []string, positionalArgumentFound bool) string {
suggestedArgs := []string{}

// If no positional argument exists, suggest one.
if !positionalArgumentFound {
suggestedArgs = append(suggestedArgs, hintValue)
}

// Suggest to use the other arguments.
suggestedArgs = append(suggestedArgs, otherArgs...)

suggestedCommand := append([]string{"scw", cmd.getPath()}, suggestedArgs...)
return "Try running: " + strings.Join(suggestedCommand, " ")
}

func handleUnmarshalErrors(cmd *Command, unmarshalErr *args.UnmarshalArgError) error {
wrappedErr := errors.Unwrap(unmarshalErr)

Expand Down
104 changes: 103 additions & 1 deletion internal/core/cobra_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (
)

type testType struct {
Name string
NameID string
Tag string
}

func testGetCommands() *Commands {
Expand All @@ -25,6 +26,22 @@ func testGetCommands() *Commands {
return "", nil
},
},
&Command{
Namespace: "test-positional",
ArgSpecs: ArgSpecs{
{
Name: "name-id",
Positional: true,
},
{
Name: "tag",
},
},
ArgsType: reflect.TypeOf(testType{}),
Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
return "", nil
},
},
)
}

Expand Down Expand Up @@ -53,3 +70,88 @@ func Test_handleUnmarshalErrors(t *testing.T) {
),
}))
}

func Test_PositionalArg(t *testing.T) {
t.Run("Error", func(t *testing.T) {
t.Run("Missing1", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running: scw test-positional <name-id>",
}),
),
}))

t.Run("Missing2", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional tag=world",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running: scw test-positional <name-id> tag=world",
}),
),
}))

t.Run("Invalid1", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional name-id=plop tag=world",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: "Try running: scw test-positional plop tag=world",
}),
),
}))

t.Run("Invalid2", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional tag=world name-id=plop",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: fmt.Sprintf("Try running: scw test-positional plop tag=world"),
}),
),
}))

t.Run("Invalid3", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop name-id=plop",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Err: fmt.Errorf("a positional argument is required for this command"),
Hint: fmt.Sprintf("Try running: scw test-positional plop"),
}),
),
}))
})

t.Run("simple", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop",
Check: TestCheckExitCode(0),
}))

t.Run("full command", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional plop tag=world",
Check: TestCheckExitCode(0),
}))

t.Run("full command", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test-positional -h",
Check: TestCheckCombine(
TestCheckExitCode(0),
TestCheckGolden(),
),
}))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
USAGE:
scw [global-flags] test-positional <name-id> [flags] [arg=value ...]

ARGS:
name-id
[tag]

FLAGS:
-h, --help help for test-positional

GLOBAL FLAGS:
-D, --debug Enable debug mode
-o, --output string Output format: json or human
-p, --profile string The config profile to use

0 comments on commit 2dbe16f

Please sign in to comment.