Skip to content

Commit 1239c35

Browse files
committed
Refactor internals of Command and State flag tracking
1 parent 51002c2 commit 1239c35

File tree

5 files changed

+53
-61
lines changed

5 files changed

+53
-61
lines changed

README.md

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,35 @@
22

33
[![GoDoc](https://godoc.org/github.com/mfridman/cli?status.svg)](https://godoc.org/github.com/mfridman/cli)
44
[![CI](https://github.com/mfridman/cli/actions/workflows/ci.yaml/badge.svg)](https://github.com/mfridman/cli/actions/workflows/ci.yaml)
5-
[![Go Report
6-
Card](https://goreportcard.com/badge/github.com/mfridman/cli)](https://goreportcard.com/report/github.com/mfridman/cli)
75

8-
A lightweight framework for building Go CLI applications with nested subcommands.
9-
10-
Supports flexible flag placement ([allowing flags anywhere on the
11-
CLI](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/)), since Go's standard
12-
library requires flags before arguments.
6+
A Go framework for building CLI applications with flexible flag placement. Extends the standard
7+
library's `flag` package to support [flags
8+
anywhere](https://mfridman.com/blog/2024/allowing-flags-anywhere-on-the-cli/) in command arguments.
139

1410
## Features
1511

1612
- Nested subcommands for organizing complex CLIs
17-
- Flexible flag parsing, allowing flags anywhere on the CLI
13+
- Flexible flag parsing, allowing flags anywhere
1814
- Subcommands inherit flags from parent commands
1915
- Type-safe flag access
2016
- Automatic generation of help text and usage information
2117
- Suggestions for misspelled or incomplete commands
2218

23-
And that's it! It's the bare minimum to build a CLI application in Go while leveraging the standard
19+
And that's it! It's the **bare minimum to build a CLI application** while leveraging the standard
2420
library's `flag` package.
2521

22+
### But why?
23+
24+
This framework embraces minimalism while maintaining functionality. It provides essential building
25+
blocks for CLI applications without the bloat, allowing you to:
26+
27+
- Build maintainable command-line tools quickly
28+
- Focus on application logic rather than framework complexity
29+
- Extend functionality **only when needed**
30+
31+
Sometimes less is more. While other frameworks offer extensive features, this package focuses on
32+
core functionality.
33+
2634
## Installation
2735

2836
```bash
@@ -100,16 +108,25 @@ a slice of child commands.
100108

101109
> [!TIP]
102110
>
103-
> There's a top-level convenience function `FlagsFunc` that allows you to define flags inline:
111+
> There's a convenience function `FlagsFunc` that allows you to define flags inline:
104112
105113
```go
106-
cmd.Flags = cli.FlagsFunc(func(fs *flag.FlagSet) {
107-
fs.Bool("verbose", false, "enable verbose output")
108-
fs.String("output", "", "output file")
109-
fs.Int("count", 0, "number of items")
110-
})
114+
root := &cli.Command{
115+
Flags: cli.FlagsFunc(func(f *flag.FlagSet) {
116+
fs.Bool("verbose", false, "enable verbose output")
117+
fs.String("output", "", "output file")
118+
fs.Int("count", 0, "number of items")
119+
}),
120+
FlagsMetadata: []cli.FlagMetadata{
121+
{Name: "c", Required: true},
122+
},
123+
}
111124
```
112125

126+
The `FlagsMetadata` field is a slice of `FlagMetadata` structs that define metadata for each flag.
127+
Unfortunatly, the `flag.FlagSet` package alone is a bit limiting, so this package adds a layer on
128+
top to provide the most common features.
129+
113130
The `Exec` field is a function that is called when the command is executed. This is where you put
114131
your business logic.
115132

command.go

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ type Command struct {
4747
// is called.
4848
Exec func(ctx context.Context, s *State) error
4949

50-
state *State
51-
// TODO(mf): remove this in favor of tracking the selected *Command in the state
50+
state *State
5251
selected *Command
5352
}
5453

@@ -94,15 +93,13 @@ func (c *Command) showHelp() error {
9493
return nil
9594
}
9695

97-
// Display command description first if available, with wrapping
9896
if c.ShortHelp != "" {
9997
for _, line := range wrapText(c.ShortHelp, 80) {
10098
fmt.Fprintf(w, "%s\n", line)
10199
}
102100
fmt.Fprintln(w)
103101
}
104102

105-
// Show usage pattern
106103
fmt.Fprintf(w, "Usage:\n ")
107104
if c.Usage != "" {
108105
fmt.Fprintf(w, "%s\n", c.Usage)
@@ -118,7 +115,6 @@ func (c *Command) showHelp() error {
118115
}
119116
fmt.Fprintln(w)
120117

121-
// Show available subcommands
122118
if len(c.SubCommands) > 0 {
123119
fmt.Fprintf(w, "Available Commands:\n")
124120

@@ -155,7 +151,6 @@ func (c *Command) showHelp() error {
155151
fmt.Fprintln(w)
156152
}
157153

158-
// Collect and format all flags
159154
type flagInfo struct {
160155
name string
161156
usage string
@@ -176,12 +171,12 @@ func (c *Command) showHelp() error {
176171
})
177172
}
178173

179-
// Global flags
180-
if c.state.parent != nil {
174+
// Global flags from parent commands
175+
if c.state != nil && c.state.parent != nil {
181176
p := c.state.parent
182177
for p != nil {
183-
if p.flags != nil {
184-
p.flags.VisitAll(func(f *flag.Flag) {
178+
if p.cmd != nil && p.cmd.Flags != nil {
179+
p.cmd.Flags.VisitAll(func(f *flag.Flag) {
185180
flags = append(flags, flagInfo{
186181
name: "-" + f.Name,
187182
usage: f.Usage,
@@ -195,12 +190,10 @@ func (c *Command) showHelp() error {
195190
}
196191

197192
if len(flags) > 0 {
198-
// Sort flags by name
199193
slices.SortFunc(flags, func(a, b flagInfo) int {
200194
return cmp.Compare(a.name, b.name)
201195
})
202196

203-
// Find the longest flag name for alignment
204197
maxLen := 0
205198
for _, f := range flags {
206199
if len(f.name) > maxLen {
@@ -218,28 +211,22 @@ func (c *Command) showHelp() error {
218211
}
219212
}
220213

221-
// Print local flags first
222214
if hasLocal {
223215
fmt.Fprintf(w, "Flags:\n")
224216
for _, f := range flags {
225217
if !f.global {
226218
nameWidth := maxLen + 4
227219
wrapWidth := 80 - nameWidth
228220

229-
// Prepare the usage text with default value if needed
230221
usageText := f.usage
231222
if f.defval != "" && f.defval != "false" {
232223
usageText += fmt.Sprintf(" (default %s)", f.defval)
233224
}
234225

235-
// Wrap the usage text
236226
lines := wrapText(usageText, wrapWidth)
237-
238-
// Print first line with flag name
239227
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
240228
fmt.Fprintf(w, " %s%s%s\n", f.name, padding, lines[0])
241229

242-
// Print subsequent lines with proper padding
243230
indentPadding := strings.Repeat(" ", nameWidth+2)
244231
for _, line := range lines[1:] {
245232
fmt.Fprintf(w, "%s%s\n", indentPadding, line)
@@ -249,28 +236,22 @@ func (c *Command) showHelp() error {
249236
fmt.Fprintln(w)
250237
}
251238

252-
// Then print global flags
253239
if hasGlobal {
254240
fmt.Fprintf(w, "Global Flags:\n")
255241
for _, f := range flags {
256242
if f.global {
257243
nameWidth := maxLen + 4
258244
wrapWidth := 80 - nameWidth
259245

260-
// Prepare the usage text with default value if needed
261246
usageText := f.usage
262247
if f.defval != "" && f.defval != "false" {
263248
usageText += fmt.Sprintf(" (default %s)", f.defval)
264249
}
265250

266-
// Wrap the usage text
267251
lines := wrapText(usageText, wrapWidth)
268-
269-
// Print first line with flag name
270252
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
271253
fmt.Fprintf(w, " %s%s%s\n", f.name, padding, lines[0])
272254

273-
// Print subsequent lines with proper padding
274255
indentPadding := strings.Repeat(" ", nameWidth+2)
275256
for _, line := range lines[1:] {
276257
fmt.Fprintf(w, "%s%s\n", indentPadding, line)
@@ -281,7 +262,6 @@ func (c *Command) showHelp() error {
281262
}
282263
}
283264

284-
// Show help hint for subcommands
285265
if len(c.SubCommands) > 0 {
286266
fmt.Fprintf(w, "Use \"%s [command] --help\" for more information about a command.\n", c.Name)
287267
}

parse.go

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,7 @@ func Parse(root *Command, args []string) error {
2626

2727
// Initialize root state
2828
if root.state == nil {
29-
root.state = &State{}
30-
}
31-
if root.state.flags == nil {
32-
if root.Flags == nil {
33-
root.Flags = flag.NewFlagSet(root.Name, flag.ContinueOnError)
34-
}
35-
root.state.flags = root.Flags
29+
root.state = &State{cmd: root}
3630
}
3731

3832
// First split args at the -- delimiter if present
@@ -55,31 +49,29 @@ func Parse(root *Command, args []string) error {
5549

5650
// Create combined flags with all parent flags
5751
combinedFlags := flag.NewFlagSet(root.Name, flag.ContinueOnError)
52+
// TODO(mf): revisit this
5853
combinedFlags.SetOutput(io.Discard)
5954

60-
// First pass: process commands and build the flag set This lets us capture help requests before
61-
// any flag parsing errors
55+
// First pass: process commands and build the flag set. This lets us capture help requests
56+
// before any flag parsing errors
6257
for _, arg := range argsToParse {
6358
if arg == "-h" || arg == "--h" || arg == "-help" || arg == "--help" {
6459
combinedFlags.Usage = func() { _ = current.showHelp() }
6560
return current.showHelp()
6661
}
67-
6862
// Skip anything that looks like a flag
6963
if strings.HasPrefix(arg, "-") {
7064
continue
7165
}
72-
7366
// Try to traverse to subcommand
7467
if len(current.SubCommands) > 0 {
7568
if sub := current.findSubCommand(arg); sub != nil {
7669
if sub.state == nil {
77-
sub.state = &State{}
70+
sub.state = &State{cmd: sub}
7871
}
7972
if sub.Flags == nil {
8073
sub.Flags = flag.NewFlagSet(sub.Name, flag.ContinueOnError)
8174
}
82-
sub.state.flags = sub.Flags
8375
sub.state.parent = current.state
8476
current = sub
8577
commandChain = append(commandChain, sub)
@@ -122,7 +114,6 @@ func Parse(root *Command, args []string) error {
122114
if flag == nil {
123115
return fmt.Errorf("command %q: internal error: required flag %q not found in flag set", current.Name, flagMetadata.Name)
124116
}
125-
126117
// Look for the flag in the original args before any delimiter
127118
found := false
128119
for _, arg := range argsToParse {

state.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ type State struct {
1717
Stdin io.Reader
1818
Stdout, Stderr io.Writer
1919

20-
// TODO(mf): remove flags in favor of tracking the selected *Command
21-
flags *flag.FlagSet
20+
cmd *Command // Reference to the command this state belongs to
2221
parent *State
2322
}
2423

@@ -36,7 +35,7 @@ type State struct {
3635
// unexpected behavior.
3736
func GetFlag[T any](s *State, name string) T {
3837
// TODO(mf): we should have a way to get the selected command here to improve error messages
39-
if f := s.flags.Lookup(name); f != nil {
38+
if f := s.cmd.Flags.Lookup(name); f != nil {
4039
if getter, ok := f.Value.(flag.Getter); ok {
4140
value := getter.Get()
4241
if v, ok := value.(T); ok {
@@ -52,6 +51,6 @@ func GetFlag[T any](s *State, name string) T {
5251
return GetFlag[T](s.parent, name)
5352
}
5453
// If flag not found anywhere in hierarchy, panic with helpful message
55-
msg := fmt.Sprintf("internal error: flag not found: %q in %s flag set", name, s.flags.Name())
54+
msg := fmt.Sprintf("internal error: flag not found: %q in %s flag set", name, s.cmd.Name)
5655
panic(msg)
5756
}

state_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ func TestGetFlag(t *testing.T) {
1313

1414
t.Run("flag not found", func(t *testing.T) {
1515
st := &State{
16-
flags: flag.NewFlagSet("root", flag.ContinueOnError),
16+
cmd: &Command{
17+
Name: "root",
18+
Flags: flag.NewFlagSet("root", flag.ContinueOnError),
19+
},
1720
}
1821
// Capture the panic
1922
defer func() {
@@ -27,9 +30,11 @@ func TestGetFlag(t *testing.T) {
2730
})
2831
t.Run("flag type mismatch", func(t *testing.T) {
2932
st := &State{
30-
flags: flag.NewFlagSet("root", flag.ContinueOnError),
33+
cmd: &Command{
34+
Name: "root",
35+
Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("version", "1.0.0", "show version") }),
36+
},
3137
}
32-
st.flags.String("version", "1.0", "version")
3338
defer func() {
3439
r := recover()
3540
require.NotNil(t, r)

0 commit comments

Comments
 (0)