Skip to content

Commit d273039

Browse files
committed
feat: enhance usage output with type hints, required markers, and zero-default suppression
1 parent fc8c5dd commit d273039

2 files changed

Lines changed: 165 additions & 24 deletions

File tree

usage.go

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
"github.com/pressly/cli/pkg/textutil"
1111
)
1212

13+
// defaultTerminalWidth is the assumed terminal width for wrapping help text.
14+
const defaultTerminalWidth = 80
15+
1316
// DefaultUsage returns the default usage string for the command hierarchy. It is used when the
1417
// command does not provide a custom usage function. The usage string includes the command's short
1518
// help, usage pattern, available subcommands, and flags.
@@ -65,7 +68,7 @@ func DefaultUsage(root *Command) string {
6568
}
6669

6770
nameWidth := maxNameLen + 4
68-
wrapWidth := 80 - nameWidth
71+
wrapWidth := defaultTerminalWidth - nameWidth
6972

7073
for _, sub := range sortedCommands {
7174
if sub.ShortHelp == "" {
@@ -92,15 +95,40 @@ func DefaultUsage(root *Command) string {
9295
continue
9396
}
9497
isGlobal := i < len(root.state.path)-1
98+
requiredFlags := make(map[string]bool)
99+
for _, m := range cmd.FlagsMetadata {
100+
if m.Required {
101+
requiredFlags[m.Name] = true
102+
}
103+
}
95104
cmd.Flags.VisitAll(func(f *flag.Flag) {
96105
flags = append(flags, flagInfo{
97-
name: "-" + f.Name,
98-
usage: f.Usage,
99-
defval: f.DefValue,
100-
global: isGlobal,
106+
name: "-" + f.Name,
107+
usage: f.Usage,
108+
defval: f.DefValue,
109+
typeName: flagTypeName(f),
110+
global: isGlobal,
111+
required: requiredFlags[f.Name],
101112
})
102113
})
103114
}
115+
} else if terminalCmd.Flags != nil {
116+
// Pre-parse fallback: show the command's own flags even without state.
117+
requiredFlags := make(map[string]bool)
118+
for _, m := range terminalCmd.FlagsMetadata {
119+
if m.Required {
120+
requiredFlags[m.Name] = true
121+
}
122+
}
123+
terminalCmd.Flags.VisitAll(func(f *flag.Flag) {
124+
flags = append(flags, flagInfo{
125+
name: "-" + f.Name,
126+
usage: f.Usage,
127+
defval: f.DefValue,
128+
typeName: flagTypeName(f),
129+
required: requiredFlags[f.Name],
130+
})
131+
})
104132
}
105133

106134
if len(flags) > 0 {
@@ -110,8 +138,8 @@ func DefaultUsage(root *Command) string {
110138

111139
maxFlagLen := 0
112140
for _, f := range flags {
113-
if len(f.name) > maxFlagLen {
114-
maxFlagLen = len(f.name)
141+
if n := len(f.displayName()); n > maxFlagLen {
142+
maxFlagLen = n
115143
}
116144
}
117145

@@ -152,21 +180,24 @@ func DefaultUsage(root *Command) string {
152180
// writeFlagSection handles the formatting of flag descriptions
153181
func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global bool) {
154182
nameWidth := maxLen + 4
155-
wrapWidth := 80 - nameWidth
183+
wrapWidth := defaultTerminalWidth - nameWidth
156184

157185
for _, f := range flags {
158186
if f.global != global {
159187
continue
160188
}
161189

162190
description := f.usage
163-
if f.defval != "" {
191+
if f.required {
192+
description += " (required)"
193+
} else if !isZeroDefault(f.defval, f.typeName) {
164194
description += fmt.Sprintf(" (default: %s)", f.defval)
165195
}
166196

197+
display := f.displayName()
167198
lines := textutil.Wrap(description, wrapWidth)
168-
padding := strings.Repeat(" ", maxLen-len(f.name)+4)
169-
fmt.Fprintf(b, " %s%s%s\n", f.name, padding, lines[0])
199+
padding := strings.Repeat(" ", maxLen-len(display)+4)
200+
fmt.Fprintf(b, " %s%s%s\n", display, padding, lines[0])
170201

171202
indentPadding := strings.Repeat(" ", nameWidth+2)
172203
for _, line := range lines[1:] {
@@ -176,8 +207,56 @@ func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, global b
176207
}
177208

178209
type flagInfo struct {
179-
name string
180-
usage string
181-
defval string
182-
global bool
210+
name string
211+
usage string
212+
defval string
213+
typeName string
214+
global bool
215+
required bool
216+
}
217+
218+
// displayName returns the flag name with its type hint, e.g., "-config string" or "-verbose" (no
219+
// type for bools).
220+
func (f flagInfo) displayName() string {
221+
if f.typeName == "" {
222+
return f.name
223+
}
224+
return f.name + " " + f.typeName
225+
}
226+
227+
// flagTypeName returns a short type name for a flag's value. Bool flags return "" since their type
228+
// is obvious from usage. This mirrors the approach used by Go's flag.PrintDefaults.
229+
func flagTypeName(f *flag.Flag) string {
230+
// Use the type name from the Value interface, which returns the type as a string.
231+
typeName := fmt.Sprintf("%T", f.Value)
232+
// The flag package uses unexported types like *flag.boolValue, *flag.stringValue, etc. Extract
233+
// just the base name and strip the "Value" suffix.
234+
if i := strings.LastIndex(typeName, "."); i >= 0 {
235+
typeName = typeName[i+1:]
236+
}
237+
typeName = strings.TrimPrefix(typeName, "*")
238+
typeName = strings.TrimSuffix(typeName, "Value")
239+
240+
// Don't show type for bools — their usage is self-evident.
241+
if typeName == "bool" {
242+
return ""
243+
}
244+
return typeName
245+
}
246+
247+
// isZeroDefault returns true if the default value is the zero value for its type and should be
248+
// suppressed in help output to reduce noise.
249+
func isZeroDefault(defval, typeName string) bool {
250+
switch {
251+
case defval == "":
252+
return true
253+
case defval == "false" && typeName == "":
254+
// Bool flags (typeName is "" for bools).
255+
return true
256+
case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"):
257+
return true
258+
case defval == "0" && typeName == "float64":
259+
return true
260+
}
261+
return false
183262
}

usage_test.go

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,21 +265,28 @@ func TestUsageGeneration(t *testing.T) {
265265
require.Contains(t, output, "float flag")
266266
})
267267

268-
t.Run("usage before parsing", func(t *testing.T) {
268+
t.Run("usage before parsing shows flags", func(t *testing.T) {
269269
t.Parallel()
270270

271271
cmd := &Command{
272272
Name: "unparsed",
273273
Flags: FlagsFunc(func(fset *flag.FlagSet) {
274-
fset.Bool("flag", false, "test flag")
274+
fset.Bool("debug", false, "enable debug mode")
275+
fset.String("config", "", "config file path")
275276
}),
277+
FlagsMetadata: []FlagMetadata{
278+
{Name: "config", Required: true},
279+
},
276280
Exec: func(ctx context.Context, s *State) error { return nil },
277281
}
278282

279-
// Usage should work even before parsing
283+
// Usage should work even before parsing and show flags
280284
output := DefaultUsage(cmd)
281285
require.NotEmpty(t, output)
282-
// But it may be limited without state
286+
require.Contains(t, output, "Flags:")
287+
require.Contains(t, output, "-debug")
288+
require.Contains(t, output, "-config string")
289+
require.Contains(t, output, "(required)")
283290
})
284291

285292
t.Run("usage with custom usage string", func(t *testing.T) {
@@ -330,10 +337,9 @@ func TestUsageGeneration(t *testing.T) {
330337
func TestWriteFlagSection(t *testing.T) {
331338
t.Parallel()
332339

333-
t.Run("writeFlagSection helper function", func(t *testing.T) {
340+
t.Run("non-zero defaults shown and type hints", func(t *testing.T) {
334341
t.Parallel()
335342

336-
// Test the internal behavior through DefaultUsage since writeFlagSection is not exported
337343
cmd := &Command{
338344
Name: "test",
339345
Flags: FlagsFunc(func(fset *flag.FlagSet) {
@@ -350,17 +356,73 @@ func TestWriteFlagSection(t *testing.T) {
350356
output := DefaultUsage(cmd)
351357
require.Contains(t, output, "Flags:")
352358
require.Contains(t, output, "-verbose")
353-
require.Contains(t, output, "-config")
354-
require.Contains(t, output, "-workers")
359+
require.Contains(t, output, "-config string")
360+
require.Contains(t, output, "-workers int")
355361
require.Contains(t, output, "enable verbose output")
356362
require.Contains(t, output, "configuration file path")
357363
require.Contains(t, output, "number of worker threads")
358364

359-
// Test default values are shown
365+
// Non-zero defaults are shown
360366
require.Contains(t, output, "(default: /etc/config)")
361367
require.Contains(t, output, "(default: 4)")
362368
})
363369

370+
t.Run("zero-value defaults suppressed", func(t *testing.T) {
371+
t.Parallel()
372+
373+
cmd := &Command{
374+
Name: "test",
375+
Flags: FlagsFunc(func(fset *flag.FlagSet) {
376+
fset.Bool("verbose", false, "enable verbose output")
377+
fset.String("output", "", "output file")
378+
fset.Int("count", 0, "number of items")
379+
fset.Float64("rate", 0.0, "rate limit")
380+
}),
381+
Exec: func(ctx context.Context, s *State) error { return nil },
382+
}
383+
384+
err := Parse(cmd, []string{})
385+
require.NoError(t, err)
386+
387+
output := DefaultUsage(cmd)
388+
// Zero-value defaults should not appear
389+
require.NotContains(t, output, "(default: false)")
390+
require.NotContains(t, output, "(default: 0)")
391+
require.NotContains(t, output, "(default: )")
392+
// But non-bool flags should still have type hints
393+
require.Contains(t, output, "-output string")
394+
require.Contains(t, output, "-count int")
395+
require.Contains(t, output, "-rate float64")
396+
// Bool flags should NOT have a type hint
397+
require.NotContains(t, output, "-verbose bool")
398+
})
399+
400+
t.Run("required flags marked", func(t *testing.T) {
401+
t.Parallel()
402+
403+
cmd := &Command{
404+
Name: "test",
405+
Flags: FlagsFunc(func(fset *flag.FlagSet) {
406+
fset.String("file", "", "path to file")
407+
fset.String("output", "stdout", "output destination")
408+
}),
409+
FlagsMetadata: []FlagMetadata{
410+
{Name: "file", Required: true},
411+
},
412+
Exec: func(ctx context.Context, s *State) error { return nil },
413+
}
414+
415+
err := Parse(cmd, []string{"-file", "test.txt"})
416+
require.NoError(t, err)
417+
418+
output := DefaultUsage(cmd)
419+
require.Contains(t, output, "(required)")
420+
// Required flag should not also show a default
421+
require.NotContains(t, output, "(default: )")
422+
// Non-required flag with non-zero default should show default
423+
require.Contains(t, output, "(default: stdout)")
424+
})
425+
364426
t.Run("no flags section when no flags", func(t *testing.T) {
365427
t.Parallel()
366428

0 commit comments

Comments
 (0)