Skip to content

Commit b6285cf

Browse files
authored
feat: add short flag aliases via FlagMetadata.Short (#11)
1 parent d273039 commit b6285cf

File tree

6 files changed

+340
-48
lines changed

6 files changed

+340
-48
lines changed

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,22 @@ resolved command. For applications that need work between parsing and execution,
4040
## Flags
4141

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

4545
```go
4646
Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
4747
f.Bool("verbose", false, "enable verbose output")
4848
f.String("output", "", "output file")
4949
}),
5050
FlagsMetadata: []cli.FlagMetadata{
51-
{Name: "output", Required: true},
51+
{Name: "verbose", Short: "v"},
52+
{Name: "output", Short: "o", Required: true},
5253
},
5354
```
5455

56+
Short aliases register `-v` as an alias for `--verbose`, `-o` as an alias for `--output`, and so on.
57+
Both forms are shown in help output automatically.
58+
5559
Access flags inside `Exec` with the type-safe `GetFlag` function:
5660

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

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

113117
## License
114118

command.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,16 @@ func (c *Command) terminal() *Command {
7171
return c.state.path[len(c.state.path)-1]
7272
}
7373

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

80+
// Short is an optional single-character alias for the flag. When set, users can use either
81+
// -v or -verbose (if Short is "v" and Name is "verbose"). Must be a single ASCII letter.
82+
Short string
83+
7984
// Required indicates whether the flag is required.
8085
Required bool
8186
}

parse.go

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,22 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) {
106106

107107
// Check if this flag expects a value across all commands in the chain (not just the
108108
// current command), since flags from ancestor commands are inherited and can appear
109-
// anywhere.
109+
// anywhere. Also check short flag aliases from FlagsMetadata.
110110
name := strings.TrimLeft(arg, "-")
111111
skipValue := false
112112
for _, cmd := range root.state.path {
113-
if f := cmd.Flags.Lookup(name); f != nil {
113+
// First try direct lookup.
114+
f := cmd.Flags.Lookup(name)
115+
// If not found, check if it's a short alias.
116+
if f == nil {
117+
for _, fm := range cmd.FlagsMetadata {
118+
if fm.Short == name {
119+
f = cmd.Flags.Lookup(fm.Name)
120+
break
121+
}
122+
}
123+
}
124+
if f != nil {
114125
if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool {
115126
skipValue = true
116127
}
@@ -145,23 +156,43 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) {
145156
}
146157

147158
// combineFlags merges flags from the command path into a single FlagSet. Flags are added in reverse
148-
// order (deepest command first) so that child flags take precedence over parent flags.
159+
// order (deepest command first) so that child flags take precedence over parent flags. Short flag
160+
// aliases from FlagsMetadata are also registered, sharing the same Value as their long counterpart.
149161
func combineFlags(path []*Command) *flag.FlagSet {
150162
combined := flag.NewFlagSet(path[0].Name, flag.ContinueOnError)
151163
combined.SetOutput(io.Discard)
152164
for i := len(path) - 1; i >= 0; i-- {
153165
cmd := path[i]
154-
if cmd.Flags != nil {
155-
cmd.Flags.VisitAll(func(f *flag.Flag) {
156-
if combined.Lookup(f.Name) == nil {
157-
combined.Var(f.Value, f.Name, f.Usage)
158-
}
159-
})
166+
if cmd.Flags == nil {
167+
continue
160168
}
169+
shortMap := shortFlagMap(cmd.FlagsMetadata)
170+
cmd.Flags.VisitAll(func(f *flag.Flag) {
171+
if combined.Lookup(f.Name) == nil {
172+
combined.Var(f.Value, f.Name, f.Usage)
173+
}
174+
// Register the short alias pointing to the same Value.
175+
if short, ok := shortMap[f.Name]; ok {
176+
if combined.Lookup(short) == nil {
177+
combined.Var(f.Value, short, f.Usage)
178+
}
179+
}
180+
})
161181
}
162182
return combined
163183
}
164184

185+
// shortFlagMap builds a map from long flag name to short alias from FlagsMetadata.
186+
func shortFlagMap(metadata []FlagMetadata) map[string]string {
187+
m := make(map[string]string, len(metadata))
188+
for _, fm := range metadata {
189+
if fm.Short != "" {
190+
m[fm.Name] = fm.Short
191+
}
192+
}
193+
return m
194+
}
195+
165196
// checkRequiredFlags verifies that all flags marked as required in FlagsMetadata were explicitly
166197
// set during parsing.
167198
func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error {
@@ -249,10 +280,46 @@ func validateCommands(root *Command, path []string) error {
249280
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
250281
}
251282

283+
if err := validateFlagsMetadata(root); err != nil {
284+
quoted := make([]string, len(currentPath))
285+
for i, p := range currentPath {
286+
quoted[i] = strconv.Quote(p)
287+
}
288+
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
289+
}
290+
252291
for _, sub := range root.SubCommands {
253292
if err := validateCommands(sub, currentPath); err != nil {
254293
return err
255294
}
256295
}
257296
return nil
258297
}
298+
299+
// validateFlagsMetadata checks that each FlagMetadata entry refers to a flag that exists in the
300+
// command's FlagSet, that Short aliases are single ASCII letters, and that no two entries share the
301+
// same Short alias.
302+
func validateFlagsMetadata(cmd *Command) error {
303+
if len(cmd.FlagsMetadata) == 0 {
304+
return nil
305+
}
306+
seenShorts := make(map[string]string) // short -> flag name
307+
for _, fm := range cmd.FlagsMetadata {
308+
if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil {
309+
return fmt.Errorf("flag metadata references unknown flag %q", fm.Name)
310+
}
311+
if fm.Short == "" {
312+
continue
313+
}
314+
if len(fm.Short) != 1 || fm.Short[0] < 'a' || fm.Short[0] > 'z' {
315+
if fm.Short[0] < 'A' || fm.Short[0] > 'Z' {
316+
return fmt.Errorf("flag %q: short alias must be a single ASCII letter, got %q", fm.Name, fm.Short)
317+
}
318+
}
319+
if other, ok := seenShorts[fm.Short]; ok {
320+
return fmt.Errorf("duplicate short flag %q: used by both %q and %q", fm.Short, other, fm.Name)
321+
}
322+
seenShorts[fm.Short] = fm.Name
323+
}
324+
return nil
325+
}

parse_test.go

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,7 @@ func TestParse(t *testing.T) {
368368
}
369369
err := Parse(cmd, nil)
370370
require.Error(t, err)
371-
// TODO(mf): consider improving this error message so it's obvious that a "required" flag
372-
// was set by the cli author but not registered in the flag set
373-
require.ErrorContains(t, err, `command "root": internal error: required flag -some-other-flag not found in flag set`)
371+
require.ErrorContains(t, err, `flag metadata references unknown flag "some-other-flag"`)
374372
})
375373
t.Run("space in command name", func(t *testing.T) {
376374
t.Parallel()
@@ -569,7 +567,7 @@ func TestParse(t *testing.T) {
569567
}
570568
err := Parse(cmd, []string{"--existing=value"})
571569
require.Error(t, err)
572-
require.ErrorContains(t, err, "required flag -nonexistent not found in flag set")
570+
require.ErrorContains(t, err, `flag metadata references unknown flag "nonexistent"`)
573571
})
574572
t.Run("args with special characters", func(t *testing.T) {
575573
t.Parallel()
@@ -696,6 +694,148 @@ func TestParse(t *testing.T) {
696694
})
697695
}
698696

697+
func TestShortFlags(t *testing.T) {
698+
t.Parallel()
699+
700+
t.Run("short flag sets value", func(t *testing.T) {
701+
t.Parallel()
702+
cmd := &Command{
703+
Name: "root",
704+
Flags: FlagsFunc(func(f *flag.FlagSet) {
705+
f.Bool("verbose", false, "enable verbose output")
706+
f.String("output", "", "output file")
707+
}),
708+
FlagsMetadata: []FlagMetadata{
709+
{Name: "verbose", Short: "v"},
710+
{Name: "output", Short: "o"},
711+
},
712+
Exec: func(ctx context.Context, s *State) error { return nil },
713+
}
714+
err := Parse(cmd, []string{"-v", "-o", "file.txt"})
715+
require.NoError(t, err)
716+
require.True(t, GetFlag[bool](cmd.state, "verbose"))
717+
require.Equal(t, "file.txt", GetFlag[string](cmd.state, "output"))
718+
})
719+
720+
t.Run("long flag still works with short alias defined", func(t *testing.T) {
721+
t.Parallel()
722+
cmd := &Command{
723+
Name: "root",
724+
Flags: FlagsFunc(func(f *flag.FlagSet) {
725+
f.Bool("verbose", false, "enable verbose output")
726+
}),
727+
FlagsMetadata: []FlagMetadata{
728+
{Name: "verbose", Short: "v"},
729+
},
730+
Exec: func(ctx context.Context, s *State) error { return nil },
731+
}
732+
err := Parse(cmd, []string{"-verbose"})
733+
require.NoError(t, err)
734+
require.True(t, GetFlag[bool](cmd.state, "verbose"))
735+
})
736+
737+
t.Run("short flag with subcommand", func(t *testing.T) {
738+
t.Parallel()
739+
child := &Command{
740+
Name: "child",
741+
Flags: FlagsFunc(func(f *flag.FlagSet) {
742+
f.String("name", "", "the name")
743+
}),
744+
FlagsMetadata: []FlagMetadata{
745+
{Name: "name", Short: "n"},
746+
},
747+
Exec: func(ctx context.Context, s *State) error { return nil },
748+
}
749+
root := &Command{
750+
Name: "root",
751+
Flags: FlagsFunc(func(f *flag.FlagSet) {
752+
f.Bool("verbose", false, "verbose")
753+
}),
754+
FlagsMetadata: []FlagMetadata{
755+
{Name: "verbose", Short: "v"},
756+
},
757+
SubCommands: []*Command{child},
758+
Exec: func(ctx context.Context, s *State) error { return nil },
759+
}
760+
err := Parse(root, []string{"-v", "child", "-n", "hello"})
761+
require.NoError(t, err)
762+
require.True(t, GetFlag[bool](root.state, "verbose"))
763+
require.Equal(t, "hello", GetFlag[string](root.state, "name"))
764+
})
765+
766+
t.Run("short and long flags are aliases sharing same value", func(t *testing.T) {
767+
t.Parallel()
768+
cmd := &Command{
769+
Name: "root",
770+
Flags: FlagsFunc(func(f *flag.FlagSet) {
771+
f.Int("count", 0, "number of items")
772+
}),
773+
FlagsMetadata: []FlagMetadata{
774+
{Name: "count", Short: "c"},
775+
},
776+
Exec: func(ctx context.Context, s *State) error { return nil },
777+
}
778+
// Use short flag
779+
err := Parse(cmd, []string{"-c", "42"})
780+
require.NoError(t, err)
781+
// Both short and long name should return the same value
782+
require.Equal(t, 42, GetFlag[int](cmd.state, "count"))
783+
})
784+
785+
t.Run("metadata references unknown flag", func(t *testing.T) {
786+
t.Parallel()
787+
cmd := &Command{
788+
Name: "root",
789+
Flags: FlagsFunc(func(f *flag.FlagSet) {
790+
f.Bool("verbose", false, "enable verbose output")
791+
}),
792+
FlagsMetadata: []FlagMetadata{
793+
{Name: "vrbose", Short: "v"}, // typo in Name
794+
},
795+
Exec: func(ctx context.Context, s *State) error { return nil },
796+
}
797+
err := Parse(cmd, []string{})
798+
require.Error(t, err)
799+
require.Contains(t, err.Error(), `flag metadata references unknown flag "vrbose"`)
800+
})
801+
802+
t.Run("short alias must be single ASCII letter", func(t *testing.T) {
803+
t.Parallel()
804+
cmd := &Command{
805+
Name: "root",
806+
Flags: FlagsFunc(func(f *flag.FlagSet) {
807+
f.Bool("verbose", false, "enable verbose output")
808+
}),
809+
FlagsMetadata: []FlagMetadata{
810+
{Name: "verbose", Short: "vv"},
811+
},
812+
Exec: func(ctx context.Context, s *State) error { return nil },
813+
}
814+
err := Parse(cmd, []string{})
815+
require.Error(t, err)
816+
require.Contains(t, err.Error(), "short alias must be a single ASCII letter")
817+
})
818+
819+
t.Run("duplicate short alias", func(t *testing.T) {
820+
t.Parallel()
821+
cmd := &Command{
822+
Name: "root",
823+
Flags: FlagsFunc(func(f *flag.FlagSet) {
824+
f.Bool("verbose", false, "enable verbose output")
825+
f.Bool("version", false, "show version")
826+
}),
827+
FlagsMetadata: []FlagMetadata{
828+
{Name: "verbose", Short: "v"},
829+
{Name: "version", Short: "v"},
830+
},
831+
Exec: func(ctx context.Context, s *State) error { return nil },
832+
}
833+
err := Parse(cmd, []string{})
834+
require.Error(t, err)
835+
require.Contains(t, err.Error(), `duplicate short flag "v"`)
836+
})
837+
}
838+
699839
func getCommand(t *testing.T, c *Command) *Command {
700840
require.NotNil(t, c)
701841
require.NotNil(t, c.state)

0 commit comments

Comments
 (0)