Skip to content
Merged
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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,22 @@ resolved command. For applications that need work between parsing and execution,
## Flags

`FlagsFunc` is a convenience for defining flags inline. Use `FlagsMetadata` to extend the standard
`flag` package with features like required flag enforcement:
`flag` package with features like required flag enforcement and short aliases:

```go
Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
f.String("output", "", "output file")
}),
FlagsMetadata: []cli.FlagMetadata{
{Name: "output", Required: true},
{Name: "verbose", Short: "v"},
{Name: "output", Short: "o", Required: true},
},
```

Short aliases register `-v` as an alias for `--verbose`, `-o` as an alias for `--output`, and so on.
Both forms are shown in help output automatically.

Access flags inside `Exec` with the type-safe `GetFlag` function:

```go
Expand Down Expand Up @@ -107,8 +111,8 @@ There are many great CLI libraries out there, but I always felt [they were too h
needs](https://mfridman.com/blog/2021/a-simpler-building-block-for-go-clis/).

Inspired by Peter Bourgon's [ff](https://github.com/peterbourgon/ff) library, specifically the `v3`
branch, which was so close to what I wanted. The `v4` branch took a different direction, and I wanted
to keep the simplicity of `v3`. This library carries that idea forward.
branch, which was so close to what I wanted. The `v4` branch took a different direction, and I
wanted to keep the simplicity of `v3`. This library carries that idea forward.

## License

Expand Down
7 changes: 6 additions & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,16 @@ func (c *Command) terminal() *Command {
return c.state.path[len(c.state.path)-1]
}

// FlagMetadata holds additional metadata for a flag, such as whether it is required.
// FlagMetadata holds additional metadata for a flag, such as whether it is required or has a short
// alias.
type FlagMetadata struct {
// Name is the flag's name. Must match the flag name in the flag set.
Name string

// Short is an optional single-character alias for the flag. When set, users can use either
// -v or -verbose (if Short is "v" and Name is "verbose"). Must be a single ASCII letter.
Short string

// Required indicates whether the flag is required.
Required bool
}
Expand Down
85 changes: 76 additions & 9 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,22 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) {

// Check if this flag expects a value across all commands in the chain (not just the
// current command), since flags from ancestor commands are inherited and can appear
// anywhere.
// anywhere. Also check short flag aliases from FlagsMetadata.
name := strings.TrimLeft(arg, "-")
skipValue := false
for _, cmd := range root.state.path {
if f := cmd.Flags.Lookup(name); f != nil {
// First try direct lookup.
f := cmd.Flags.Lookup(name)
// If not found, check if it's a short alias.
if f == nil {
for _, fm := range cmd.FlagsMetadata {
if fm.Short == name {
f = cmd.Flags.Lookup(fm.Name)
break
}
}
}
if f != nil {
if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool {
skipValue = true
}
Expand Down Expand Up @@ -145,23 +156,43 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) {
}

// combineFlags merges flags from the command path into a single FlagSet. Flags are added in reverse
// order (deepest command first) so that child flags take precedence over parent flags.
// order (deepest command first) so that child flags take precedence over parent flags. Short flag
// aliases from FlagsMetadata are also registered, sharing the same Value as their long counterpart.
func combineFlags(path []*Command) *flag.FlagSet {
combined := flag.NewFlagSet(path[0].Name, flag.ContinueOnError)
combined.SetOutput(io.Discard)
for i := len(path) - 1; i >= 0; i-- {
cmd := path[i]
if cmd.Flags != nil {
cmd.Flags.VisitAll(func(f *flag.Flag) {
if combined.Lookup(f.Name) == nil {
combined.Var(f.Value, f.Name, f.Usage)
}
})
if cmd.Flags == nil {
continue
}
shortMap := shortFlagMap(cmd.FlagsMetadata)
cmd.Flags.VisitAll(func(f *flag.Flag) {
if combined.Lookup(f.Name) == nil {
combined.Var(f.Value, f.Name, f.Usage)
}
// Register the short alias pointing to the same Value.
if short, ok := shortMap[f.Name]; ok {
if combined.Lookup(short) == nil {
combined.Var(f.Value, short, f.Usage)
}
}
})
}
return combined
}

// shortFlagMap builds a map from long flag name to short alias from FlagsMetadata.
func shortFlagMap(metadata []FlagMetadata) map[string]string {
m := make(map[string]string, len(metadata))
for _, fm := range metadata {
if fm.Short != "" {
m[fm.Name] = fm.Short
}
}
return m
}

// checkRequiredFlags verifies that all flags marked as required in FlagsMetadata were explicitly
// set during parsing.
func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error {
Expand Down Expand Up @@ -249,10 +280,46 @@ func validateCommands(root *Command, path []string) error {
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
}

if err := validateFlagsMetadata(root); err != nil {
quoted := make([]string, len(currentPath))
for i, p := range currentPath {
quoted[i] = strconv.Quote(p)
}
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
}

for _, sub := range root.SubCommands {
if err := validateCommands(sub, currentPath); err != nil {
return err
}
}
return nil
}

// validateFlagsMetadata checks that each FlagMetadata entry refers to a flag that exists in the
// command's FlagSet, that Short aliases are single ASCII letters, and that no two entries share the
// same Short alias.
func validateFlagsMetadata(cmd *Command) error {
if len(cmd.FlagsMetadata) == 0 {
return nil
}
seenShorts := make(map[string]string) // short -> flag name
for _, fm := range cmd.FlagsMetadata {
if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil {
return fmt.Errorf("flag metadata references unknown flag %q", fm.Name)
}
if fm.Short == "" {
continue
}
if len(fm.Short) != 1 || fm.Short[0] < 'a' || fm.Short[0] > 'z' {
if fm.Short[0] < 'A' || fm.Short[0] > 'Z' {
return fmt.Errorf("flag %q: short alias must be a single ASCII letter, got %q", fm.Name, fm.Short)
}
}
if other, ok := seenShorts[fm.Short]; ok {
return fmt.Errorf("duplicate short flag %q: used by both %q and %q", fm.Short, other, fm.Name)
}
seenShorts[fm.Short] = fm.Name
}
return nil
}
148 changes: 144 additions & 4 deletions parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,7 @@ func TestParse(t *testing.T) {
}
err := Parse(cmd, nil)
require.Error(t, err)
// TODO(mf): consider improving this error message so it's obvious that a "required" flag
// was set by the cli author but not registered in the flag set
require.ErrorContains(t, err, `command "root": internal error: required flag -some-other-flag not found in flag set`)
require.ErrorContains(t, err, `flag metadata references unknown flag "some-other-flag"`)
})
t.Run("space in command name", func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -569,7 +567,7 @@ func TestParse(t *testing.T) {
}
err := Parse(cmd, []string{"--existing=value"})
require.Error(t, err)
require.ErrorContains(t, err, "required flag -nonexistent not found in flag set")
require.ErrorContains(t, err, `flag metadata references unknown flag "nonexistent"`)
})
t.Run("args with special characters", func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -696,6 +694,148 @@ func TestParse(t *testing.T) {
})
}

func TestShortFlags(t *testing.T) {
t.Parallel()

t.Run("short flag sets value", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
f.String("output", "", "output file")
}),
FlagsMetadata: []FlagMetadata{
{Name: "verbose", Short: "v"},
{Name: "output", Short: "o"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(cmd, []string{"-v", "-o", "file.txt"})
require.NoError(t, err)
require.True(t, GetFlag[bool](cmd.state, "verbose"))
require.Equal(t, "file.txt", GetFlag[string](cmd.state, "output"))
})

t.Run("long flag still works with short alias defined", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
}),
FlagsMetadata: []FlagMetadata{
{Name: "verbose", Short: "v"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(cmd, []string{"-verbose"})
require.NoError(t, err)
require.True(t, GetFlag[bool](cmd.state, "verbose"))
})

t.Run("short flag with subcommand", func(t *testing.T) {
t.Parallel()
child := &Command{
Name: "child",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.String("name", "", "the name")
}),
FlagsMetadata: []FlagMetadata{
{Name: "name", Short: "n"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
root := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "verbose")
}),
FlagsMetadata: []FlagMetadata{
{Name: "verbose", Short: "v"},
},
SubCommands: []*Command{child},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(root, []string{"-v", "child", "-n", "hello"})
require.NoError(t, err)
require.True(t, GetFlag[bool](root.state, "verbose"))
require.Equal(t, "hello", GetFlag[string](root.state, "name"))
})

t.Run("short and long flags are aliases sharing same value", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Int("count", 0, "number of items")
}),
FlagsMetadata: []FlagMetadata{
{Name: "count", Short: "c"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
// Use short flag
err := Parse(cmd, []string{"-c", "42"})
require.NoError(t, err)
// Both short and long name should return the same value
require.Equal(t, 42, GetFlag[int](cmd.state, "count"))
})

t.Run("metadata references unknown flag", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
}),
FlagsMetadata: []FlagMetadata{
{Name: "vrbose", Short: "v"}, // typo in Name
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(cmd, []string{})
require.Error(t, err)
require.Contains(t, err.Error(), `flag metadata references unknown flag "vrbose"`)
})

t.Run("short alias must be single ASCII letter", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
}),
FlagsMetadata: []FlagMetadata{
{Name: "verbose", Short: "vv"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(cmd, []string{})
require.Error(t, err)
require.Contains(t, err.Error(), "short alias must be a single ASCII letter")
})

t.Run("duplicate short alias", func(t *testing.T) {
t.Parallel()
cmd := &Command{
Name: "root",
Flags: FlagsFunc(func(f *flag.FlagSet) {
f.Bool("verbose", false, "enable verbose output")
f.Bool("version", false, "show version")
}),
FlagsMetadata: []FlagMetadata{
{Name: "verbose", Short: "v"},
{Name: "version", Short: "v"},
},
Exec: func(ctx context.Context, s *State) error { return nil },
}
err := Parse(cmd, []string{})
require.Error(t, err)
require.Contains(t, err.Error(), `duplicate short flag "v"`)
})
}

func getCommand(t *testing.T, c *Command) *Command {
require.NotNil(t, c)
require.NotNil(t, c.state)
Expand Down
Loading