diff --git a/CHANGELOG.md b/CHANGELOG.md index d4de0b9..8d3d9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ## [Unreleased] ### Added - Pluggable flag-level help text rendering via `cli.DefaultFlagStringFunc` +- `context.GlobalBoolT` was added as an analogue to `context.GlobalBool` +- Support for hiding commands by setting `Hidden: true` -- this will hide the + commands in help output ### Changed - `Float64Flag`, `IntFlag`, and `DurationFlag` default values are no longer diff --git a/app.go b/app.go index 89c741b..b4ad8dd 100644 --- a/app.go +++ b/app.go @@ -82,8 +82,12 @@ type App struct { Email string // Writer writer to write output to Writer io.Writer + // ErrWriter writes error output + ErrWriter io.Writer // Other custom info Metadata map[string]interface{} + + didSetup bool } // Tries to find out when this binary was compiled. @@ -111,8 +115,16 @@ func NewApp() *App { } } -// Entry point to the cli app. Parses the arguments slice and routes to the proper flag/args combination -func (a *App) Run(arguments []string) (err error) { +// Setup runs initialization code to ensure all data structures are ready for +// `Run` or inspection prior to `Run`. It is internally called by `Run`, but +// will return early if setup has already happened. +func (a *App) Setup() { + if a.didSetup { + return + } + + a.didSetup = true + if a.Author != "" || a.Email != "" { a.Authors = append(a.Authors, Author{Name: a.Author, Email: a.Email}) } @@ -148,6 +160,11 @@ func (a *App) Run(arguments []string) (err error) { if !a.HideVersion { a.appendFlag(VersionFlag) } +} + +// Entry point to the cli app. Parses the arguments slice and routes to the proper flag/args combination +func (a *App) Run(arguments []string) (err error) { + a.Setup() // parse flags set := flagSet(a.Name, a.Flags) @@ -228,11 +245,11 @@ func (a *App) Run(arguments []string) (err error) { // DEPRECATED: Another entry point to the cli app, takes care of passing arguments and error handling func (a *App) RunAndExitOnError() { - fmt.Fprintf(os.Stderr, + fmt.Fprintf(a.errWriter(), "DEPRECATED cli.App.RunAndExitOnError. %s See %s\n", contactSysadmin, runAndExitOnErrorDeprecationURL) if err := a.Run(os.Args); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(a.errWriter(), err) OsExiter(1) } } @@ -357,11 +374,41 @@ func (a *App) Command(name string) *Command { return nil } -// Returnes the array containing all the categories with the commands they contain +// Categories returns a slice containing all the categories with the commands they contain func (a *App) Categories() CommandCategories { return a.categories } +// VisibleCategories returns a slice of categories and commands that are +// Hidden=false +func (a *App) VisibleCategories() []*CommandCategory { + ret := []*CommandCategory{} + for _, category := range a.categories { + if visible := func() *CommandCategory { + for _, command := range category.Commands { + if !command.Hidden { + return category + } + } + return nil + }(); visible != nil { + ret = append(ret, visible) + } + } + return ret +} + +// VisibleCommands returns a slice of the Commands with Hidden=false +func (a *App) VisibleCommands() []Command { + ret := []Command{} + for _, command := range a.Commands { + if !command.Hidden { + ret = append(ret, command) + } + } + return ret +} + // VisibleFlags returns a slice of the Flags with Hidden=false func (a *App) VisibleFlags() []Flag { return visibleFlags(a.Flags) @@ -377,6 +424,16 @@ func (a *App) hasFlag(flag Flag) bool { return false } +func (a *App) errWriter() io.Writer { + + // When the app ErrWriter is nil use the package level one. + if a.ErrWriter == nil { + return ErrWriter + } + + return a.ErrWriter +} + func (a *App) appendFlag(flag Flag) { if !a.hasFlag(flag) { a.Flags = append(a.Flags, flag) @@ -422,7 +479,7 @@ func HandleAction(action interface{}, context *Context) (err error) { vals := reflect.ValueOf(action).Call([]reflect.Value{reflect.ValueOf(context)}) if len(vals) == 0 { - fmt.Fprintf(os.Stderr, + fmt.Fprintf(ErrWriter, "DEPRECATED Action signature. Must be `cli.ActionFunc`. %s See %s\n", contactSysadmin, appActionDeprecationURL) return nil diff --git a/app_test.go b/app_test.go index bf2887e..4f054a4 100644 --- a/app_test.go +++ b/app_test.go @@ -304,6 +304,48 @@ func TestApp_CommandWithNoFlagBeforeTerminator(t *testing.T) { expect(t, args[2], "notAFlagAtAll") } +func TestApp_VisibleCommands(t *testing.T) { + app := NewApp() + app.Commands = []Command{ + Command{ + Name: "frob", + HelpName: "foo frob", + Action: func(_ *Context) error { return nil }, + }, + Command{ + Name: "frib", + HelpName: "foo frib", + Hidden: true, + Action: func(_ *Context) error { return nil }, + }, + } + + app.Setup() + expected := []Command{ + app.Commands[0], + app.Commands[2], // help + } + actual := app.VisibleCommands() + expect(t, len(expected), len(actual)) + for i, actualCommand := range actual { + expectedCommand := expected[i] + + if expectedCommand.Action != nil { + // comparing func addresses is OK! + expect(t, fmt.Sprintf("%p", expectedCommand.Action), fmt.Sprintf("%p", actualCommand.Action)) + } + + // nil out funcs, as they cannot be compared + // (https://github.com/golang/go/issues/8554) + expectedCommand.Action = nil + actualCommand.Action = nil + + if !reflect.DeepEqual(expectedCommand, actualCommand) { + t.Errorf("expected\n%#v\n!=\n%#v", expectedCommand, actualCommand) + } + } +} + func TestApp_Float64Flag(t *testing.T) { var meters float64 @@ -1124,11 +1166,114 @@ func TestApp_Run_Categories(t *testing.T) { output := buf.String() t.Logf("output: %q\n", buf.Bytes()) - if !strings.Contains(output, "1:\n command1") { - t.Errorf("want buffer to include category %q, did not: \n%q", "1:\n command1", output) + if !strings.Contains(output, "1:\n command1") { + t.Errorf("want buffer to include category %q, did not: \n%q", "1:\n command1", output) } } +func TestApp_VisibleCategories(t *testing.T) { + app := NewApp() + app.Name = "visible-categories" + app.Commands = []Command{ + Command{ + Name: "command1", + Category: "1", + HelpName: "foo command1", + Hidden: true, + }, + Command{ + Name: "command2", + Category: "2", + HelpName: "foo command2", + }, + Command{ + Name: "command3", + Category: "3", + HelpName: "foo command3", + }, + } + + expected := []*CommandCategory{ + &CommandCategory{ + Name: "2", + Commands: []Command{ + app.Commands[1], + }, + }, + &CommandCategory{ + Name: "3", + Commands: []Command{ + app.Commands[2], + }, + }, + } + + app.Setup() + expect(t, expected, app.VisibleCategories()) + + app = NewApp() + app.Name = "visible-categories" + app.Commands = []Command{ + Command{ + Name: "command1", + Category: "1", + HelpName: "foo command1", + Hidden: true, + }, + Command{ + Name: "command2", + Category: "2", + HelpName: "foo command2", + Hidden: true, + }, + Command{ + Name: "command3", + Category: "3", + HelpName: "foo command3", + }, + } + + expected = []*CommandCategory{ + &CommandCategory{ + Name: "3", + Commands: []Command{ + app.Commands[2], + }, + }, + } + + app.Setup() + expect(t, expected, app.VisibleCategories()) + + app = NewApp() + app.Name = "visible-categories" + app.Commands = []Command{ + Command{ + Name: "command1", + Category: "1", + HelpName: "foo command1", + Hidden: true, + }, + Command{ + Name: "command2", + Category: "2", + HelpName: "foo command2", + Hidden: true, + }, + Command{ + Name: "command3", + Category: "3", + HelpName: "foo command3", + Hidden: true, + }, + } + + expected = []*CommandCategory{} + + app.Setup() + expect(t, expected, app.VisibleCategories()) +} + func TestApp_Run_DoesNotOverwriteErrorFromBefore(t *testing.T) { app := NewApp() app.Action = func(c *Context) error { return nil } diff --git a/category.go b/category.go index 7dbf218..5c3d4b5 100644 --- a/category.go +++ b/category.go @@ -28,3 +28,14 @@ func (c CommandCategories) AddCommand(category string, command Command) CommandC } return append(c, &CommandCategory{Name: category, Commands: []Command{command}}) } + +// VisibleCommands returns a slice of the Commands with Hidden=false +func (c *CommandCategory) VisibleCommands() []Command { + ret := []Command{} + for _, command := range c.Commands { + if !command.Hidden { + ret = append(ret, command) + } + } + return ret +} diff --git a/command.go b/command.go index 9ca7e51..09a9464 100644 --- a/command.go +++ b/command.go @@ -48,6 +48,8 @@ type Command struct { SkipFlagParsing bool // Boolean to hide built-in help command HideHelp bool + // Boolean to hide this command from help or completion + Hidden bool // Full name of command for help, defaults to full command name, including parent commands. HelpName string diff --git a/context.go b/context.go index ef3d2fc..aad2812 100644 --- a/context.go +++ b/context.go @@ -104,6 +104,14 @@ func (c *Context) GlobalBool(name string) bool { return false } +// Looks up the value of a global bool flag, returns true if no bool flag exists +func (c *Context) GlobalBoolT(name string) bool { + if fs := lookupGlobalFlagSet(name, c); fs != nil { + return lookupBoolT(name, fs) + } + return false +} + // Looks up the value of a global string flag, returns "" if no string flag exists func (c *Context) GlobalString(name string) string { if fs := lookupGlobalFlagSet(name, c); fs != nil { diff --git a/context_test.go b/context_test.go index 4c23271..7ba2ebd 100644 --- a/context_test.go +++ b/context_test.go @@ -82,6 +82,30 @@ func TestContext_BoolT(t *testing.T) { expect(t, c.BoolT("myflag"), true) } +func TestContext_GlobalBool(t *testing.T) { + set := flag.NewFlagSet("test", 0) + + globalSet := flag.NewFlagSet("test-global", 0) + globalSet.Bool("myflag", false, "doc") + globalCtx := NewContext(nil, globalSet, nil) + + c := NewContext(nil, set, globalCtx) + expect(t, c.GlobalBool("myflag"), false) + expect(t, c.GlobalBool("nope"), false) +} + +func TestContext_GlobalBoolT(t *testing.T) { + set := flag.NewFlagSet("test", 0) + + globalSet := flag.NewFlagSet("test-global", 0) + globalSet.Bool("myflag", true, "doc") + globalCtx := NewContext(nil, globalSet, nil) + + c := NewContext(nil, set, globalCtx) + expect(t, c.GlobalBoolT("myflag"), true) + expect(t, c.GlobalBoolT("nope"), false) +} + func TestContext_Args(t *testing.T) { set := flag.NewFlagSet("test", 0) set.Bool("myflag", false, "doc") diff --git a/errors.go b/errors.go index 5f1e83b..db46a83 100644 --- a/errors.go +++ b/errors.go @@ -2,12 +2,17 @@ package cli import ( "fmt" + "io" "os" "strings" ) var OsExiter = os.Exit +// ErrWriter is used to write errors to the user. This can be anything +// implementing the io.Writer interface and defaults to os.Stderr. +var ErrWriter io.Writer = os.Stderr + type MultiError struct { Errors []error } @@ -69,7 +74,7 @@ func HandleExitCoder(err error) { if exitErr, ok := err.(ExitCoder); ok { if err.Error() != "" { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(ErrWriter, err) } OsExiter(exitErr.ExitCode()) return diff --git a/errors_test.go b/errors_test.go index 6863105..8f5f284 100644 --- a/errors_test.go +++ b/errors_test.go @@ -34,7 +34,7 @@ func TestHandleExitCoder_ExitCoder(t *testing.T) { defer func() { OsExiter = os.Exit }() - HandleExitCoder(NewExitError("galactic perimiter breach", 9)) + HandleExitCoder(NewExitError("galactic perimeter breach", 9)) expect(t, exitCode, 9) expect(t, called, true) @@ -51,7 +51,7 @@ func TestHandleExitCoder_MultiErrorWithExitCoder(t *testing.T) { defer func() { OsExiter = os.Exit }() - exitErr := NewExitError("galactic perimiter breach", 9) + exitErr := NewExitError("galactic perimeter breach", 9) err := NewMultiError(errors.New("wowsa"), errors.New("egad"), exitErr) HandleExitCoder(err) diff --git a/flag.go b/flag.go index 3b6a2e1..8354de0 100644 --- a/flag.go +++ b/flag.go @@ -220,7 +220,7 @@ func (f IntSliceFlag) Apply(set *flag.FlagSet) { s = strings.TrimSpace(s) err := newVal.Set(s) if err != nil { - fmt.Fprintf(os.Stderr, err.Error()) + fmt.Fprintf(ErrWriter, err.Error()) } } f.Value = newVal diff --git a/help.go b/help.go index 259e452..6591e8b 100644 --- a/help.go +++ b/help.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "os" "strings" "text/tabwriter" "text/template" @@ -21,15 +22,15 @@ VERSION: {{.Version}} {{end}}{{end}}{{if len .Authors}} AUTHOR(S): - {{range .Authors}}{{ . }}{{end}} - {{end}}{{if .Commands}} -COMMANDS:{{range .Categories}}{{if .Name}} - {{.Name}}{{ ":" }}{{end}}{{range .Commands}} - {{.Name}}{{with .ShortName}}, {{.}}{{end}}{{ "\t" }}{{.Usage}}{{end}} + {{range .Authors}}{{.}}{{end}} + {{end}}{{if .VisibleCommands}} +COMMANDS:{{range .VisibleCategories}}{{if .Name}} + {{.Name}}:{{end}}{{range .VisibleCommands}} + {{.Name}}{{with .ShortName}}, {{.}}{{end}}{{"\t"}}{{.Usage}}{{end}} {{end}}{{end}}{{if .VisibleFlags}} GLOBAL OPTIONS: {{range .VisibleFlags}}{{.}} - {{end}}{{end}}{{if .Copyright }} + {{end}}{{end}}{{if .Copyright}} COPYRIGHT: {{.Copyright}} {{end}} @@ -52,7 +53,7 @@ DESCRIPTION: OPTIONS: {{range .VisibleFlags}}{{.}} - {{end}}{{ end }} + {{end}}{{end}} ` // The text template for the subcommand help topic. @@ -64,9 +65,9 @@ var SubcommandHelpTemplate = `NAME: USAGE: {{.HelpName}} command{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}} -COMMANDS:{{range .Categories}}{{if .Name}} - {{.Name}}{{ ":" }}{{end}}{{range .Commands}} - {{.Name}}{{with .ShortName}}, {{.}}{{end}}{{ "\t" }}{{.Usage}}{{end}} +COMMANDS:{{range .VisibleCategories}}{{if .Name}} + {{.Name}}:{{end}}{{range .VisibleCommands}} + {{.Name}}{{with .ShortName}}, {{.}}{{end}}{{"\t"}}{{.Usage}}{{end}} {{end}}{{if .VisibleFlags}} OPTIONS: {{range .VisibleFlags}}{{.}} @@ -119,6 +120,9 @@ func ShowAppHelp(c *Context) { // Prints the list of subcommands as the default app completion method func DefaultAppComplete(c *Context) { for _, command := range c.App.Commands { + if command.Hidden { + continue + } for _, name := range command.Names() { fmt.Fprintln(c.App.Writer, name) } @@ -188,7 +192,10 @@ func printHelp(out io.Writer, templ string, data interface{}) { err := t.Execute(w, data) if err != nil { // If the writer is closed, t.Execute will fail, and there's nothing - // we can do to recover. We could send this to os.Stderr if we need. + // we can do to recover. + if os.Getenv("CLI_TEMPLATE_ERROR_DEBUG") != "" { + fmt.Fprintf(ErrWriter, "CLI TEMPLATE ERROR: %#v\n", err) + } return } w.Flush() diff --git a/help_test.go b/help_test.go index 0fabdba..c5372e4 100644 --- a/help_test.go +++ b/help_test.go @@ -168,3 +168,35 @@ func Test_helpSubcommand_Action_ErrorIfNoTopic(t *testing.T) { t.Fatalf("expected exit value = 3, got %d instead", exitErr.exitCode) } } + +func TestShowAppHelp_HiddenCommand(t *testing.T) { + app := &App{ + Commands: []Command{ + Command{ + Name: "frobbly", + Action: func(ctx *Context) error { + return nil + }, + }, + Command{ + Name: "secretfrob", + Hidden: true, + Action: func(ctx *Context) error { + return nil + }, + }, + }, + } + + output := &bytes.Buffer{} + app.Writer = output + app.Run([]string{"app", "--help"}) + + if strings.Contains(output.String(), "secretfrob") { + t.Errorf("expected output to exclude \"secretfrob\"; got: %q", output.String()) + } + + if !strings.Contains(output.String(), "frobbly") { + t.Errorf("expected output to include \"frobbly\"; got: %q", output.String()) + } +}