From 5c778361528e8dae437f2d70b52035196aaa91d5 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 26 Apr 2026 11:05:16 +0200 Subject: [PATCH 1/2] refactor help API --- CHANGELOG.md | 23 ++++ README.md | 31 +++++- command.go | 77 +++++++------ error.go | 29 +++++ error_test.go | 19 ++++ examples/cmd/echo/main.go | 5 +- examples/cmd/task/main.go | 19 ++-- parse.go | 75 +++++++------ parse_test.go | 52 ++++----- path_test.go | 2 + run.go | 41 +++---- run_test.go | 39 +++++++ state.go | 27 +++-- state_test.go | 127 ++++++++++++++++++++++ usage.go | 223 ++++++++++++++------------------------ usage/command.go | 19 ++++ usage/flag.go | 71 ++++++++++++ usage/flag_test.go | 23 ++++ usage/help.go | 146 +++++++++++++++++++++++++ usage/help_test.go | 49 +++++++++ usage_test.go | 94 ++++++++++++---- xflag/parse.go | 2 +- 22 files changed, 891 insertions(+), 302 deletions(-) create mode 100644 error.go create mode 100644 error_test.go create mode 100644 usage/command.go create mode 100644 usage/flag.go create mode 100644 usage/flag_test.go create mode 100644 usage/help.go create mode 100644 usage/help_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c67389..33faf9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `flagtype.EnumDefault` constructor for enums with an initial default value +- New `usage` package with composable help document blocks: `Text`, `Lines`, `List`, `Flags`, and + `Commands` +- `Help` function for rendering the resolved command help document +- `Cmd` field on `State` for accessing the terminal command selected by parsing +- `UsageErrorf` for opt-in usage errors; `Run` prints command help to stderr before returning the + underlying error + +### Changed + +- **BREAKING**: Replace `Command.UsageFunc` with `Command.Help`, which receives the built-in + `usage.Help` document and returns the customized document +- **BREAKING**: Custom help now composes `usage.Help` documents instead of string-concatenating + default usage text +- **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to + `Command.FlagConfigs` +- Help output is now built through the `usage` package while preserving the default automatic + `--help` behavior + +### Removed + +- **BREAKING**: Remove `DefaultUsage` and `Usage`; use `Help(cmd).String()` for direct rendering +- **BREAKING**: Remove usage-related `State` helpers: `Command`, `CommandPath`, `Usage`, and + `UsageErrorf`; use `State.Cmd`, `Command.Path`, `Help`, and top-level `UsageErrorf` instead ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index df2d4c1..41db788 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ resolved command. For applications that need work between parsing and execution, ## Flags -`FlagsFunc` is a convenience for defining flags inline. Use `FlagOptions` to extend the standard +`FlagsFunc` is a convenience for defining flags inline. Use `FlagConfigs` to extend the standard `flag` package with features like required flag enforcement and short aliases: ```go @@ -49,7 +49,7 @@ Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.String("output", "", "output file") }), -FlagOptions: []cli.FlagOption{ +FlagConfigs: []cli.FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o", Required: true}, }, @@ -95,8 +95,31 @@ example](examples/cmd/task/). ## Help -Help text is generated automatically and displayed when `--help` is passed. To customize it, set the -`UsageFunc` field on a command. +Help text is generated automatically and displayed when `--help` is passed. To customize it, set +the `Help` field on a command: + +```go +Help: func(c *cli.Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "greet margo")) +}, +``` + +Inside `Exec`, `State` exposes the resolved command as `Cmd`, so usage errors can stay explicit: + +```go +Exec: func(ctx context.Context, s *cli.State) error { + if len(s.Args) == 0 { + return cli.UsageErrorf("must supply a name") + } + fmt.Fprintf(s.Stdout, "hello, %s\n", s.Args[0]) + return nil +}, +``` + +`UsageErrorf` is opt-in: `Run` prints the resolved command's help to stderr before returning the +underlying error. Normal errors are returned unchanged. + +For command-aware errors, use `s.Cmd.Path()` to get the resolved command path. ## Usage Syntax diff --git a/command.go b/command.go index 07a12ee..ed96413 100644 --- a/command.go +++ b/command.go @@ -7,55 +7,64 @@ import ( "strings" "github.com/pressly/cli/pkg/suggest" + "github.com/pressly/cli/usage" ) -// ErrHelp is returned by [Parse] when the -help or -h flag is invoked. It is identical to -// [flag.ErrHelp] but re-exported here so callers using [Parse] and [Run] separately do not need to -// import the flag package solely for error checking. +// ErrHelp is returned by [Parse] when a help flag is present. // -// Note: [ParseAndRun] handles this automatically and never surfaces ErrHelp to the caller. +// [ParseAndRun] handles ErrHelp automatically by printing [Help] to stdout and returning nil. +// Callers that use [Parse] and [Run] separately can check errors.Is(err, ErrHelp) and render help +// themselves. var ErrHelp = flag.ErrHelp -// Command represents a CLI command or subcommand within the application's command hierarchy. +// Command defines one command in a CLI. +// +// A command can be the root command passed to [ParseAndRun], or a subcommand listed in +// [Command.SubCommands]. Most programs define Name, optional help fields, optional flags, and Exec. type Command struct { - // Name is always a single word representing the command's name. It is used to identify the - // command in the command hierarchy and in help text. + // Name is the single word users type to select the command. Name string - // Usage provides the command's full usage pattern. + // Usage overrides the generated usage line when the command needs a custom synopsis. // // Example: "cli todo list [flags]" Usage string - // ShortHelp is a brief description of the command's purpose. It is displayed in the help text - // when the command is shown. + // ShortHelp describes the command in help output and parent command listings. ShortHelp string - // UsageFunc is an optional function that can be used to generate a custom usage string for the - // command. It receives the current command and should return a string with the full usage - // pattern. - UsageFunc func(*Command) string + // Help customizes the command's help document. + // + // Leave Help nil for the built-in help. Set it when you want to append examples, reorder + // sections, or replace the document entirely. The function receives the command being shown and + // the built-in document. + Help func(*Command, usage.Help) usage.Help - // Flags holds the command-specific flag definitions. Each command maintains its own flag set - // for parsing arguments. + // Flags defines the command's flags using the standard library flag package. Flags *flag.FlagSet - // FlagOptions is an optional list of flag options to extend the FlagSet with additional - // behavior. This is useful for tracking required flags, short aliases, and local flags. - FlagOptions []FlagOption - // SubCommands is a list of nested commands that exist under this command. + // FlagConfigs adds cli-specific behavior to flags already defined in Flags. + // + // Use it for required flags, short aliases, and flags that should not be inherited by + // subcommands. + FlagConfigs []FlagConfig + + // SubCommands lists commands users can select after this command's name. SubCommands []*Command - // Exec defines the command's execution logic. It receives the current application [State] and - // returns an error if execution fails. This function is called when [Run] is invoked on the - // command. + // Exec runs after parsing selects this command. + // + // Return [UsageErrorf] for invalid args or flag combinations so Run can print help. Return a + // normal error for operational failures. Exec func(ctx context.Context, s *State) error state *State } -// Path returns the command chain from root to current command. It can only be called after the root -// command has been parsed and the command hierarchy has been established. +// Path returns the parsed command chain from root to this command. +// +// Call Path after [Parse] when command logic needs to inspect where the selected command sits in +// the command tree. func (c *Command) Path() []*Command { if c.state == nil { return nil @@ -71,26 +80,22 @@ func (c *Command) terminal() *Command { return c.state.path[len(c.state.path)-1] } -// FlagOption holds additional options for a flag, such as whether it is required or has a short -// alias. -type FlagOption struct { - // Name is the flag's name. Must match the flag name in the flag set. +// FlagConfig adds cli-specific behavior to a flag defined in a command's FlagSet. +type FlagConfig struct { + // Name is the flag's long name as registered in the command's FlagSet. 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 lets users type a one-letter alias, such as -v for --verbose. Short string - // Required indicates whether the flag is required. + // Required requires users to provide the flag explicitly. Required bool - // Local indicates that the flag should not be inherited by child commands. When true, the flag - // is only available on the command that defines it. + // Local keeps the flag on this command instead of inheriting it into subcommands. Local bool } -// FlagsFunc is a helper function that creates a new [flag.FlagSet] and applies the given function -// to it. Intended for use in command definitions to simplify flag setup. Example usage: +// FlagsFunc creates a FlagSet inline for a command definition. // // cmd.Flags = cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("verbose", false, "enable verbose output") diff --git a/error.go b/error.go new file mode 100644 index 0000000..eb9cadb --- /dev/null +++ b/error.go @@ -0,0 +1,29 @@ +package cli + +import "fmt" + +// UsageError marks an invalid command invocation. +// +// You normally create one with [UsageErrorf] rather than constructing this type directly. +type UsageError struct { + err error +} + +// UsageErrorf returns an error for invalid command-line usage. +// +// Return UsageErrorf from Exec when the command was selected successfully but the remaining args or +// flag combination are invalid. [Run] prints Help(s.Cmd) to stderr, then returns the formatted error +// without the UsageError wrapper. +func UsageErrorf(format string, args ...any) error { + return &UsageError{err: fmt.Errorf(format, args...)} +} + +// Error returns the formatted usage error message. +func (e *UsageError) Error() string { + return e.err.Error() +} + +// Unwrap exposes the formatted error for errors.Is and errors.As. +func (e *UsageError) Unwrap() error { + return e.err +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..3751e2c --- /dev/null +++ b/error_test.go @@ -0,0 +1,19 @@ +package cli + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUsageError(t *testing.T) { + t.Parallel() + + err := UsageErrorf("missing %s", "name") + require.EqualError(t, err, "missing name") + + var usageErr *UsageError + require.True(t, errors.As(err, &usageErr)) + require.EqualError(t, errors.Unwrap(err), "missing name") +} diff --git a/examples/cmd/echo/main.go b/examples/cmd/echo/main.go index b5f0310..7b1812d 100644 --- a/examples/cmd/echo/main.go +++ b/examples/cmd/echo/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "flag" "fmt" "os" @@ -20,12 +19,12 @@ func main() { // Add a flag to capitalize the input f.Bool("c", false, "capitalize the input") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "c", Required: true}, }, Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { - return errors.New("must provide text to echo, see --help") + return cli.UsageErrorf("must provide text to echo") } output := strings.Join(s.Args, " ") // If -c flag is set, capitalize the output diff --git a/examples/cmd/task/main.go b/examples/cmd/task/main.go index 48ed76c..8799fc6 100644 --- a/examples/cmd/task/main.go +++ b/examples/cmd/task/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "context" - "errors" "flag" "fmt" "os" @@ -12,6 +11,7 @@ import ( "time" "github.com/pressly/cli" + "github.com/pressly/cli/usage" ) func main() { @@ -23,13 +23,15 @@ func main() { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") }), + Help: func(c *cli.Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "todo list today --file tasks.json", "todo task add --file tasks.json \"write docs\"")) + }, Exec: func(ctx context.Context, s *cli.State) error { if cli.GetFlag[bool](s, "version") { fmt.Fprintf(s.Stdout, "todo v1.0.0\n") return nil } - fmt.Fprintf(s.Stderr, "todo: subcommand required, use --help for more information\n") - return nil + return cli.UsageErrorf("subcommand required") }, SubCommands: []*cli.Command{ list(), @@ -52,12 +54,11 @@ func list() *cli.Command { f.String("file", "", "path to the tasks file") f.String("tags", "", "filter tasks by tags") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, Exec: func(ctx context.Context, s *cli.State) error { - fmt.Fprintf(s.Stderr, "todo list: subcommand required, use --help for more information\n") - return nil + return cli.UsageErrorf("subcommand required") }, SubCommands: []*cli.Command{ listToday(), @@ -126,7 +127,7 @@ func task() *cli.Command { Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("file", "", "path to the tasks file") }), - FlagOptions: []cli.FlagOption{ + FlagConfigs: []cli.FlagConfig{ {Name: "file", Required: true}, }, ShortHelp: "Manage tasks", @@ -184,7 +185,7 @@ func taskDone() *cli.Command { ShortHelp: "Mark a task as done", Exec: func(ctx context.Context, s *cli.State) error { if len(s.Args) == 0 { - return errors.New("task ID required") + return cli.UsageErrorf("task ID required") } tasks, err := getTasksFromFile(s) if err != nil { @@ -216,7 +217,7 @@ func taskRemove() *cli.Command { file = cli.GetFlag[string](s, "file") ) if len(s.Args) == 0 && !all { - return errors.New("task ID required, or use --all to remove all tasks") + return cli.UsageErrorf("task ID required, or use --all to remove all tasks") } if all { if !force { diff --git a/parse.go b/parse.go index df65291..ec17d87 100644 --- a/parse.go +++ b/parse.go @@ -13,12 +13,11 @@ import ( "github.com/pressly/cli/xflag" ) -// Parse traverses the command hierarchy and parses arguments. It returns an error if parsing fails -// at any point. +// Parse resolves a command and parses its flags without running it. // -// This function is the main entry point for parsing command-line arguments and should be called -// with the root command and the arguments to parse, typically os.Args[1:]. Once parsing is -// complete, the root command is ready to be executed with the [Run] function. +// Most programs should use [ParseAndRun]. Use Parse directly when you need to inspect parsed flags +// or initialize resources before calling [Run]. If the user asks for help, Parse returns [ErrHelp] +// after resolving the command so [Help] can render the right command document. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") @@ -27,15 +26,17 @@ func Parse(root *Command, args []string) error { return fmt.Errorf("failed to parse: %w", err) } - // Initialize or update root state - if root.state == nil { - root.state = &State{ - path: []*Command{root}, - } - } else { - // Reset command path but preserve other state - root.state.path = []*Command{root} + // Initialize or update root state. Clear command pointers across the tree first so stale + // subcommands from a previous parse do not retain the newly resolved path. + state := root.state + clearCommandState(root) + if state == nil { + state = &State{} } + root.state = state + root.state.Args = nil + root.state.Cmd = nil + root.state.path = []*Command{root} argsToParse, remainingArgs := splitAtDelimiter(args) @@ -43,6 +44,7 @@ func Parse(root *Command, args []string) error { if err != nil { return err } + root.state.Cmd = current current.Flags.Usage = func() { /* suppress default usage */ } // Check for help flags after resolving the correct command @@ -106,11 +108,11 @@ 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. Also check short flag aliases from FlagOptions. + // anywhere. Also check short flag aliases from FlagConfigs. name := strings.TrimLeft(arg, "-") skipValue := false for _, cmd := range root.state.path { - localFlags := localFlagSet(cmd.FlagOptions) + localFlags := localFlagSet(cmd.FlagConfigs) // Skip local flags on ancestor commands (any command already in the path is an // ancestor of the not-yet-resolved terminal command). if localFlags[name] { @@ -120,7 +122,7 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { f := cmd.Flags.Lookup(name) // If not found, check if it's a short alias. if f == nil { - for _, fm := range cmd.FlagOptions { + for _, fm := range cmd.FlagConfigs { if fm.Short == name { if localFlags[fm.Name] { break @@ -150,6 +152,7 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { if len(current.SubCommands) > 0 { if sub := current.findSubCommand(arg); sub != nil { root.state.path = append(slices.Clone(root.state.path), sub) + sub.state = root.state if sub.Flags == nil { sub.Flags = flag.NewFlagSet(sub.Name, flag.ContinueOnError) } @@ -164,9 +167,19 @@ func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) { return current, nil } +func clearCommandState(cmd *Command) { + if cmd == nil { + return + } + cmd.state = nil + for _, sub := range cmd.SubCommands { + clearCommandState(sub) + } +} + // 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. Short flag -// aliases from FlagOptions are also registered, sharing the same Value as their long counterpart. +// aliases from FlagConfigs 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) @@ -176,8 +189,8 @@ func combineFlags(path []*Command) *flag.FlagSet { if cmd.Flags == nil { continue } - localFlags := localFlagSet(cmd.FlagOptions) - shortMap := shortFlagMap(cmd.FlagOptions) + localFlags := localFlagSet(cmd.FlagConfigs) + shortMap := shortFlagMap(cmd.FlagConfigs) isAncestor := i < terminalIdx cmd.Flags.VisitAll(func(f *flag.Flag) { // Skip local flags from ancestor commands — they are not inherited. @@ -198,8 +211,8 @@ func combineFlags(path []*Command) *flag.FlagSet { return combined } -// localFlagSet builds a set of flag names that are marked as local in FlagOptions. -func localFlagSet(options []FlagOption) map[string]bool { +// localFlagSet builds a set of flag names that are marked as local in FlagConfigs. +func localFlagSet(options []FlagConfig) map[string]bool { m := make(map[string]bool, len(options)) for _, fm := range options { if fm.Local { @@ -209,8 +222,8 @@ func localFlagSet(options []FlagOption) map[string]bool { return m } -// shortFlagMap builds a map from long flag name to short alias from FlagOptions. -func shortFlagMap(options []FlagOption) map[string]string { +// shortFlagMap builds a map from long flag name to short alias from FlagConfigs. +func shortFlagMap(options []FlagConfig) map[string]string { m := make(map[string]string, len(options)) for _, fm := range options { if fm.Short != "" { @@ -220,7 +233,7 @@ func shortFlagMap(options []FlagOption) map[string]string { return m } -// checkRequiredFlags verifies that all flags marked as required in FlagOptions were explicitly set +// checkRequiredFlags verifies that all flags marked as required in FlagConfigs were explicitly set // during parsing. func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error { // Build a set of flags that were explicitly set during parsing. Visit (unlike VisitAll) only @@ -233,7 +246,7 @@ func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error { terminalIdx := len(path) - 1 var missingFlags []string for i, cmd := range path { - for _, fo := range cmd.FlagOptions { + for _, fo := range cmd.FlagConfigs { if !fo.Required { continue } @@ -312,7 +325,7 @@ func validateCommands(root *Command, path []string) error { return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err) } - if err := validateFlagOptions(root); err != nil { + if err := validateFlagConfigs(root); err != nil { quoted := make([]string, len(currentPath)) for i, p := range currentPath { quoted[i] = strconv.Quote(p) @@ -328,17 +341,17 @@ func validateCommands(root *Command, path []string) error { return nil } -// validateFlagOptions checks that each FlagOption entry refers to a flag that exists in the +// validateFlagConfigs checks that each FlagConfig 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 validateFlagOptions(cmd *Command) error { - if len(cmd.FlagOptions) == 0 { +func validateFlagConfigs(cmd *Command) error { + if len(cmd.FlagConfigs) == 0 { return nil } seenShorts := make(map[string]string) // short -> flag name - for _, fm := range cmd.FlagOptions { + for _, fm := range cmd.FlagConfigs { if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil { - return fmt.Errorf("flag option references unknown flag %q", fm.Name) + return fmt.Errorf("flag config references unknown flag %q", fm.Name) } if fm.Short == "" { continue diff --git a/parse_test.go b/parse_test.go index 920edc0..7b84585 100644 --- a/parse_test.go +++ b/parse_test.go @@ -38,7 +38,7 @@ func newTestState() testState { Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("echo", "", "echo the message") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "echo", Required: false}, // not required }, Exec: exec, @@ -49,7 +49,7 @@ func newTestState() testState { fset.Bool("mandatory-flag", false, "mandatory flag") fset.String("another-mandatory-flag", "", "another mandatory flag") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "mandatory-flag", Required: true}, {Name: "another-mandatory-flag", Required: true}, }, @@ -362,13 +362,13 @@ func TestParse(t *testing.T) { t.Parallel() cmd := &Command{ Name: "root", - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "some-other-flag", Required: true}, }, } err := Parse(cmd, nil) require.Error(t, err) - require.ErrorContains(t, err, `flag option references unknown flag "some-other-flag"`) + require.ErrorContains(t, err, `flag config references unknown flag "some-other-flag"`) }) t.Run("space in command name", func(t *testing.T) { t.Parallel() @@ -552,14 +552,14 @@ func TestParse(t *testing.T) { require.NoError(t, err) // Just ensure it doesn't crash and can parse the first match }) - t.Run("flag option for non-existent flag", func(t *testing.T) { + t.Run("flag config for non-existent flag", func(t *testing.T) { t.Parallel() cmd := &Command{ Name: "root", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("existing", "", "existing flag") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "existing", Required: true}, {Name: "nonexistent", Required: true}, }, @@ -567,7 +567,7 @@ func TestParse(t *testing.T) { } err := Parse(cmd, []string{"--existing=value"}) require.Error(t, err) - require.ErrorContains(t, err, `flag option references unknown flag "nonexistent"`) + require.ErrorContains(t, err, `flag config references unknown flag "nonexistent"`) }) t.Run("args with special characters", func(t *testing.T) { t.Parallel() @@ -645,7 +645,7 @@ func TestParse(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("port", "8080", "port number") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "port", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -665,7 +665,7 @@ func TestParse(t *testing.T) { f.Bool("force", false, "force operation") f.Bool("force-all", false, "force all") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "force", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -705,7 +705,7 @@ func TestShortFlags(t *testing.T) { f.Bool("verbose", false, "enable verbose output") f.String("output", "", "output file") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o"}, }, @@ -724,7 +724,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -741,7 +741,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("name", "", "the name") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "name", Short: "n"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -751,7 +751,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "verbose") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, }, SubCommands: []*Command{child}, @@ -770,7 +770,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Int("count", 0, "number of items") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "count", Short: "c"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -789,14 +789,14 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {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 option references unknown flag "vrbose"`) + require.Contains(t, err.Error(), `flag config references unknown flag "vrbose"`) }) t.Run("short alias must be single ASCII letter", func(t *testing.T) { @@ -806,7 +806,7 @@ func TestShortFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "vv"}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -824,7 +824,7 @@ func TestShortFlags(t *testing.T) { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "version", Short: "v"}, }, @@ -851,7 +851,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{child}, @@ -869,7 +869,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{{ @@ -890,7 +890,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -911,7 +911,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("token", "", "auth token") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "token", Required: true, Local: true}, }, SubCommands: []*Command{child}, @@ -927,7 +927,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.String("token", "", "auth token") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "token", Required: true, Local: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -952,7 +952,7 @@ func TestLocalFlags(t *testing.T) { f.Bool("version", false, "show version") f.Bool("verbose", false, "enable verbose output") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Local: true}, }, SubCommands: []*Command{child}, @@ -961,7 +961,7 @@ func TestLocalFlags(t *testing.T) { err := Parse(root, []string{"child", "--help"}) require.ErrorIs(t, err, flag.ErrHelp) - usage := DefaultUsage(root) + usage := Help(root).String() // --verbose should appear in inherited flags (not local) assert.Contains(t, usage, "--verbose") // --version should NOT appear (local to root, not inherited) @@ -981,7 +981,7 @@ func TestLocalFlags(t *testing.T) { Flags: FlagsFunc(func(f *flag.FlagSet) { f.Bool("version", false, "show version") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "version", Short: "V", Local: true}, }, SubCommands: []*Command{child}, diff --git a/path_test.go b/path_test.go index 274168e..290d475 100644 --- a/path_test.go +++ b/path_test.go @@ -181,6 +181,8 @@ func TestCommandPath(t *testing.T) { require.Len(t, path, 2) require.Equal(t, "root", path[0].Name) require.Equal(t, "child2", path[1].Name) + require.Nil(t, child1.Path()) + require.Equal(t, path, child2.Path()) }) t.Run("command with complex names in path", func(t *testing.T) { diff --git a/run.go b/run.go index c35cf22..d31f6b0 100644 --- a/run.go +++ b/run.go @@ -13,20 +13,21 @@ import ( "sync" ) -// RunOptions specifies options for running a command. +// RunOptions overrides the standard streams used by [Run] and [ParseAndRun]. +// +// Leave it nil for normal CLI programs. Provide it in tests or embedded applications that need to +// capture output or supply custom input. type RunOptions struct { - // Stdin, Stdout, and Stderr are the standard input, output, and error streams for the command. - // If any of these are nil, the command will use the default streams ([os.Stdin], [os.Stdout], - // and [os.Stderr], respectively). + // Stdin, Stdout, and Stderr replace os.Stdin, os.Stdout, and os.Stderr when set. Stdin io.Reader Stdout, Stderr io.Writer } -// Run executes the current command. It returns an error if the command has not been parsed or if -// the command has no execution function. +// Run executes the command selected by [Parse]. // -// The options parameter may be nil, in which case default values are used. See [RunOptions] for -// more details. +// Use Run only with the split [Parse]/Run flow. [ParseAndRun] is the usual entry point. If Exec +// returns [UsageErrorf], Run prints [Help] for the selected command to stderr and returns the +// underlying error. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -49,27 +50,23 @@ func Run(ctx context.Context, root *Command, options *RunOptions) error { return run(ctx, cmd, root.state) } -// ParseAndRun is a convenience function that combines [Parse] and [Run] into a single call. It -// parses the command hierarchy, handles help flags automatically (printing usage to stdout and -// returning nil), and then executes the resolved command. +// ParseAndRun parses args and runs the selected command. // -// This is the recommended entry point for most CLI applications: +// Use ParseAndRun as the normal entry point for CLI applications. It handles help flags by printing +// [Help] to stdout and returning nil, then runs Exec for the selected command. // // if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { // fmt.Fprintf(os.Stderr, "error: %v\n", err) // os.Exit(1) // } // -// The options parameter may be nil, in which case default values are used. See [RunOptions] for -// more details. -// -// For applications that need to perform work between parsing and execution (e.g., initializing -// resources based on parsed flags), use [Parse] and [Run] separately. +// Use [Parse] and [Run] separately when you need work between parsing and execution, such as +// initializing resources from parsed flags. func ParseAndRun(ctx context.Context, root *Command, args []string, options *RunOptions) error { if err := Parse(root, args); err != nil { if errors.Is(err, ErrHelp) { options = checkAndSetRunOptions(options) - _, _ = fmt.Fprintln(options.Stdout, DefaultUsage(root)) + _, _ = fmt.Fprintln(options.Stdout, Help(root)) return nil } return err @@ -94,7 +91,13 @@ func run(ctx context.Context, cmd *Command, state *State) (retErr error) { } } }() - return cmd.Exec(ctx, state) + err := cmd.Exec(ctx, state) + var usageErr *UsageError + if errors.As(err, &usageErr) { + _, _ = fmt.Fprintln(state.Stderr, Help(state.Cmd)) + return usageErr.Unwrap() + } + return err } func updateState(s *State, opt *RunOptions) { diff --git a/run_test.go b/run_test.go index 4b1639e..66e06f5 100644 --- a/run_test.go +++ b/run_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "flag" + "fmt" "strings" "testing" @@ -229,3 +230,41 @@ func TestRun(t *testing.T) { } }) } + +func TestParseAndRun(t *testing.T) { + t.Parallel() + + t.Run("runs command", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + fmt.Fprintln(s.Stdout, "hello") + return nil + }, + } + + err := ParseAndRun(context.Background(), root, nil, &RunOptions{Stdout: stdout}) + require.NoError(t, err) + require.Equal(t, "hello\n", stdout.String()) + }) + + t.Run("prints help", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + root := &Command{ + Name: "greet", + ShortHelp: "Print a greeting", + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := ParseAndRun(context.Background(), root, []string{"--help"}, &RunOptions{Stdout: stdout}) + require.NoError(t, err) + require.Contains(t, stdout.String(), "Print a greeting") + require.Contains(t, stdout.String(), "Usage:") + require.Contains(t, stdout.String(), "greet") + }) +} diff --git a/state.go b/state.go index e1fa297..0a98104 100644 --- a/state.go +++ b/state.go @@ -1,37 +1,46 @@ package cli import ( + "errors" "flag" "fmt" "io" ) -// State holds command information during Exec function execution, allowing child commands to access -// parent flags. Use [GetFlag] to get flag values across the command hierarchy. +// State is passed to Exec with the parsed invocation context. +// +// Use Args for remaining positional arguments, Stdin/Stdout/Stderr for command I/O, Cmd for the +// selected command, and [GetFlag] to read parsed flag values. type State struct { - // Args contains the remaining arguments after flag parsing. + // Args contains positional arguments left after command and flag parsing. Args []string - // Standard I/O streams. + // Stdin, Stdout, and Stderr are the streams command code should use instead of package-level + // os.Stdin, os.Stdout, and os.Stderr. Stdin io.Reader Stdout, Stderr io.Writer + // Cmd is the command selected by parsing. + Cmd *Command + // path is the command hierarchy from the root command to the current command. The root command // is the first element in the path, and the terminal command is the last element. path []*Command } -// GetFlag retrieves a flag value by name from the command hierarchy. It first checks the current -// command's flags, then walks up through parent commands. +// GetFlag reads a parsed flag value from State. // -// If the flag doesn't exist or if the type doesn't match the requested type T an error will be -// raised in the Run function. This is an internal error and should never happen in normal usage. -// This ensures flag-related programming errors are caught early during development. +// Call GetFlag from Exec with the same Go type used to define the flag. It checks the selected +// command first, then inherited parent flags. A missing flag or wrong type is treated as a +// programming error and returned from [Run]. // // verbose := GetFlag[bool](state, "verbose") // count := GetFlag[int](state, "count") // path := GetFlag[string](state, "path") func GetFlag[T any](s *State, name string) T { + if s == nil { + panic(&internalError{err: errors.New("state is nil")}) + } // Try to find the flag in each command's flag set, starting from the current command for i := len(s.path) - 1; i >= 0; i-- { cmd := s.path[i] diff --git a/state_test.go b/state_test.go index 6e09711..05d91d7 100644 --- a/state_test.go +++ b/state_test.go @@ -1,9 +1,13 @@ package cli import ( + "context" "flag" + "fmt" + "strings" "testing" + "github.com/pressly/cli/usage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,3 +52,126 @@ func TestGetFlag(t *testing.T) { _ = GetFlag[int](state, "version") }) } + +func TestStateCommandContext(t *testing.T) { + t.Parallel() + + t.Run("command and command path", func(t *testing.T) { + t.Parallel() + + child := &Command{ + Name: "child", + Exec: func(ctx context.Context, s *State) error { + require.Equal(t, "child", s.Cmd.Name) + require.Equal(t, []*Command{s.path[0], s.Cmd}, s.Cmd.Path()) + return nil + }, + } + root := &Command{ + Name: "root", + SubCommands: []*Command{child}, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + err = Run(context.Background(), root, nil) + require.NoError(t, err) + }) + + t.Run("usage uses terminal custom usage", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "root", + SubCommands: []*Command{ + { + Name: "child", + Help: func(c *Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "root child file.txt")) + }, + Exec: func(ctx context.Context, s *State) error { + output := Help(s.Cmd).String() + require.Contains(t, output, "Examples:") + require.Contains(t, output, "root child file.txt") + return nil + }, + }, + }, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + err = Run(context.Background(), root, nil) + require.NoError(t, err) + }) + + t.Run("usage error prints help and returns underlying error", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + return UsageErrorf("must supply a name") + }, + } + + err := Parse(root, nil) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "must supply a name") + require.Contains(t, stderr.String(), "Usage:") + require.Contains(t, stderr.String(), "greet") + }) + + t.Run("usage error prints terminal command help", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "root", + Flags: FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + SubCommands: []*Command{ + { + Name: "child", + ShortHelp: "Run the child command", + Exec: func(ctx context.Context, s *State) error { + return UsageErrorf("missing file") + }, + }, + }, + } + + err := Parse(root, []string{"child"}) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "missing file") + require.Contains(t, stderr.String(), "Run the child command") + require.Contains(t, stderr.String(), "root child [flags]") + require.Contains(t, stderr.String(), "Inherited Flags:") + require.Contains(t, stderr.String(), "--verbose") + }) + + t.Run("normal error does not print help", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "greet", + Exec: func(ctx context.Context, s *State) error { + return fmt.Errorf("boom") + }, + } + + err := Parse(root, nil) + require.NoError(t, err) + stderr := new(strings.Builder) + err = Run(context.Background(), root, &RunOptions{Stderr: stderr}) + require.Error(t, err) + require.EqualError(t, err, "boom") + require.Empty(t, stderr.String()) + }) +} diff --git a/usage.go b/usage.go index 58e9b4e..07a808e 100644 --- a/usage.go +++ b/usage.go @@ -7,87 +7,105 @@ import ( "slices" "strings" - "github.com/pressly/cli/pkg/textutil" + "github.com/pressly/cli/usage" ) -// defaultTerminalWidth is the assumed terminal width for wrapping help text. -const defaultTerminalWidth = 80 - -// DefaultUsage returns the default usage string for the command hierarchy. It is used when the -// command does not provide a custom usage function. The usage string includes the command's short -// help, usage pattern, available subcommands, and flags. -func DefaultUsage(root *Command) string { +// Help returns the help document for root's resolved command. +// +// Call Help after Parse when you want to render help yourself, or inside a Command.Help hook when +// composing the default help document. ParseAndRun calls it automatically for --help and UsageErrorf +// errors. +func Help(root *Command) usage.Help { if root == nil { - return "" + return nil } // Get terminal command from state terminalCmd := root.terminal() - var b strings.Builder - - if terminalCmd.UsageFunc != nil { - return terminalCmd.UsageFunc(terminalCmd) - } + var help usage.Help if terminalCmd.ShortHelp != "" { - b.WriteString(terminalCmd.ShortHelp) - b.WriteString("\n\n") + help = append(help, usage.Text(terminalCmd.ShortHelp)) } - b.WriteString("Usage:\n") + flags := collectHelpFlags(root, terminalCmd) + + var usageLine string if terminalCmd.Usage != "" { - b.WriteString(" " + terminalCmd.Usage + "\n") + usageLine = terminalCmd.Usage } else { - usage := terminalCmd.Name + usageLine = terminalCmd.Name if root.state != nil && len(root.state.path) > 0 { - usage = getCommandPath(root.state.path) + usageLine = getCommandPath(root.state.path) } - if terminalCmd.Flags != nil { - usage += " [flags]" + if len(flags) > 0 { + usageLine += " [flags]" } if len(terminalCmd.SubCommands) > 0 { - usage += " " + usageLine += " " } - b.WriteString(" " + usage + "\n") } - b.WriteString("\n") + help = append(help, usage.Lines("Usage:", usageLine)) if len(terminalCmd.SubCommands) > 0 { - b.WriteString("Available Commands:\n") sortedCommands := slices.Clone(terminalCmd.SubCommands) slices.SortFunc(sortedCommands, func(a, b *Command) int { return cmp.Compare(a.Name, b.Name) }) - maxNameLen := 0 + subcommands := make([]usage.Command, 0, len(sortedCommands)) for _, sub := range sortedCommands { - if len(sub.Name) > maxNameLen { - maxNameLen = len(sub.Name) - } + subcommands = append(subcommands, usage.Command{ + Name: sub.Name, + Summary: sub.ShortHelp, + }) } + help = append(help, usage.Commands("Available Commands:", subcommands)) + } - nameWidth := maxNameLen + 4 - wrapWidth := defaultTerminalWidth - nameWidth + if len(flags) > 0 { + slices.SortFunc(flags, func(a, b flagInfo) int { + return cmp.Compare(a.name, b.name) + }) - for _, sub := range sortedCommands { - if sub.ShortHelp == "" { - fmt.Fprintf(&b, " %s\n", sub.Name) - continue + hasLocal := false + hasInherited := false + for _, f := range flags { + if f.inherited { + hasInherited = true + } else { + hasLocal = true } + } - lines := textutil.Wrap(sub.ShortHelp, wrapWidth) - padding := strings.Repeat(" ", maxNameLen-len(sub.Name)+4) - fmt.Fprintf(&b, " %s%s%s\n", sub.Name, padding, lines[0]) + if hasLocal { + help = append(help, usage.Flags("Flags:", usageFlags(flags, false))) + } - indentPadding := strings.Repeat(" ", nameWidth+2) - for _, line := range lines[1:] { - fmt.Fprintf(&b, "%s%s\n", indentPadding, line) - } + if hasInherited { + help = append(help, usage.Flags("Inherited Flags:", usageFlags(flags, true))) + } + } + + if len(terminalCmd.SubCommands) > 0 { + cmdName := terminalCmd.Name + if root.state != nil && len(root.state.path) > 0 { + cmdName = getCommandPath(root.state.path) } - b.WriteString("\n") + help = append(help, usage.Text( + fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", cmdName), + )) } + if terminalCmd.Help != nil { + help = terminalCmd.Help(terminalCmd, help) + } + + return help +} + +func collectHelpFlags(root, terminalCmd *Command) []flagInfo { var flags []flagInfo if root.state != nil && len(root.state.path) > 0 { terminalIdx := len(root.state.path) - 1 @@ -96,7 +114,7 @@ func DefaultUsage(root *Command) string { continue } isInherited := i < terminalIdx - metaMap := flagOptionMap(cmd.FlagOptions) + metaMap := flagConfigMap(cmd.FlagConfigs) cmd.Flags.VisitAll(func(f *flag.Flag) { // Skip local flags from ancestor commands — they don't appear in child help. if isInherited { @@ -120,7 +138,7 @@ func DefaultUsage(root *Command) string { } } else if terminalCmd.Flags != nil { // Pre-parse fallback: show the command's own flags even without state. - metaMap := flagOptionMap(terminalCmd.FlagOptions) + metaMap := flagConfigMap(terminalCmd.FlagConfigs) terminalCmd.Flags.VisitAll(func(f *flag.Flag) { fi := flagInfo{ name: "--" + f.Name, @@ -135,93 +153,34 @@ func DefaultUsage(root *Command) string { flags = append(flags, fi) }) } - - if len(flags) > 0 { - slices.SortFunc(flags, func(a, b flagInfo) int { - return cmp.Compare(a.name, b.name) - }) - - hasAnyShort := false - for _, f := range flags { - if f.short != "" { - hasAnyShort = true - break - } - } - - maxFlagLen := 0 - for _, f := range flags { - if n := len(f.displayName(hasAnyShort)); n > maxFlagLen { - maxFlagLen = n - } - } - - hasLocal := false - hasInherited := false - for _, f := range flags { - if f.inherited { - hasInherited = true - } else { - hasLocal = true - } - } - - if hasLocal { - b.WriteString("Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, false, hasAnyShort) - b.WriteString("\n") - } - - if hasInherited { - b.WriteString("Inherited Flags:\n") - writeFlagSection(&b, flags, maxFlagLen, true, hasAnyShort) - b.WriteString("\n") - } - } - - if len(terminalCmd.SubCommands) > 0 { - cmdName := terminalCmd.Name - if root.state != nil && len(root.state.path) > 0 { - cmdName = getCommandPath(root.state.path) - } - fmt.Fprintf(&b, "Use \"%s [command] --help\" for more information about a command.\n", cmdName) - } - - return strings.TrimRight(b.String(), "\n") + return flags } -// writeFlagSection handles the formatting of flag descriptions -func writeFlagSection(b *strings.Builder, flags []flagInfo, maxLen int, inherited, hasAnyShort bool) { - nameWidth := maxLen + 4 - wrapWidth := defaultTerminalWidth - nameWidth - +func usageFlags(flags []flagInfo, inherited bool) []usage.Flag { + out := make([]usage.Flag, 0, len(flags)) for _, f := range flags { if f.inherited != inherited { continue } - - description := f.usage - if f.required { - description += " (required)" - } else if !isZeroDefault(f.defval, f.typeName) { - description += fmt.Sprintf(" (default: %s)", f.defval) - } - - display := f.displayName(hasAnyShort) - lines := textutil.Wrap(description, wrapWidth) - padding := strings.Repeat(" ", maxLen-len(display)+4) - fmt.Fprintf(b, " %s%s%s\n", display, padding, lines[0]) - - indentPadding := strings.Repeat(" ", nameWidth+2) - for _, line := range lines[1:] { - fmt.Fprintf(b, "%s%s\n", indentPadding, line) + defval := "" + if !f.required && !isZeroDefault(f.defval, f.typeName) { + defval = f.defval } + out = append(out, usage.Flag{ + Name: strings.TrimPrefix(f.name, "--"), + Short: f.short, + Placeholder: f.typeName, + Usage: f.usage, + Default: defval, + Required: f.required, + }) } + return out } -// flagOptionMap builds a lookup map from flag name to its FlagOption. -func flagOptionMap(options []FlagOption) map[string]FlagOption { - m := make(map[string]FlagOption, len(options)) +// flagConfigMap builds a lookup map from flag name to its FlagConfig. +func flagConfigMap(options []FlagConfig) map[string]FlagConfig { + m := make(map[string]FlagConfig, len(options)) for _, fm := range options { m[fm.Name] = fm } @@ -238,24 +197,6 @@ type flagInfo struct { required bool } -// displayName returns the flag name with optional short alias and type hint. When hasAnyShort is -// true, flags without a short alias are padded to align with those that have one. Examples: "-v, -// --verbose", "-o, --output string", " --config string", "--debug". -func (f flagInfo) displayName(hasAnyShort bool) string { - var name string - if f.short != "" { - name = "-" + f.short + ", " + f.name - } else if hasAnyShort { - name = " " + f.name - } else { - name = f.name - } - if f.typeName == "" { - return name - } - return name + " " + f.typeName -} - // flagTypeName returns a short type name for a flag's value. Bool flags return "" since their type // is obvious from usage. This mirrors the approach used by Go's flag.PrintDefaults. func flagTypeName(f *flag.Flag) string { diff --git a/usage/command.go b/usage/command.go new file mode 100644 index 0000000..5580156 --- /dev/null +++ b/usage/command.go @@ -0,0 +1,19 @@ +package usage + +// Command describes one command row in a help document. +type Command struct { + Name string + Summary string +} + +// Commands returns a help section for subcommands. +func Commands(heading string, commands []Command) Block { + items := make([]Item, 0, len(commands)) + for _, cmd := range commands { + items = append(items, Item{ + Name: cmd.Name, + Summary: cmd.Summary, + }) + } + return List(heading, items...) +} diff --git a/usage/flag.go b/usage/flag.go new file mode 100644 index 0000000..a39e1d7 --- /dev/null +++ b/usage/flag.go @@ -0,0 +1,71 @@ +package usage + +import "fmt" + +// Flag describes one flag row in a help document. +type Flag struct { + // Name is the long flag name without dashes, such as "verbose". + Name string + + // Short is the optional short flag name without dashes, such as "v". + Short string + + // Placeholder is shown after non-boolean flags, such as "string" or "int". + Placeholder string + + // Usage describes what the flag changes. + Usage string + + // Default is shown when the default is useful to users. + Default string + + // Required marks the flag as required in help output. + Required bool +} + +// Flags returns a help section for flag rows. +func Flags(heading string, flags []Flag) Block { + hasShort := false + for _, f := range flags { + if f.Short != "" { + hasShort = true + break + } + } + items := make([]Item, 0, len(flags)) + for _, f := range flags { + items = append(items, Item{ + Name: f.Spec(hasShort), + Summary: f.Description(), + }) + } + return List(heading, items...) +} + +// Spec returns the flag spelling shown in help output. +func (f Flag) Spec(padShort bool) string { + var name string + if f.Short != "" { + name = "-" + f.Short + ", --" + f.Name + } else if padShort { + name = " --" + f.Name + } else { + name = "--" + f.Name + } + if f.Placeholder == "" { + return name + } + return name + " " + f.Placeholder +} + +// Description returns the help text shown after the flag spelling. +func (f Flag) Description() string { + description := f.Usage + if f.Required { + return description + " (required)" + } + if f.Default != "" { + return fmt.Sprintf("%s (default: %s)", description, f.Default) + } + return description +} diff --git a/usage/flag_test.go b/usage/flag_test.go new file mode 100644 index 0000000..01ee25f --- /dev/null +++ b/usage/flag_test.go @@ -0,0 +1,23 @@ +package usage + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFlagSpec(t *testing.T) { + t.Parallel() + + require.Equal(t, "--verbose", Flag{Name: "verbose"}.Spec(false)) + require.Equal(t, " --config string", Flag{Name: "config", Placeholder: "string"}.Spec(true)) + require.Equal(t, "-o, --output string", Flag{Name: "output", Short: "o", Placeholder: "string"}.Spec(false)) +} + +func TestFlagDescription(t *testing.T) { + t.Parallel() + + require.Equal(t, "enable verbose output", Flag{Usage: "enable verbose output"}.Description()) + require.Equal(t, "output file (default: stdout)", Flag{Usage: "output file", Default: "stdout"}.Description()) + require.Equal(t, "path to file (required)", Flag{Usage: "path to file", Required: true, Default: "ignored"}.Description()) +} diff --git a/usage/help.go b/usage/help.go new file mode 100644 index 0000000..4e24241 --- /dev/null +++ b/usage/help.go @@ -0,0 +1,146 @@ +// Package usage provides small building blocks for command help documents. +// +// Use this package from a cli.Command Help hook when the default help needs examples, extra +// sections, or a different layout. +package usage + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// Help is an ordered list of blocks that can be rendered as command help. +type Help []Block + +// Text returns an untitled paragraph block. +// +// Use Text for descriptions, notes, or closing hints. +func Text(lines ...string) Block { + return Block{lines: lines} +} + +// Lines returns a titled block of indented lines. +// +// Use Lines for sections such as Usage or Examples where each line should stand on its own. +func Lines(heading string, lines ...string) Block { + return Block{Heading: heading, lines: lines, indent: true} +} + +// List returns a titled list of name/summary pairs. +// +// Use List for aligned sections such as commands, flags, or named examples. +func List(heading string, items ...Item) Block { + return Block{Heading: heading, items: items} +} + +// String renders the full help document as a string. +// +// Use String in tests or when you want to pass help text to an API that expects a string. +func (h Help) String() string { + var b strings.Builder + _, _ = h.WriteTo(&b) + return strings.TrimRight(b.String(), "\n") +} + +// WriteTo writes the help document to w. +// +// Use WriteTo when streaming help directly to stdout, stderr, or another writer. +func (h Help) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + for i, block := range h { + if i > 0 { + if _, err := fmt.Fprintln(cw); err != nil { + return cw.n, err + } + } + if _, err := block.WriteTo(cw); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +// Block is one section in a help document. +type Block struct { + // Heading is rendered above the block when set, such as "Usage:" or "Examples:". + Heading string + + lines []string + indent bool + items []Item +} + +// String renders the block as a string. +func (b Block) String() string { + var s strings.Builder + _, _ = b.WriteTo(&s) + return strings.TrimRight(s.String(), "\n") +} + +// WriteTo writes the block to w. +func (b Block) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + if b.Heading != "" { + if _, err := fmt.Fprintln(cw, b.Heading); err != nil { + return cw.n, err + } + } + if len(b.items) > 0 { + if _, err := writeItems(cw, b.items); err != nil { + return cw.n, err + } + } + for _, line := range b.lines { + if b.indent { + line = " " + line + } + if _, err := fmt.Fprintln(cw, line); err != nil { + return cw.n, err + } + } + return cw.n, nil +} + +// Item is one row in a List block. +type Item struct { + Name string + Summary string +} + +func writeItems(w io.Writer, items []Item) (int64, error) { + cw := &countWriter{w: w} + var b bytes.Buffer + tw := tabwriter.NewWriter(&b, 0, 0, 4, ' ', 0) + for _, item := range items { + if item.Summary == "" { + if _, err := fmt.Fprintf(tw, " %s\n", item.Name); err != nil { + return cw.n, err + } + continue + } + if _, err := fmt.Fprintf(tw, " %s\t%s\n", item.Name, item.Summary); err != nil { + return cw.n, err + } + } + if err := tw.Flush(); err != nil { + return cw.n, err + } + if _, err := cw.Write(b.Bytes()); err != nil { + return cw.n, err + } + return cw.n, nil +} + +type countWriter struct { + w io.Writer + n int64 +} + +func (w *countWriter) Write(p []byte) (int, error) { + n, err := w.w.Write(p) + w.n += int64(n) + return n, err +} diff --git a/usage/help_test.go b/usage/help_test.go new file mode 100644 index 0000000..433846c --- /dev/null +++ b/usage/help_test.go @@ -0,0 +1,49 @@ +package usage + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHelpString(t *testing.T) { + t.Parallel() + + h := Help{ + Text("print a greeting"), + Lines("Usage:", "greet [flags] "), + Flags("Flags:", []Flag{ + {Name: "verbose", Short: "v", Usage: "enable verbose output"}, + {Name: "output", Placeholder: "string", Usage: "output file", Required: true}, + }), + Commands("Available Commands:", []Command{ + {Name: "hello", Summary: "print hello"}, + }), + } + + output := h.String() + require.Contains(t, output, "print a greeting") + require.Contains(t, output, "Usage:") + require.Contains(t, output, "greet [flags] ") + require.Contains(t, output, "-v, --verbose") + require.Contains(t, output, "--output string") + require.Contains(t, output, "output file (required)") + require.Contains(t, output, "Available Commands:") + require.Contains(t, output, "hello") + require.False(t, strings.HasSuffix(output, "\n")) +} + +func TestBlockString(t *testing.T) { + t.Parallel() + + output := Lines("Examples:", "greet margo").String() + require.Equal(t, "Examples:\n greet margo", output) +} + +func TestListWithoutSummary(t *testing.T) { + t.Parallel() + + output := List("Commands:", Item{Name: "serve"}).String() + require.Equal(t, "Commands:\n serve", output) +} diff --git a/usage_test.go b/usage_test.go index 8bacfb0..b1b4f37 100644 --- a/usage_test.go +++ b/usage_test.go @@ -5,6 +5,7 @@ import ( "flag" "testing" + "github.com/pressly/cli/usage" "github.com/stretchr/testify/require" ) @@ -22,10 +23,13 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotEmpty(t, output) require.Contains(t, output, "simple") require.Contains(t, output, "Usage:") + require.Contains(t, output, " simple") + require.NotContains(t, output, "[flags]") + require.NotContains(t, output, "Flags:") }) t.Run("usage with flags", func(t *testing.T) { @@ -44,8 +48,9 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "withflags") + require.Contains(t, output, "withflags [flags]") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config") require.Contains(t, output, "-count") @@ -69,13 +74,15 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "parent") require.Contains(t, output, "child1") require.Contains(t, output, "child2") require.Contains(t, output, "first child command") require.Contains(t, output, "second child command") require.Contains(t, output, "Available Commands:") + require.Contains(t, output, "parent ") + require.NotContains(t, output, "[flags]") }) t.Run("usage with flags and subcommands", func(t *testing.T) { @@ -103,7 +110,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "complex") require.Contains(t, output, "complex command with flags and subcommands") require.Contains(t, output, "-global") @@ -128,7 +135,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "longdesc") require.Contains(t, output, "very long description") require.Contains(t, output, "-long-flag") @@ -149,7 +156,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "globalonly") require.Contains(t, output, "-debug") require.Contains(t, output, "-output") @@ -178,7 +185,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "manychildren") for i := 0; i < 10; i++ { require.Contains(t, output, "cmd"+string(rune('0'+i))) @@ -197,7 +204,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "empty") require.NotEmpty(t, output) }) @@ -226,7 +233,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(root, []string{}) require.NoError(t, err) - output := DefaultUsage(root) + output := Help(root).String() require.Contains(t, output, "root") require.Contains(t, output, "root command") require.Contains(t, output, "parent") @@ -253,7 +260,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "-bool-flag") require.Contains(t, output, "-string-flag") require.Contains(t, output, "-int-flag") @@ -274,14 +281,14 @@ func TestUsageGeneration(t *testing.T) { fset.Bool("debug", false, "enable debug mode") fset.String("config", "", "config file path") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "config", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, } // Usage should work even before parsing and show flags - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotEmpty(t, output) require.Contains(t, output, "Flags:") require.Contains(t, output, "-debug") @@ -301,10 +308,51 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "custom [options] ") }) + t.Run("help hook composes default document", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + ShortHelp: "custom command", + Help: func(c *Command, h usage.Help) usage.Help { + return append(h, usage.Lines("Examples:", "custom example")) + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := Help(cmd).String() + require.Contains(t, output, "custom command") + require.Contains(t, output, "Examples:") + require.Contains(t, output, "custom example") + }) + + t.Run("help hook replaces default document", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + Help: func(c *Command, h usage.Help) usage.Help { + return usage.Help{ + usage.Text("custom help"), + } + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := Help(cmd).String() + require.Equal(t, "custom help", output) + }) + t.Run("usage with inherited and local flags", func(t *testing.T) { t.Parallel() @@ -326,7 +374,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(parent, []string{"child"}) require.NoError(t, err) - output := DefaultUsage(parent) + output := Help(parent).String() require.Contains(t, output, "-local") require.Contains(t, output, "-global") require.Contains(t, output, "local flag") @@ -334,7 +382,7 @@ func TestUsageGeneration(t *testing.T) { }) } -func TestWriteFlagSection(t *testing.T) { +func TestFlagHelp(t *testing.T) { t.Parallel() t.Run("non-zero defaults shown and type hints", func(t *testing.T) { @@ -353,7 +401,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "Flags:") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config string") @@ -384,7 +432,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Zero-value defaults should not appear require.NotContains(t, output, "(default: false)") require.NotContains(t, output, "(default: 0)") @@ -406,7 +454,7 @@ func TestWriteFlagSection(t *testing.T) { fset.String("file", "", "path to file") fset.String("output", "stdout", "output destination") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "file", Required: true}, }, Exec: func(ctx context.Context, s *State) error { return nil }, @@ -415,7 +463,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{"-file", "test.txt"}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.Contains(t, output, "(required)") // Required flag should not also show a default require.NotContains(t, output, "(default: )") @@ -433,7 +481,7 @@ func TestWriteFlagSection(t *testing.T) { fset.String("output", "", "output file") fset.String("config", "", "config file path") }), - FlagOptions: []FlagOption{ + FlagConfigs: []FlagConfig{ {Name: "verbose", Short: "v"}, {Name: "output", Short: "o"}, }, @@ -443,7 +491,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Flags with short aliases show both forms require.Contains(t, output, "-v, --verbose") require.Contains(t, output, "-o, --output string") @@ -466,7 +514,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() // Without any short flags, no extra padding should be added require.Contains(t, output, " --verbose") require.Contains(t, output, " --config string") @@ -485,7 +533,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := Help(cmd).String() require.NotContains(t, output, "Flags:") require.NotContains(t, output, "Inherited Flags:") }) diff --git a/xflag/parse.go b/xflag/parse.go index 7284d76..0bbcea5 100644 --- a/xflag/parse.go +++ b/xflag/parse.go @@ -32,7 +32,7 @@ func ParseToEnd(f *flag.FlagSet, arguments []string) error { // // If you want to treat an unknown flag as a positional argument. For example: // - // $ ./cmd --valid=true arg1 --unknown-flag=foo arg2 + // $ ./cmd --valid=true arg1 --unknown-flag=foo arg2 // // Right now, this will trigger an error. But *some* users might want that unknown flag to // be treated as a positional argument. It's trivial to add this behavior, by using VisitAll From 4b5d9589db2bc6699d7e1f630731d4acc63cb692 Mon Sep 17 00:00:00 2001 From: Mike Fridman Date: Sun, 26 Apr 2026 11:18:16 +0200 Subject: [PATCH 2/2] fix lint --- run_test.go | 4 ++-- usage/command.go | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/run_test.go b/run_test.go index 66e06f5..c35e012 100644 --- a/run_test.go +++ b/run_test.go @@ -241,8 +241,8 @@ func TestParseAndRun(t *testing.T) { root := &Command{ Name: "greet", Exec: func(ctx context.Context, s *State) error { - fmt.Fprintln(s.Stdout, "hello") - return nil + _, err := fmt.Fprintln(s.Stdout, "hello") + return err }, } diff --git a/usage/command.go b/usage/command.go index 5580156..fb693a9 100644 --- a/usage/command.go +++ b/usage/command.go @@ -10,10 +10,7 @@ type Command struct { func Commands(heading string, commands []Command) Block { items := make([]Item, 0, len(commands)) for _, cmd := range commands { - items = append(items, Item{ - Name: cmd.Name, - Summary: cmd.Summary, - }) + items = append(items, Item(cmd)) } return List(heading, items...) }