Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): positional argument #759

Merged
merged 11 commits into from
Mar 11, 2020
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