diff --git a/.gitignore b/.gitignore index f68fd60..3fec32c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -internal/ tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c67389..9071bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `flagtype.EnumDefault` constructor for enums with an initial default value +- `Cmd` field on `State` for accessing the terminal command selected by parsing +- `Summary` field on `Command` for the short text shown in command lists +- `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 returns the full help string + for a command +- **BREAKING**: Replace `Command.ShortHelp` with `Command.Summary` for command lists and + `Command.Description` for longer command help text +- **BREAKING**: Rename `FlagOption` to `FlagConfig` and `Command.FlagOptions` to + `Command.FlagConfigs` +- Help output keeps the default automatic `--help` behavior through `ParseAndRun`; `Command.Help` + replaces the generated help string when a command needs full control + +### Removed + +- **BREAKING**: Remove `ErrHelp`; check `errors.Is(err, flag.ErrHelp)` when handling `Parse` + directly +- **BREAKING**: Remove `DefaultUsage` and the top-level `Usage` function from the public API ## [v0.6.0] - 2026-02-18 diff --git a/README.md b/README.md index df2d4c1..2c1b41b 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Requires Go 1.21 or higher. ```go root := &cli.Command{ - Name: "greet", - ShortHelp: "Print a greeting", + Name: "greet", + Summary: "Print a greeting", Exec: func(ctx context.Context, s *cli.State) error { fmt.Fprintln(s.Stdout, "hello, world!") return nil @@ -39,9 +39,18 @@ if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { resolved command. For applications that need work between parsing and execution, use `Parse` and `Run` separately. See the [examples](examples/) directory for more complete applications. +The command above gets usable help without any extra setup: + +```text +Print a greeting + +Usage: + greet +``` + ## 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 +58,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}, }, @@ -74,13 +83,17 @@ Commands can have nested subcommands, each with their own flags and `Exec` funct ```go root := &cli.Command{ - Name: "todo", - Usage: "todo [flags]", - ShortHelp: "A simple CLI for managing your tasks", + Name: "todo", + Usage: "todo [flags]", + Summary: "Manage tasks", + Description: "todo manages tasks stored in a local file.", SubCommands: []*cli.Command{ { - Name: "list", - ShortHelp: "List all tasks", + Name: "list", + Summary: "List tasks", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, Exec: func(ctx context.Context, s *cli.State) error { // ... return nil @@ -90,13 +103,90 @@ root := &cli.Command{ } ``` +`Summary` is the short sentence shown when a command appears in another command's help: + +```text +Available Commands: + list List tasks +``` + +`Description` is the longer text shown at the top of that command's own help: + +```text +List tasks in the current workspace. + +By default, completed tasks are hidden. + +Usage: + todo list +``` + +If a command only needs one sentence, set `Summary` and leave `Description` empty. If `Description` +is set and `Summary` is empty, command lists use the first line of `Description`. + For a more complete example with deeply nested subcommands, see the [todo 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. Most commands only need +`Name`, `Summary`, flags, subcommands, and `Exec`. + +Set the `Help` field only when a command needs to replace the generated help entirely: + +```go +Help: func(c *cli.Command) string { + return `Print a greeting. + +Usage: + greet + +Examples: + greet margo` +}, +``` + +That replaces the built-in help with: + +```text +Print a greeting. + +Usage: + greet + +Examples: + greet margo +``` + +If you use `Parse` directly, handle `flag.ErrHelp` yourself. Most applications should use +`ParseAndRun` when they want cli to print help automatically. + +```go +if err := cli.Parse(root, args); err != nil { + if errors.Is(err, flag.ErrHelp) { + // Print custom help here, or use ParseAndRun for built-in help. + return nil + } + return err +} +``` + +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..73b0927 100644 --- a/command.go +++ b/command.go @@ -9,53 +9,79 @@ import ( "github.com/pressly/cli/pkg/suggest" ) -// 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. +// Command describes a single command in the CLI. // -// Note: [ParseAndRun] handles this automatically and never surfaces ErrHelp to the caller. -var ErrHelp = flag.ErrHelp - -// Command represents a CLI command or subcommand within the application's command hierarchy. +// Pass a Command to [ParseAndRun] (or [Parse] and [Run]) to run a program. To add a subcommand, +// list it in another command's [Command.SubCommands]. 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 word users type to pick this command. It must start with a letter and can contain + // letters, digits, dashes, or underscores. For the root command it is also the program name + // shown in help. Name string - // Usage provides the command's full usage pattern. + // Usage replaces the usage line shown at the top of help. Set it to show the expected + // arguments. The default usage line shows only the command path, plus "[flags]" when the + // command has flags. // - // Example: "cli todo list [flags]" + // Example: "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 string + // Summary is the one-line description shown next to this command in its parent's command list. + // It is also shown at the top of this command's own help when [Command.Description] is empty. + // + // Most commands only need Summary. Use [Command.Description] when one line is not enough. + Summary string + + // Description is the longer help text shown at the top of this command's own help. Use it to + // explain behavior, defaults, or anything else worth knowing. + // + // When [Command.Summary] is empty, the first line of Description is used in command lists + // instead. + Description 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 replaces the built-in help text for this command. Leave it nil to use the default help. + // + // The function is given the command and returns the full help string. Help is used for --help + // and for [UsageErrorf] errors. Each command can set its own Help, and only the selected + // command's Help is called. + Help func(*Command) string - // Flags holds the command-specific flag definitions. Each command maintains its own flag set - // for parsing arguments. + // Flags holds this command's flags as a standard library [flag.FlagSet]. Build it with + // [flag.NewFlagSet], or use [FlagsFunc] to define flags inline. + // + // Subcommands inherit these flags unless they are marked [FlagConfig.Local] in + // [Command.FlagConfigs]. Read flag values inside [Command.Exec] with [GetFlag]. 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 extra behavior to flags already defined in [Command.Flags]. See [FlagConfig] + // for the available options. + // + // Each entry must point to a flag defined in [Command.Flags]. Otherwise [Parse] returns an + // error. + FlagConfigs []FlagConfig + + // SubCommands are the commands users can pick after this command's name. + // + // When a command has SubCommands, the first non-flag argument must match one of them. An + // unknown name returns an "unknown command" error with suggestions. Commands without + // SubCommands pass any non-flag arguments through to [State.Args]. 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 is the function that runs when this command is picked. It is given a [State] holding the + // parsed inputs the command needs. + // + // Return [UsageErrorf] for bad arguments or flag combinations so [Run] prints the command's + // help to stderr. Return a normal error for everything else; [Run] returns it without printing + // help. 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 list of commands from the root down to this command. It is usually called inside +// [Command.Exec] as s.Cmd.Path() to build error messages that include the full command path. +// +// Path returns nil if called before [Parse]. func (c *Command) Path() []*Command { if c.state == nil { return nil @@ -71,32 +97,34 @@ 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 extra behavior to a single flag already defined in [Command.Flags]. It is used as +// an entry in [Command.FlagConfigs]. +type FlagConfig struct { + // Name is the long flag name as registered in the command's [flag.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 is a one-letter alias for the flag, such as "v" so users can type -v instead of + // --verbose. Both forms are shown in help. Short string - // Required indicates whether the flag is required. + // Required, when true, makes [Parse] fail unless the user sets the flag. The default value is + // not enough; the user must pass it. 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, when true, keeps the flag on this command only and stops it from being inherited by + // subcommands. Parent flags are inherited by default. 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 [flag.FlagSet] inline so you don't have to make one and assign it separately. +// The returned FlagSet uses [flag.ContinueOnError], so parsing errors are returned instead of being +// fatal. // -// cmd.Flags = cli.FlagsFunc(func(f *flag.FlagSet) { +// Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("verbose", false, "enable verbose output") // f.String("output", "", "output file") // f.Int("count", 0, "number of items") -// }) +// }), func FlagsFunc(fn func(f *flag.FlagSet)) *flag.FlagSet { fset := flag.NewFlagSet("", flag.ContinueOnError) fn(fset) diff --git a/doc.go b/doc.go index 462824e..2ac146a 100644 --- a/doc.go +++ b/doc.go @@ -1,21 +1,21 @@ -// Package cli provides a lightweight library for building command-line applications using Go's -// standard library flag package. It extends flag functionality to support flags anywhere in command -// arguments. +// Package cli builds command-line programs on top of the standard library [flag] package. It adds +// nested subcommands and lets users place flags anywhere in command arguments. // -// Key features: -// - Nested subcommands for organizing complex CLIs -// - Flexible flag parsing, allowing flags anywhere in arguments -// - Parent-to-child flag inheritance -// - Type-safe flag access -// - Automatic help text generation -// - Command suggestions for misspelled inputs +// Features: +// - Nested subcommands via [Command.SubCommands] +// - Flags placed anywhere on the command line +// - Parent flags inherited by child commands +// - Type-safe flag access via [GetFlag] +// - Generated help, replaceable per command via [Command.Help] +// - "Did you mean" suggestions for misspelled subcommands // // Quick example: // // root := &cli.Command{ -// Name: "echo", -// Usage: "echo [flags] ...", -// ShortHelp: "prints the provided text", +// Name: "echo", +// Usage: "echo [flags] ...", +// Summary: "Print text", +// Description: "echo prints the provided text.", // Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // f.Bool("c", false, "capitalize the input") // }), @@ -28,9 +28,11 @@ // return nil // }, // } +// if err := cli.ParseAndRun(ctx, root, os.Args[1:], nil); err != nil { +// fmt.Fprintf(os.Stderr, "error: %v\n", err) +// os.Exit(1) +// } // -// The package intentionally maintains a minimal API surface to serve as a building block for CLI -// applications while leveraging the standard library's flag package. This approach enables -// developers to build maintainable command-line tools quickly while focusing on application logic -// rather than framework complexity. +// The API is small on purpose. cli uses the standard library flag package instead of replacing it, +// so most of what you write is your program. package cli diff --git a/error.go b/error.go new file mode 100644 index 0000000..bb601d3 --- /dev/null +++ b/error.go @@ -0,0 +1,28 @@ +package cli + +import "fmt" + +type usageError struct { + err error +} + +// UsageErrorf returns an error that means the command was used incorrectly. Return it from +// [Command.Exec] when the command itself was right but the arguments or flag combination are wrong: +// +// if len(s.Args) == 0 { +// return cli.UsageErrorf("must supply a name") +// } +// +// When [Run] sees a UsageErrorf error, it prints the command's help to stderr and returns the error +// message you passed in. Return a normal error if you do not want help printed. +func UsageErrorf(format string, args ...any) error { + return &usageError{err: fmt.Errorf(format, args...)} +} + +func (e *usageError) Error() string { + return e.err.Error() +} + +func (e *usageError) Unwrap() error { + return e.err +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..70a4f7b --- /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..adb5d95 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" @@ -13,19 +12,20 @@ import ( func main() { root := &cli.Command{ - Name: "echo", - Usage: "echo [flags] ...", - ShortHelp: "echo is a simple command that prints the provided text", + Name: "echo", + Usage: "echo [flags] ...", + Summary: "Print text", + Description: "echo prints the provided text.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { // 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..b0318bd 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" @@ -16,9 +15,10 @@ import ( func main() { root := &cli.Command{ - Name: "todo", - Usage: "todo [flags]", - ShortHelp: "A simple CLI for managing your tasks", + Name: "todo", + Usage: "todo [flags]", + Summary: "Manage tasks", + Description: "todo manages tasks stored in a local JSON file.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("verbose", false, "enable verbose output") f.Bool("version", false, "print the version") @@ -28,8 +28,7 @@ func main() { 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(), @@ -45,19 +44,19 @@ func main() { func list() *cli.Command { return &cli.Command{ - Name: "list", - Usage: "todo list [flags]", - ShortHelp: "List tasks", + Name: "list", + Usage: "todo list [flags]", + Summary: "List tasks", + Description: "List tasks by saved views such as today or overdue.", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { 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(), @@ -73,9 +72,9 @@ func getTasksFromFile(s *cli.State) (*TaskList, error) { func listToday() *cli.Command { return &cli.Command{ - Name: "today", - Usage: "todo list today [flags]", - ShortHelp: "List tasks due today", + Name: "today", + Usage: "todo list today [flags]", + Summary: "List tasks due today", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -97,9 +96,9 @@ func listToday() *cli.Command { func listOverdue() *cli.Command { return &cli.Command{ - Name: "overdue", - Usage: "todo list overdue [flags]", - ShortHelp: "List overdue tasks", + Name: "overdue", + Usage: "todo list overdue [flags]", + Summary: "List overdue tasks", Exec: func(ctx context.Context, s *cli.State) error { tasks, err := getTasksFromFile(s) if err != nil { @@ -126,10 +125,10 @@ 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", + Summary: "Manage tasks", SubCommands: []*cli.Command{ taskAdd(), taskDone(), @@ -140,9 +139,9 @@ func task() *cli.Command { func taskAdd() *cli.Command { return &cli.Command{ - Name: "add", - Usage: "todo task add [flags]", - ShortHelp: "Add a new task", + Name: "add", + Usage: "todo task add [flags]", + Summary: "Add a new task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.String("tags", "", "comma-separated list of tags") }), @@ -179,12 +178,12 @@ func taskAdd() *cli.Command { func taskDone() *cli.Command { return &cli.Command{ - Name: "done", - Usage: "todo task done [flags]", - ShortHelp: "Mark a task as done", + Name: "done", + Usage: "todo task done [flags]", + Summary: "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 { @@ -202,9 +201,9 @@ func taskDone() *cli.Command { func taskRemove() *cli.Command { return &cli.Command{ - Name: "remove", - Usage: "todo task remove [flags]", - ShortHelp: "Remove a task", + Name: "remove", + Usage: "todo task remove [flags]", + Summary: "Remove a task", Flags: cli.FlagsFunc(func(f *flag.FlagSet) { f.Bool("force", false, "force removal without confirmation") f.Bool("all", false, "remove all tasks") @@ -216,7 +215,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/internal/helpdoc/helpdoc.go b/internal/helpdoc/helpdoc.go new file mode 100644 index 0000000..18f6dfb --- /dev/null +++ b/internal/helpdoc/helpdoc.go @@ -0,0 +1,381 @@ +// Package helpdoc builds the default command help document. +package helpdoc + +import ( + "bytes" + "flag" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" +) + +// Document is an ordered list of help blocks. +type Document []Block + +// Block is one section in a help document. +type Block struct { + heading string + lines []string + indent bool + items []Item +} + +// Item is one row in a list block. +type Item struct { + Name string + Summary string +} + +// Command is the command metadata needed to build help. +type Command struct { + Name string + Usage string + Summary string + Description string + Flags *flag.FlagSet + FlagConfigs []FlagConfig + Subcommands []Command +} + +// FlagConfig adds help-specific behavior to a flag in a FlagSet. +type FlagConfig struct { + Name string + Short string + Required bool + Local bool +} + +// New returns the default help document for the parsed command path. +func New(path []Command) Document { + if len(path) == 0 { + return nil + } + + cmd := path[len(path)-1] + flags := collectFlags(path) + + var doc Document + if text := commandHelpText(cmd); text != "" { + doc = append(doc, Text(text)) + } + + usageLine := cmd.Usage + if usageLine == "" { + usageLine = commandPath(path) + if len(flags) > 0 { + usageLine += " [flags]" + } + if len(cmd.Subcommands) > 0 { + usageLine += " " + } + } + doc = append(doc, Lines("Usage:", usageLine)) + + if len(cmd.Subcommands) > 0 { + subcommands := slices.Clone(cmd.Subcommands) + slices.SortFunc(subcommands, func(a, b Command) int { + return strings.Compare(a.Name, b.Name) + }) + + items := make([]Item, 0, len(subcommands)) + for _, sub := range subcommands { + items = append(items, Item{ + Name: sub.Name, + Summary: commandListSummary(sub), + }) + } + doc = append(doc, List("Available Commands:", items...)) + } + + if len(flags) > 0 { + slices.SortFunc(flags, func(a, b flagInfo) int { + return strings.Compare(a.name, b.name) + }) + + localFlags, inheritedFlags := splitFlags(flags) + if len(localFlags) > 0 { + doc = append(doc, flagsBlock("Flags:", localFlags)) + } + if len(inheritedFlags) > 0 { + doc = append(doc, flagsBlock("Inherited Flags:", inheritedFlags)) + } + } + + if len(cmd.Subcommands) > 0 { + doc = append(doc, Text( + fmt.Sprintf("Use \"%s [command] --help\" for more information about a command.", commandPath(path)), + )) + } + + return doc +} + +// Text returns an untitled paragraph block. +func Text(lines ...string) Block { + return Block{lines: lines} +} + +// Lines returns a titled block of indented lines. +func Lines(heading string, lines ...string) Block { + return Block{heading: heading, lines: lines, indent: true} +} + +// List returns a titled list of name/summary pairs. +func List(heading string, items ...Item) Block { + return Block{heading: heading, items: items} +} + +func flagsBlock(heading string, flags []flagInfo) 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: flagSpec(f.name, f.short, f.placeholder, hasShort), + Summary: flagDescription(f.usage, f.defaultValue, f.required), + }) + } + return List(heading, items...) +} + +func commandPath(path []Command) string { + names := make([]string, 0, len(path)) + for _, cmd := range path { + names = append(names, cmd.Name) + } + return strings.Join(names, " ") +} + +// String renders the help document as a string. +func (d Document) String() string { + var b strings.Builder + _, _ = d.WriteTo(&b) + return strings.TrimRight(b.String(), "\n") +} + +// WriteTo writes the help document to w. +func (d Document) WriteTo(w io.Writer) (n int64, err error) { + cw := &countWriter{w: w} + for i, block := range d { + 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 +} + +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 +} + +func flagSpec(name, short, placeholder string, padShort bool) string { + var spec string + if short != "" { + spec = "-" + short + ", --" + name + } else if padShort { + spec = " --" + name + } else { + spec = "--" + name + } + if placeholder == "" { + return spec + } + return spec + " " + placeholder +} + +func flagDescription(usage, defaultValue string, required bool) string { + if required { + return usage + " (required)" + } + if defaultValue != "" { + return fmt.Sprintf("%s (default: %s)", usage, defaultValue) + } + return usage +} + +func flagTypeName(f *flag.Flag) string { + typeName := fmt.Sprintf("%T", f.Value) + if i := strings.LastIndex(typeName, "."); i >= 0 { + typeName = typeName[i+1:] + } + typeName = strings.TrimPrefix(typeName, "*") + typeName = strings.TrimSuffix(typeName, "Value") + if typeName == "bool" { + return "" + } + return typeName +} + +func commandHelpText(cmd Command) string { + if cmd.Description != "" { + return cmd.Description + } + return cmd.Summary +} + +func commandListSummary(cmd Command) string { + if cmd.Summary != "" { + return cmd.Summary + } + return firstLine(cmd.Description) +} + +func firstLine(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func collectFlags(path []Command) []flagInfo { + var flags []flagInfo + terminalIdx := len(path) - 1 + for i, cmd := range path { + if cmd.Flags == nil { + continue + } + inherited := i < terminalIdx + meta := flagConfigMap(cmd.FlagConfigs) + cmd.Flags.VisitAll(func(f *flag.Flag) { + info := flagInfo{ + name: f.Name, + usage: f.Usage, + defaultValue: f.DefValue, + placeholder: flagTypeName(f), + } + if cfg, ok := meta[f.Name]; ok { + info.short = cfg.Short + info.required = cfg.Required + info.local = cfg.Local + } + if inherited && info.local { + return + } + info.inherited = inherited + info.defaultValue = flagDefault(info.defaultValue, info.placeholder, info.required) + flags = append(flags, info) + }) + } + return flags +} + +func splitFlags(flags []flagInfo) (local, inherited []flagInfo) { + for _, f := range flags { + if f.inherited { + inherited = append(inherited, f) + } else { + local = append(local, f) + } + } + return local, inherited +} + +func flagConfigMap(configs []FlagConfig) map[string]FlagConfig { + m := make(map[string]FlagConfig, len(configs)) + for _, cfg := range configs { + m[cfg.Name] = cfg + } + return m +} + +func flagDefault(defval, typeName string, required bool) string { + if required || isZeroDefault(defval, typeName) { + return "" + } + return defval +} + +func isZeroDefault(defval, typeName string) bool { + switch { + case defval == "": + return true + case defval == "false" && typeName == "": + return true + case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): + return true + case defval == "0" && typeName == "float64": + return true + } + return false +} + +type flagInfo struct { + name string + short string + placeholder string + usage string + defaultValue string + required bool + local bool + inherited bool +} + +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/internal/usage/help.go b/internal/usage/help.go new file mode 100644 index 0000000..2f6d346 --- /dev/null +++ b/internal/usage/help.go @@ -0,0 +1,180 @@ +// Package usage contains experimental building blocks for command help documents. +// +// It is internal while the public usage API is still being refined. Generated help prefers +// cli.Command.Description for the command's long help text and cli.Command.Summary for command +// lists, with fallbacks for simple commands that only set one field. +package usage + +import ( + "io" + "strings" + + "github.com/pressly/cli" + "github.com/pressly/cli/internal/helpdoc" +) + +// Document is an ordered list of blocks that can be rendered as command help. +type Document []Block + +// Block is one section in a help document. +type Block struct { + block helpdoc.Block +} + +// Item is one row in a List block. +type Item struct { + Name string + Summary string +} + +// Text returns an untitled paragraph block. +// +// Use Text for descriptions, notes, or closing hints. +func Text(lines ...string) Block { + return Block{block: helpdoc.Text(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{block: helpdoc.Lines(heading, lines...)} +} + +// 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{block: helpdoc.List(heading, helpItems(items)...)} +} + +// String renders the full help document as a string. +// +// Use String when returning help from cli.Command.Help or when comparing help text in tests. +func (d Document) String() string { + return d.helpdoc().String() +} + +// WriteTo writes the help document to w. +// +// Use WriteTo when streaming help directly to stdout, stderr, or another writer. +func (d Document) WriteTo(w io.Writer) (int64, error) { + return d.helpdoc().WriteTo(w) +} + +// New returns the default help document for cmd. +// +// Use New from cli.Command.Help when you want to keep the built-in help layout and add or reorder +// sections before returning the final string. The document starts with Description when set, or +// Summary otherwise. Subcommand lists use Summary when set, or the first line of Description +// otherwise. Use New, not Help, inside a cli.Command.Help hook so the hook does not call itself. +func New(cmd *cli.Command) Document { + cmd = resolveCommand(cmd) + if cmd == nil { + return nil + } + return fromHelpDoc(helpdoc.New(helpPath(cmd))) +} + +// Help returns help text for cmd. +// +// Use Help when handling flag.ErrHelp yourself after calling cli.Parse directly. It returns the +// same text cli.ParseAndRun prints for --help: if the resolved command has a cli.Command.Help hook, +// Help returns that hook's output; otherwise, it renders the default document from New. Inside a +// cli.Command.Help hook, use New instead. +func Help(cmd *cli.Command) string { + cmd = resolveCommand(cmd) + if cmd == nil { + return "" + } + if cmd.Help != nil { + return strings.TrimRight(cmd.Help(cmd), "\n") + } + return New(cmd).String() +} + +func resolveCommand(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + if path := cmd.Path(); len(path) > 0 { + return path[len(path)-1] + } + return cmd +} + +func fromHelpDoc(doc helpdoc.Document) Document { + out := make(Document, 0, len(doc)) + for _, block := range doc { + out = append(out, Block{block: block}) + } + return out +} + +func (d Document) helpdoc() helpdoc.Document { + out := make(helpdoc.Document, 0, len(d)) + for _, block := range d { + out = append(out, block.block) + } + return out +} + +func helpItems(items []Item) []helpdoc.Item { + out := make([]helpdoc.Item, 0, len(items)) + for _, item := range items { + out = append(out, helpdoc.Item{ + Name: item.Name, + Summary: item.Summary, + }) + } + return out +} + +func helpPath(cmd *cli.Command) []helpdoc.Command { + path := cmd.Path() + if len(path) == 0 { + path = []*cli.Command{cmd} + } + out := make([]helpdoc.Command, 0, len(path)) + for _, c := range path { + out = append(out, helpCommand(c)) + } + return out +} + +func helpCommand(cmd *cli.Command) helpdoc.Command { + return helpdoc.Command{ + Name: cmd.Name, + Usage: cmd.Usage, + Summary: cmd.Summary, + Description: cmd.Description, + Flags: cmd.Flags, + FlagConfigs: helpFlagConfigs(cmd.FlagConfigs), + Subcommands: helpSubcommands(cmd.SubCommands), + } +} + +func helpSubcommands(commands []*cli.Command) []helpdoc.Command { + out := make([]helpdoc.Command, 0, len(commands)) + for _, cmd := range commands { + out = append(out, helpdoc.Command{ + Name: cmd.Name, + Summary: cmd.Summary, + Description: cmd.Description, + }) + } + return out +} + +func helpFlagConfigs(configs []cli.FlagConfig) []helpdoc.FlagConfig { + out := make([]helpdoc.FlagConfig, 0, len(configs)) + for _, cfg := range configs { + out = append(out, helpdoc.FlagConfig{ + Name: cfg.Name, + Short: cfg.Short, + Required: cfg.Required, + Local: cfg.Local, + }) + } + return out +} diff --git a/internal/usage/help_test.go b/internal/usage/help_test.go new file mode 100644 index 0000000..46ab802 --- /dev/null +++ b/internal/usage/help_test.go @@ -0,0 +1,252 @@ +package usage + +import ( + "bytes" + "context" + "flag" + "strings" + "testing" + + "github.com/pressly/cli" + "github.com/stretchr/testify/require" +) + +func TestHelpString(t *testing.T) { + t.Parallel() + + h := Document{ + Text("print a greeting"), + Lines("Usage:", "greet [flags] "), + List("Flags:", + Item{Name: "-v, --verbose", Summary: "enable verbose output"}, + Item{Name: "--output string", Summary: "output file (required)"}, + ), + List("Available Commands:", + Item{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 TestDocumentStringForSingleBlock(t *testing.T) { + t.Parallel() + + output := Document{Lines("Examples:", "greet margo")}.String() + require.Equal(t, "Examples:\n greet margo", output) +} + +func TestListWithoutSummary(t *testing.T) { + t.Parallel() + + output := Document{List("Commands:", Item{Name: "serve"})}.String() + require.Equal(t, "Commands:\n serve", output) +} + +func TestCommandHelp(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + Description: "print a greeting", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + f.String("format", "plain", "output format") + }), + FlagConfigs: []cli.FlagConfig{ + {Name: "verbose", Short: "v"}, + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, nil)) + + output := Help(root) + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + require.Equal(t, output, strings.TrimRight(stdout.String(), "\n")) + 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, "--format string") +} + +func TestCommandHelpUsesResolvedCommand(t *testing.T) { + t.Parallel() + + child := &cli.Command{ + Name: "child", + Description: "run the child command", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.String("file", "", "input file") + }), + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + root := &cli.Command{ + Name: "root", + Flags: cli.FlagsFunc(func(f *flag.FlagSet) { + f.Bool("verbose", false, "enable verbose output") + }), + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, []string{"child"})) + + output := Help(root) + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"child", "--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + require.Equal(t, output, strings.TrimRight(stdout.String(), "\n")) + require.Contains(t, output, "run the child command") + require.Contains(t, output, "root child [flags]") + require.Contains(t, output, "Flags:") + require.Contains(t, output, "--file string") + require.Contains(t, output, "Inherited Flags:") + require.Contains(t, output, "--verbose") +} + +func TestCommandDocumentComposition(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + Description: "print a greeting", + Help: func(c *cli.Command) string { + doc := New(c) + doc = append(doc, Lines("Examples:", "greet margo")) + return doc.String() + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + var stdout bytes.Buffer + err := cli.ParseAndRun(context.Background(), root, []string{"--help"}, &cli.RunOptions{ + Stdout: &stdout, + }) + require.NoError(t, err) + + output := stdout.String() + require.Equal(t, strings.TrimRight(output, "\n"), Help(root)) + require.Contains(t, output, "print a greeting") + require.Contains(t, output, "Examples:") + require.Contains(t, output, "greet margo") +} + +func TestCommandHelpSummaryAndDescription(t *testing.T) { + t.Parallel() + + child := &cli.Command{ + Name: "list", + Summary: "List tasks", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + root := &cli.Command{ + Name: "todo", + Summary: "Manage tasks", + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(root, nil)) + output := Help(root) + require.Contains(t, output, "list List tasks") + require.NotContains(t, output, "By default, completed tasks are hidden.") + + require.NoError(t, cli.Parse(root, []string{"list"})) + output = Help(root) + require.Contains(t, output, "List tasks in the current workspace.") + require.Contains(t, output, "By default, completed tasks are hidden.") +} + +func TestCommandHelpDescriptionFallbacks(t *testing.T) { + t.Parallel() + + t.Run("summary is shown in command help when description is empty", func(t *testing.T) { + t.Parallel() + + cmd := &cli.Command{ + Name: "greet", + Summary: "Print a greeting", + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(cmd, nil)) + require.Contains(t, Help(cmd), "Print a greeting") + }) + + t.Run("description first line is shown in command lists when summary is empty", func(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "todo", + SubCommands: []*cli.Command{ + { + Name: "list", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + }, + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + + require.NoError(t, cli.Parse(root, nil)) + output := Help(root) + require.Contains(t, output, "list List tasks in the current workspace.") + require.NotContains(t, output, "By default, completed tasks are hidden.") + }) +} + +func TestCommandHelpUsesCustomHook(t *testing.T) { + t.Parallel() + + root := &cli.Command{ + Name: "greet", + Help: func(c *cli.Command) string { + require.Equal(t, "greet", c.Name) + return "custom help\n" + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, nil)) + + require.Equal(t, "custom help", Help(root)) +} + +func TestCommandHelpUsesResolvedCustomHook(t *testing.T) { + t.Parallel() + + child := &cli.Command{ + Name: "child", + Help: func(c *cli.Command) string { + require.Equal(t, "child", c.Name) + return "child help" + }, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + root := &cli.Command{ + Name: "root", + SubCommands: []*cli.Command{child}, + Exec: func(ctx context.Context, s *cli.State) error { return nil }, + } + require.NoError(t, cli.Parse(root, []string{"child"})) + + require.Equal(t, "child help", Help(root)) +} diff --git a/parse.go b/parse.go index df65291..58f0e74 100644 --- a/parse.go +++ b/parse.go @@ -13,12 +13,12 @@ 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 picks the right command and parses its flags from args, but does not run [Command.Exec]. +// Use Parse with [Run] when you need to do work between parsing and running. For the common case, +// call [ParseAndRun]. // -// 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. +// Parse returns [flag.ErrHelp] when the user passes -h or --help. You have to print the help +// yourself when this happens. [ParseAndRun] does it for you. func Parse(root *Command, args []string) error { if root == nil { return fmt.Errorf("failed to parse: root command is nil") @@ -27,15 +27,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 +45,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 @@ -50,7 +53,7 @@ func Parse(root *Command, args []string) error { if arg == "-h" || arg == "--h" || arg == "-help" || arg == "--help" { // Combine flags first so the help message includes all inherited flags combineFlags(root.state.path) - return ErrHelp + return flag.ErrHelp } } @@ -106,11 +109,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 +123,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 +153,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 +168,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 +190,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 +212,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 +223,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 +234,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 +247,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 +326,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 +342,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..75924e5 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) // --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..bbd1a99 100644 --- a/run.go +++ b/run.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "flag" "fmt" "io" "os" @@ -13,20 +14,23 @@ import ( "sync" ) -// RunOptions specifies options for running a command. +// RunOptions replaces the standard streams used by [Run] and [ParseAndRun]. Pass nil for normal +// programs to use os.Stdin, os.Stdout, and os.Stderr. +// +// Use RunOptions in tests, or anywhere you need to capture output or supply your own 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. A nil field + // falls back to its os equivalent. 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 runs the command picked by a previous call to [Parse]. Use Run only when you call [Parse] +// separately. For the common case, use [ParseAndRun]. // -// The options parameter may be nil, in which case default values are used. See [RunOptions] for -// more details. +// If [Command.Exec] returns an error created by [UsageErrorf], Run prints the command's help to +// stderr and returns the error you passed to [UsageErrorf]. Other errors are returned as-is. A nil +// ctx defaults to [context.Background]. func Run(ctx context.Context, root *Command, options *RunOptions) error { if ctx == nil { ctx = context.Background() @@ -49,27 +53,22 @@ 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. -// -// This is the recommended entry point for most CLI applications: +// ParseAndRun parses args, picks the right command, and runs its [Command.Exec]. This is the normal +// way to start a CLI program: // // 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. +// When the user passes -h or --help, ParseAndRun prints the picked command's help to stdout and +// returns nil. Use [Parse] and [Run] separately when you need to do work between parsing and +// running, such as setting up resources based on 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) { + if errors.Is(err, flag.ErrHelp) { options = checkAndSetRunOptions(options) - _, _ = fmt.Fprintln(options.Stdout, DefaultUsage(root)) + _, _ = fmt.Fprintln(options.Stdout, help(root)) return nil } return err @@ -94,7 +93,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..a01c2cd 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 { + _, err := fmt.Fprintln(s.Stdout, "hello") + return err + }, + } + + 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", + Description: "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..fa8638b 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 the value passed to [Command.Exec]. It holds the parsed inputs the command needs to run. type State struct { - // Args contains the remaining arguments after flag parsing. + // Args holds the positional arguments left after the command name and flags are parsed. + // Anything after "--" is included as-is, even if it looks like a flag. Args []string - // Standard I/O streams. + // Stdin, Stdout, and Stderr are the streams to use in your command code instead of os.Stdin, + // os.Stdout, and os.Stderr. Tests can swap them via [RunOptions]. Stdin io.Reader Stdout, Stderr io.Writer + // Cmd is the command that was picked. Call Cmd.Path() to get the full list of commands from the + // root down, useful for error messages that include the command path. + 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 returns the value of a flag as type T. Call it from inside [Command.Exec] with the same +// Go type that was used when the flag was defined. // -// 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. +// GetFlag looks for the flag on the picked command first, then in its parent commands. A flag +// defined on the root command can be read from any subcommand. An unknown flag name or a wrong type +// is a programming error: GetFlag panics, and [Run] catches the panic and returns the error. // -// verbose := GetFlag[bool](state, "verbose") -// count := GetFlag[int](state, "count") -// path := GetFlag[string](state, "path") +// verbose := cli.GetFlag[bool](s, "verbose") +// count := cli.GetFlag[int](s, "count") +// path := cli.GetFlag[string](s, "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..cfd52b6 100644 --- a/state_test.go +++ b/state_test.go @@ -1,7 +1,10 @@ package cli import ( + "context" "flag" + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -48,3 +51,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) string { + return "Usage:\n root child\n\nExamples:\n root child file.txt" + }, + Exec: func(ctx context.Context, s *State) error { + output := help(s.Cmd) + 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", + Description: "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..4f82012 100644 --- a/usage.go +++ b/usage.go @@ -1,294 +1,74 @@ package cli import ( - "cmp" - "flag" - "fmt" - "slices" "strings" - "github.com/pressly/cli/pkg/textutil" + "github.com/pressly/cli/internal/helpdoc" ) -// 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 { +func help(root *Command) string { if root == nil { return "" } - // Get terminal command from state terminalCmd := root.terminal() - - var b strings.Builder - - if terminalCmd.UsageFunc != nil { - return terminalCmd.UsageFunc(terminalCmd) + if terminalCmd.Help != nil { + return strings.TrimRight(terminalCmd.Help(terminalCmd), "\n") } - if terminalCmd.ShortHelp != "" { - b.WriteString(terminalCmd.ShortHelp) - b.WriteString("\n\n") - } - - b.WriteString("Usage:\n") - if terminalCmd.Usage != "" { - b.WriteString(" " + terminalCmd.Usage + "\n") - } else { - usage := terminalCmd.Name - if root.state != nil && len(root.state.path) > 0 { - usage = getCommandPath(root.state.path) - } - if terminalCmd.Flags != nil { - usage += " [flags]" - } - if len(terminalCmd.SubCommands) > 0 { - usage += " " - } - b.WriteString(" " + usage + "\n") - } - b.WriteString("\n") - - 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 - for _, sub := range sortedCommands { - if len(sub.Name) > maxNameLen { - maxNameLen = len(sub.Name) - } - } - - nameWidth := maxNameLen + 4 - wrapWidth := defaultTerminalWidth - nameWidth - - for _, sub := range sortedCommands { - if sub.ShortHelp == "" { - fmt.Fprintf(&b, " %s\n", sub.Name) - continue - } - - 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]) - - indentPadding := strings.Repeat(" ", nameWidth+2) - for _, line := range lines[1:] { - fmt.Fprintf(&b, "%s%s\n", indentPadding, line) - } - } - b.WriteString("\n") - } - - var flags []flagInfo - if root.state != nil && len(root.state.path) > 0 { - terminalIdx := len(root.state.path) - 1 - for i, cmd := range root.state.path { - if cmd.Flags == nil { - continue - } - isInherited := i < terminalIdx - metaMap := flagOptionMap(cmd.FlagOptions) - cmd.Flags.VisitAll(func(f *flag.Flag) { - // Skip local flags from ancestor commands — they don't appear in child help. - if isInherited { - if m, ok := metaMap[f.Name]; ok && m.Local { - return - } - } - fi := flagInfo{ - name: "--" + f.Name, - usage: f.Usage, - defval: f.DefValue, - typeName: flagTypeName(f), - inherited: isInherited, - } - if m, ok := metaMap[f.Name]; ok { - fi.required = m.Required - fi.short = m.Short - } - flags = append(flags, fi) - }) - } - } else if terminalCmd.Flags != nil { - // Pre-parse fallback: show the command's own flags even without state. - metaMap := flagOptionMap(terminalCmd.FlagOptions) - terminalCmd.Flags.VisitAll(func(f *flag.Flag) { - fi := flagInfo{ - name: "--" + f.Name, - usage: f.Usage, - defval: f.DefValue, - typeName: flagTypeName(f), - } - if m, ok := metaMap[f.Name]; ok { - fi.required = m.Required - fi.short = m.Short - } - 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 defaultHelp(root) } -// 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 - - 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) - } - } +func defaultHelp(root *Command) string { + return helpdoc.New(helpPath(root)).String() } -// 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)) - for _, fm := range options { - m[fm.Name] = fm +func helpPath(root *Command) []helpdoc.Command { + path := root.Path() + if len(path) == 0 { + path = []*Command{root.terminal()} } - return m -} - -type flagInfo struct { - name string - short string - usage string - defval string - typeName string - inherited bool - 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 + out := make([]helpdoc.Command, 0, len(path)) + for _, cmd := range path { + out = append(out, helpCommand(cmd)) } - if f.typeName == "" { - return name - } - return name + " " + f.typeName + return out } -// 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 { - // Use the type name from the Value interface, which returns the type as a string. - typeName := fmt.Sprintf("%T", f.Value) - // The flag package uses unexported types like *flag.boolValue, *flag.stringValue, etc. Extract - // just the base name and strip the "Value" suffix. - if i := strings.LastIndex(typeName, "."); i >= 0 { - typeName = typeName[i+1:] +func helpCommand(cmd *Command) helpdoc.Command { + return helpdoc.Command{ + Name: cmd.Name, + Usage: cmd.Usage, + Summary: cmd.Summary, + Description: cmd.Description, + Flags: cmd.Flags, + FlagConfigs: helpFlagConfigs(cmd.FlagConfigs), + Subcommands: helpSubcommands(cmd.SubCommands), } - typeName = strings.TrimPrefix(typeName, "*") - typeName = strings.TrimSuffix(typeName, "Value") +} - // Don't show type for bools — their usage is self-evident. - if typeName == "bool" { - return "" +func helpSubcommands(commands []*Command) []helpdoc.Command { + out := make([]helpdoc.Command, 0, len(commands)) + for _, cmd := range commands { + out = append(out, helpdoc.Command{ + Name: cmd.Name, + Summary: cmd.Summary, + Description: cmd.Description, + }) } - return typeName + return out } -// isZeroDefault returns true if the default value is the zero value for its type and should be -// suppressed in help output to reduce noise. -func isZeroDefault(defval, typeName string) bool { - switch { - case defval == "": - return true - case defval == "false" && typeName == "": - // Bool flags (typeName is "" for bools). - return true - case defval == "0" && (typeName == "int" || typeName == "int64" || typeName == "uint" || typeName == "uint64"): - return true - case defval == "0" && typeName == "float64": - return true +func helpFlagConfigs(configs []FlagConfig) []helpdoc.FlagConfig { + out := make([]helpdoc.FlagConfig, 0, len(configs)) + for _, cfg := range configs { + out = append(out, helpdoc.FlagConfig{ + Name: cfg.Name, + Short: cfg.Short, + Required: cfg.Required, + Local: cfg.Local, + }) } - return false + return out } diff --git a/usage_test.go b/usage_test.go index 8bacfb0..4f75377 100644 --- a/usage_test.go +++ b/usage_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "flag" + "strings" "testing" "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) 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) 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") @@ -60,8 +65,8 @@ func TestUsageGeneration(t *testing.T) { cmd := &Command{ Name: "parent", SubCommands: []*Command{ - {Name: "child1", ShortHelp: "first child command", Exec: func(ctx context.Context, s *State) error { return nil }}, - {Name: "child2", ShortHelp: "second child command", Exec: func(ctx context.Context, s *State) error { return nil }}, + {Name: "child1", Description: "first child command", Exec: func(ctx context.Context, s *State) error { return nil }}, + {Name: "child2", Description: "second child command", Exec: func(ctx context.Context, s *State) error { return nil }}, }, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -69,28 +74,30 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) 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) { t.Parallel() cmd := &Command{ - Name: "complex", - ShortHelp: "complex command with flags and subcommands", + Name: "complex", + Description: "complex command with flags and subcommands", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.Bool("global", false, "global flag") }), SubCommands: []*Command{ { - Name: "sub", - ShortHelp: "subcommand with its own flags", + Name: "sub", + Description: "subcommand with its own flags", Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("local", "", "local flag") }), @@ -103,7 +110,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) require.Contains(t, output, "complex") require.Contains(t, output, "complex command with flags and subcommands") require.Contains(t, output, "-global") @@ -117,8 +124,8 @@ func TestUsageGeneration(t *testing.T) { longDesc := "This is a very long description that should be wrapped properly when displayed in the usage output to ensure readability and proper formatting" cmd := &Command{ - Name: "longdesc", - ShortHelp: longDesc, + Name: "longdesc", + Description: longDesc, Flags: FlagsFunc(func(fset *flag.FlagSet) { fset.String("long-flag", "", longDesc) }), @@ -128,7 +135,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) 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) require.Contains(t, output, "globalonly") require.Contains(t, output, "-debug") require.Contains(t, output, "-output") @@ -163,9 +170,9 @@ func TestUsageGeneration(t *testing.T) { var subcommands []*Command for i := 0; i < 10; i++ { subcommands = append(subcommands, &Command{ - Name: "cmd" + string(rune('0'+i)), - ShortHelp: "command number " + string(rune('0'+i)), - Exec: func(ctx context.Context, s *State) error { return nil }, + Name: "cmd" + string(rune('0'+i)), + Description: "command number " + string(rune('0'+i)), + Exec: func(ctx context.Context, s *State) error { return nil }, }) } @@ -178,7 +185,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) 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) require.Contains(t, output, "empty") require.NotEmpty(t, output) }) @@ -206,19 +213,19 @@ func TestUsageGeneration(t *testing.T) { t.Parallel() child := &Command{ - Name: "child", - ShortHelp: "nested child command", - Exec: func(ctx context.Context, s *State) error { return nil }, + Name: "child", + Description: "nested child command", + Exec: func(ctx context.Context, s *State) error { return nil }, } parent := &Command{ Name: "parent", - ShortHelp: "parent command", + Description: "parent command", SubCommands: []*Command{child}, Exec: func(ctx context.Context, s *State) error { return nil }, } root := &Command{ Name: "root", - ShortHelp: "root command", + Description: "root command", SubCommands: []*Command{parent}, Exec: func(ctx context.Context, s *State) error { return nil }, } @@ -226,7 +233,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(root, []string{}) require.NoError(t, err) - output := DefaultUsage(root) + output := help(root) 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) 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) require.NotEmpty(t, output) require.Contains(t, output, "Flags:") require.Contains(t, output, "-debug") @@ -301,10 +308,52 @@ func TestUsageGeneration(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) require.Contains(t, output, "custom [options] ") }) + t.Run("help hook replaces default text", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + Description: "custom command", + Help: func(c *Command) string { + require.Equal(t, "custom", c.Name) + return "custom help\n\nExamples:\n custom example\n" + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := help(cmd) + require.NotContains(t, output, "custom command") + require.Contains(t, output, "custom help") + require.Contains(t, output, "Examples:") + require.Contains(t, output, "custom example") + require.False(t, strings.HasSuffix(output, "\n")) + }) + + t.Run("help hook can return a plain string", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "custom", + Help: func(c *Command) string { + return "custom help" + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + err := Parse(cmd, []string{}) + require.NoError(t, err) + + output := help(cmd) + require.Equal(t, "custom help", output) + }) + t.Run("usage with inherited and local flags", func(t *testing.T) { t.Parallel() @@ -326,7 +375,7 @@ func TestUsageGeneration(t *testing.T) { err := Parse(parent, []string{"child"}) require.NoError(t, err) - output := DefaultUsage(parent) + output := help(parent) require.Contains(t, output, "-local") require.Contains(t, output, "-global") require.Contains(t, output, "local flag") @@ -334,7 +383,76 @@ func TestUsageGeneration(t *testing.T) { }) } -func TestWriteFlagSection(t *testing.T) { +func TestHelpSummaryAndDescription(t *testing.T) { + t.Parallel() + + child := &Command{ + Name: "list", + Summary: "List tasks", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + root := &Command{ + Name: "todo", + Summary: "Manage tasks", + SubCommands: []*Command{child}, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(root, nil)) + output := help(root) + require.Contains(t, output, "list List tasks") + require.NotContains(t, output, "By default, completed tasks are hidden.") + + require.NoError(t, Parse(root, []string{"list"})) + output = help(root) + require.Contains(t, output, "List tasks in the current workspace.") + require.Contains(t, output, "By default, completed tasks are hidden.") +} + +func TestHelpDescriptionFallbacks(t *testing.T) { + t.Parallel() + + t.Run("summary is shown in command help when description is empty", func(t *testing.T) { + t.Parallel() + + cmd := &Command{ + Name: "greet", + Summary: "Print a greeting", + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(cmd, nil)) + require.Contains(t, help(cmd), "Print a greeting") + }) + + t.Run("description first line is shown in command lists when summary is empty", func(t *testing.T) { + t.Parallel() + + root := &Command{ + Name: "todo", + SubCommands: []*Command{ + { + Name: "list", + Description: `List tasks in the current workspace. + +By default, completed tasks are hidden.`, + Exec: func(ctx context.Context, s *State) error { return nil }, + }, + }, + Exec: func(ctx context.Context, s *State) error { return nil }, + } + + require.NoError(t, Parse(root, nil)) + output := help(root) + require.Contains(t, output, "list List tasks in the current workspace.") + require.NotContains(t, output, "By default, completed tasks are hidden.") + }) +} + +func TestFlagHelp(t *testing.T) { t.Parallel() t.Run("non-zero defaults shown and type hints", func(t *testing.T) { @@ -353,7 +471,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) require.Contains(t, output, "Flags:") require.Contains(t, output, "-verbose") require.Contains(t, output, "-config string") @@ -384,7 +502,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) // Zero-value defaults should not appear require.NotContains(t, output, "(default: false)") require.NotContains(t, output, "(default: 0)") @@ -406,7 +524,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 +533,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{"-file", "test.txt"}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) require.Contains(t, output, "(required)") // Required flag should not also show a default require.NotContains(t, output, "(default: )") @@ -433,7 +551,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 +561,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) // Flags with short aliases show both forms require.Contains(t, output, "-v, --verbose") require.Contains(t, output, "-o, --output string") @@ -466,7 +584,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) // Without any short flags, no extra padding should be added require.Contains(t, output, " --verbose") require.Contains(t, output, " --config string") @@ -485,7 +603,7 @@ func TestWriteFlagSection(t *testing.T) { err := Parse(cmd, []string{}) require.NoError(t, err) - output := DefaultUsage(cmd) + output := help(cmd) 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