Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ func Test_git(t *testing.T) {
}
},
},
"rest args": {
rawArgs: []string{"checkout", "some-branch", "--", "more", "tokens", "here"},
checkoutHandler: func(t *testing.T) Handler {
called := ensureCalled(t)

return func(ctx context.Context) error {
called()

rest, err := Rest(ctx)
test.NoError(t, err)
test.DeepEqual(t, rest, []string{"more", "tokens", "here"})
return nil
}
},
},
}

for name, testCase := range testCases {
Expand Down
1 change: 1 addition & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Command struct {
subCommands []*Command
flags []*Flag
arguments []*Argument
rest []string
handler Handler
}

Expand Down
18 changes: 18 additions & 0 deletions command_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "github.com/bobg/errors"
func (c *Command) validateConfig() error {
validations := []func() error{
c.validateNoDuplicateFlags,
c.validateNoDuplicateShortFlags,
c.validateNoDuplicateArguments,
c.validateNoDuplicateSubCommands,
c.validateEitherCommandsOrArguments,
Expand Down Expand Up @@ -35,6 +36,23 @@ func (c *Command) validateNoDuplicateFlags() error {
return errors.Join(errs...)
}

func (c *Command) validateNoDuplicateShortFlags() error {
flags := make(map[rune]bool)

var errs []error
for _, flag := range c.flags {
for _, short := range flag.shorts {
if flags[short] {
errs = append(errs, errors.Errorf("duplicate short flag %q", short))
Copy link

Copilot AI Apr 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for duplicate short flags does not include the command context and uses quote formatting that does not match the test expectations. Consider updating it to include the command name and adjust quote usage (e.g., 'invalid command "test": duplicate short flag 's'').

Suggested change
errs = append(errs, errors.Errorf("duplicate short flag %q", short))
errs = append(errs, errors.Errorf("invalid command %q: duplicate short flag '%c'", c.name, short))

Copilot uses AI. Check for mistakes.
}

flags[short] = true
}
}

return errors.Join(errs...)
}

func (c *Command) validateNoDuplicateArguments() error {
arguments := make(map[string]bool)

Expand Down
7 changes: 7 additions & 0 deletions command_config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ func TestCommand_config_validations(t *testing.T) {
),
expectedError: `invalid command "test": duplicate flag "some-flag"`,
},
"validateNoDuplicateShortFlags": {
commandOptions: option.NewOptions(
AddFlag("some-flag", "some flag", AddFlagShort('s')),
AddFlag("some-other-flag", "some other flag", AddFlagShort('s')),
),
expectedError: `invalid command "test": duplicate short flag 's'`,
},
"validateNoDuplicateArguments": {
commandOptions: option.NewOptions(
AddArg("some-arg", "some arg"),
Expand Down
25 changes: 15 additions & 10 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,19 @@
"github.com/bobg/errors"
)

var (
NotACommandContextError = errors.New("not a command context")
FlagNotFoundError = errors.New("flag not found")
ArgumentNotFoundError = errors.New("argument not found")
)
var HandleExecutionError = errors.New("executing handler")

func FlagValue[T any](ctx context.Context, name string) (T, error) {
var zero T

command, err := commandFromContext(ctx)
if err != nil {
return zero, errors.Wrapf(err, "finding flag %q", name)
return zero, err

Check warning on line 17 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L17

Added line #L17 was not covered by tests
}

flag, found := command.findFlag(name)
if !found {
return zero, errors.Wrapf(FlagNotFoundError, "finding flag %q", name)
return zero, errors.Wrapf(HandleExecutionError, "finding flag %q", name)

Check warning on line 22 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L22

Added line #L22 was not covered by tests
}

if flag.value != nil {
Expand All @@ -33,7 +29,7 @@
if flag.defaultEnvName != "" {
value, err := flag.parser.Parse(os.Getenv(flag.defaultEnvName))
if err != nil {
return zero, err
return zero, errors.Wrapf(errors.Join(HandleExecutionError, err), "parsing env for flag %q", name)

Check warning on line 32 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L32

Added line #L32 was not covered by tests
}

return value.(T), nil
Expand All @@ -52,7 +48,7 @@

arg, found := command.findArg(name)
if !found {
return zero, errors.Wrapf(ArgumentNotFoundError, "finding argument %q", name)
return zero, errors.Wrapf(HandleExecutionError, "finding argument %q", name)

Check warning on line 51 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L51

Added line #L51 was not covered by tests
}

if arg.value != nil {
Expand All @@ -62,6 +58,15 @@
return arg.defaultValue.(T), nil
}

func Rest(ctx context.Context) ([]string, error) {
command, err := commandFromContext(ctx)
if err != nil {
return nil, errors.Wrap(err, "getting remaining args")
}

Check warning on line 65 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L64-L65

Added lines #L64 - L65 were not covered by tests

return command.rest, nil
}

type commandContextKeyType struct{}

var commandContextKey = commandContextKeyType{}
Expand All @@ -73,7 +78,7 @@
func commandFromContext(ctx context.Context) (*Command, error) {
command, ok := ctx.Value(commandContextKey).(*Command)
if !ok {
return nil, NotACommandContextError
return nil, errors.Wrap(HandleExecutionError, "not a command context")

Check warning on line 81 in context.go

View check run for this annotation

Codecov / codecov/patch

context.go#L81

Added line #L81 was not covered by tests
}

return command, nil
Expand Down
16 changes: 13 additions & 3 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
)

const (
restToken = "--"
flagPrefix = "-"
longFlagPrefix = "--"
)
Expand Down Expand Up @@ -41,9 +42,10 @@ func (c *Command) newParser(tokens []string) *parser {

func (p *parser) parse(ctx context.Context) (bool, error) {
for p.index < len(p.tokens) {
commandProcessed, err := p.parseArg(ctx)
if err != nil || commandProcessed {
if commandProcessed, err := p.parseArg(ctx); err != nil {
return true, err
} else if commandProcessed {
return true, nil
}
}

Expand All @@ -53,7 +55,10 @@ func (p *parser) parse(ctx context.Context) (bool, error) {
func (p *parser) parseArg(ctx context.Context) (bool, error) {
current, _ := p.current()

if strings.HasPrefix(current, flagPrefix) {
if current == restToken {
p.processRest()
return false, nil
} else if strings.HasPrefix(current, flagPrefix) {
return false, p.processFlag()
} else if command, found := lo.Find(p.command.subCommands, func(subCommand *Command) bool { return subCommand.name == current }); found {
return true, p.processCommand(ctx, command)
Expand All @@ -62,6 +67,11 @@ func (p *parser) parseArg(ctx context.Context) (bool, error) {
return false, p.processArg()
}

func (p *parser) processRest() {
p.command.rest = p.unprocessed()
p.index = len(p.tokens)
}

func (p *parser) processFlag() error {
current, _ := p.current()

Expand Down