diff --git a/.github/ISSUE_TEMPLATE/v1-bug-report.md b/.github/ISSUE_TEMPLATE/v1-bug-report.md index cb9e8654fc..9b649e26ea 100644 --- a/.github/ISSUE_TEMPLATE/v1-bug-report.md +++ b/.github/ISSUE_TEMPLATE/v1-bug-report.md @@ -14,7 +14,7 @@ _**( Put the version of urfave/cli that you are using here )**_ ## Checklist - [ ] Are you running the latest v1 release? The list of releases is [here](https://github.com/urfave/cli/releases). -- [ ] Did you check the manual for your release? The v1 manual is [here](https://github.com/urfave/cli/blob/main/docs/v1/manual.md). +- [ ] Did you check the manual for your release? The v1 manual is [here](https://cli.urfave.org/v1/getting-started/). - [ ] Did you perform a search about this problem? Here's the [GitHub guide](https://help.github.com/en/github/managing-your-work-on-github/using-search-to-filter-issues-and-pull-requests) about searching. ## Dependency Management diff --git a/.github/ISSUE_TEMPLATE/v2-bug-report.md b/.github/ISSUE_TEMPLATE/v2-bug-report.md index cce26671c4..4f9b1e62cb 100644 --- a/.github/ISSUE_TEMPLATE/v2-bug-report.md +++ b/.github/ISSUE_TEMPLATE/v2-bug-report.md @@ -14,7 +14,7 @@ _**( Put the version of urfave/cli that you are using here )**_ ## Checklist - [ ] Are you running the latest v2 release? The list of releases is [here](https://github.com/urfave/cli/releases). -- [ ] Did you check the manual for your release? The v2 manual is [here](https://github.com/urfave/cli/blob/main/docs/v2/manual.md) +- [ ] Did you check the manual for your release? The v2 manual is [here](https://cli.urfave.org/v2/getting-started/) - [ ] Did you perform a search about this problem? Here's the [GitHub guide](https://help.github.com/en/github/managing-your-work-on-github/using-search-to-filter-issues-and-pull-requests) about searching. ## Dependency Management diff --git a/.github/ISSUE_TEMPLATE/v3-feature-request.md b/.github/ISSUE_TEMPLATE/v3-feature-request.md index f706c685ef..ab70d0e013 100644 --- a/.github/ISSUE_TEMPLATE/v3-feature-request.md +++ b/.github/ISSUE_TEMPLATE/v3-feature-request.md @@ -10,7 +10,7 @@ assignees: '' ## Checklist * [ ] Are you running the latest v3 release? The list of releases is [here](https://github.com/urfave/cli/releases). -* [ ] Did you check the manual for your release? The v3 manual is [here](https://github.com/urfave/cli/blob/main/docs/v3/manual.md). +* [ ] Did you check the manual for your release? The v3 manual is [here](https://github.com/urfave/cli/blob/main/docs/v3/index.md). * [ ] Did you perform a search about this feature? Here's the [GitHub guide](https://help.github.com/en/github/managing-your-work-on-github/using-search-to-filter-issues-and-pull-requests) about searching. ## What problem does this solve? diff --git a/README.md b/README.md index 415355f08b..e04c7295b5 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ command line tools in Go featuring: - environment variables - plain text files - structured file formats (supported via the - [`urfave/cli-altsrc`][urfave/cli-docs] module) + [`urfave/cli-altsrc`][urfave/cli-altsrc] module) ## Documentation diff --git a/args_test.go b/args_test.go index 7b0a727216..983ed5d06a 100644 --- a/args_test.go +++ b/args_test.go @@ -68,7 +68,7 @@ func TestArgumentsSubcommand(t *testing.T) { Max: 1, Destination: &tval, Config: TimestampConfig{ - Layout: time.RFC3339, + Layouts: []string{time.RFC3339}, }, }, &StringArg{ diff --git a/command.go b/command.go index 6d14d0f385..66978e5063 100644 --- a/command.go +++ b/command.go @@ -804,12 +804,6 @@ func (cmd *Command) parseFlags(args Args) (Args, error) { return cmd.Args(), err } - tracef("normalizing flags (cmd=%[1]q)", cmd.Name) - - if err := normalizeFlags(cmd.Flags, cmd.flagSet); err != nil { - return cmd.Args(), err - } - tracef("done parsing flags (cmd=%[1]q)", cmd.Name) return cmd.Args(), nil @@ -878,6 +872,19 @@ func (cmd *Command) appendFlag(fl Flag) { } } +// VisiblePersistentFlags returns a slice of [PersistentFlag] with Persistent=true and Hidden=false. +func (cmd *Command) VisiblePersistentFlags() []Flag { + var flags []Flag + for _, fl := range cmd.Root().Flags { + pfl, ok := fl.(PersistentFlag) + if !ok || !pfl.IsPersistent() { + continue + } + flags = append(flags, fl) + } + return visibleFlags(flags) +} + func (cmd *Command) appendCommand(aCmd *Command) { if !hasCommand(cmd.Commands, aCmd) { aCmd.parent = cmd diff --git a/command_test.go b/command_test.go index 094bfecbde..2d183d0391 100644 --- a/command_test.go +++ b/command_test.go @@ -2490,6 +2490,7 @@ func TestSetupInitializesOnlyNilWriters(t *testing.T) { } func TestFlagAction(t *testing.T) { + now := time.Now().UTC().Truncate(time.Minute) testCases := []struct { name string args []string @@ -2578,8 +2579,8 @@ func TestFlagAction(t *testing.T) { }, { name: "flag_timestamp", - args: []string{"app", "--f_timestamp", "2022-05-01 02:26:20"}, - exp: "2022-05-01T02:26:20Z ", + args: []string{"app", "--f_timestamp", now.Format(time.DateTime)}, + exp: now.UTC().Format(time.RFC3339) + " ", }, { name: "flag_timestamp_error", @@ -2738,12 +2739,14 @@ func TestFlagAction(t *testing.T) { &TimestampFlag{ Name: "f_timestamp", Config: TimestampConfig{ - Layout: "2006-01-02 15:04:05", + Timezone: time.UTC, + Layouts: []string{time.DateTime}, }, Action: func(_ context.Context, cmd *Command, v time.Time) error { if v.IsZero() { return fmt.Errorf("zero timestamp") } + _, err := cmd.Root().Writer.Write([]byte(v.Format(time.RFC3339) + " ")) return err }, @@ -3204,17 +3207,60 @@ func TestCommand_Bool(t *testing.T) { } func TestCommand_Value(t *testing.T) { - set := flag.NewFlagSet("test", 0) - set.Int("myflag", 12, "doc") - parentSet := flag.NewFlagSet("test", 0) - parentSet.Int("top-flag", 13, "doc") - pCmd := &Command{flagSet: parentSet} - cmd := &Command{flagSet: set, parent: pCmd} + subCmd := &Command{ + Name: "test", + Flags: []Flag{ + &IntFlag{ + Name: "myflag", + Usage: "doc", + Aliases: []string{"m", "mf"}, + }, + }, + Action: func(ctx context.Context, c *Command) error { + return nil + }, + } - r := require.New(t) - r.Equal(12, cmd.Value("myflag")) - r.Equal(13, cmd.Value("top-flag")) - r.Nil(cmd.Value("unknown-flag")) + cmd := &Command{ + Flags: []Flag{ + &IntFlag{ + Name: "top-flag", + Usage: "doc", + Aliases: []string{"t", "tf"}, + }, + }, + Commands: []*Command{ + subCmd, + }, + } + t.Run("flag name", func(t *testing.T) { + r := require.New(t) + err := cmd.Run(buildTestContext(t), []string{"main", "--top-flag", "13", "test", "--myflag", "14"}) + + r.NoError(err) + r.Equal(int64(13), cmd.Value("top-flag")) + r.Equal(int64(13), cmd.Value("t")) + r.Equal(int64(13), cmd.Value("tf")) + + r.Equal(int64(14), subCmd.Value("myflag")) + r.Equal(int64(14), subCmd.Value("m")) + r.Equal(int64(14), subCmd.Value("mf")) + }) + + t.Run("flag aliases", func(t *testing.T) { + r := require.New(t) + err := cmd.Run(buildTestContext(t), []string{"main", "-tf", "15", "test", "-m", "16"}) + + r.NoError(err) + r.Equal(int64(15), cmd.Value("top-flag")) + r.Equal(int64(15), cmd.Value("t")) + r.Equal(int64(15), cmd.Value("tf")) + + r.Equal(int64(16), subCmd.Value("myflag")) + r.Equal(int64(16), subCmd.Value("m")) + r.Equal(int64(16), subCmd.Value("mf")) + r.Nil(cmd.Value("unknown-flag")) + }) } func TestCommand_Value_InvalidFlagAccessHandler(t *testing.T) { @@ -3720,7 +3766,7 @@ func TestCommandReadArgsFromStdIn(t *testing.T) { { name: "empty2", input: ` - + `, args: []string{"foo"}, expectedInt: 0, @@ -3738,7 +3784,7 @@ func TestCommandReadArgsFromStdIn(t *testing.T) { { name: "intflag-from-input2", input: ` - --if + --if 100`, args: []string{"foo"}, @@ -3757,14 +3803,14 @@ func TestCommandReadArgsFromStdIn(t *testing.T) { --ssf hello --ssf - "hello + "hello 123 44" `, args: []string{"foo"}, expectedInt: 100, expectedFloat: 100.1, - expectedSlice: []string{"hello", "hello\t\n 123\n44"}, + expectedSlice: []string{"hello", "hello\n 123\n44"}, }, { name: "end-args", @@ -3919,6 +3965,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": "", "aliases": [ @@ -3929,7 +3976,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "TrimSpace": false }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "sub-command-flag", @@ -3938,6 +3986,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some usage text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": [ @@ -3947,7 +3996,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false } ], "hideHelp": false, @@ -3977,6 +4027,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": "", "aliases": [ @@ -3987,7 +4038,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "TrimSpace": false }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "another-flag", @@ -3996,6 +4048,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": [ @@ -4005,7 +4058,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false } ], "hideHelp": false, @@ -4153,6 +4207,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some usage text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": [ @@ -4162,7 +4217,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false } ], "hideHelp": false, @@ -4192,6 +4248,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": "", "aliases": [ @@ -4202,7 +4259,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "TrimSpace": false }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "another-flag", @@ -4211,6 +4269,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": [ @@ -4220,7 +4279,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false } ], "hideHelp": false, @@ -4250,6 +4310,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some 'usage' text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": "value", "aliases": [ @@ -4259,7 +4320,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "TrimSpace": false }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "flag", @@ -4268,6 +4330,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": "", "aliases": [ @@ -4278,7 +4341,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "TrimSpace": false }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "another-flag", @@ -4287,6 +4351,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": [ @@ -4296,7 +4361,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false }, { "name": "hidden-flag", @@ -4305,6 +4371,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": true, + "hideDefault": false, "persistent": false, "defaultValue": false, "aliases": null, @@ -4312,7 +4379,8 @@ func TestJSONExportCommand(t *testing.T) { "config": { "Count": null }, - "onlyOnce": false + "onlyOnce": false, + "validateDefaults" : false } ], "hideHelp": false, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 349929f773..c0054eb9a6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -63,7 +63,7 @@ make Running the default `make` target (`all`) will ensure all of the critical steps are run to verify one's changes are harmonious in nature. The same steps are also run during the [continuous integration -phase](https://github.com/urfave/cli/blob/main/.github/workflows/cli.yml). +phase](https://github.com/urfave/cli/blob/main/.github/workflows/test.yml). In the event that the `v3diff` target exits non-zero, this is a signal that the public API surface area has changed. If the changes are acceptable, then manually running the @@ -81,7 +81,7 @@ step. #### docs output The documentation in the `docs` directory is automatically built via `mkdocs` into a -static site and published when releases are pushed (see [RELEASING](./RELEASING/)). There +static site and published when releases are pushed (see [RELEASING](./RELEASING.md)). There is no strict requirement to build the documentation when developing locally, but the following `make` targets may be used if desired: diff --git a/docs/index.md b/docs/index.md index bf7f7d7bc3..3d1a051432 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ https://github.com/urfave/cli/blob/main/README.md --> # Welcome to urfave/cli -[![Run Tests](https://github.com/urfave/cli/actions/workflows/cli.yml/badge.svg)](https://github.com/urfave/cli/actions/workflows/cli.yml) +[![Run Tests](https://github.com/urfave/cli/actions/workflows/test.yml/badge.svg)](https://github.com/urfave/cli/actions/workflows/test.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/urfave/cli/v3.svg)](https://pkg.go.dev/github.com/urfave/cli/v3) [![Go Report Card](https://goreportcard.com/badge/github.com/urfave/cli/v3)](https://goreportcard.com/report/github.com/urfave/cli/v3) [![codecov](https://codecov.io/gh/urfave/cli/branch/main/graph/badge.svg?token=t9YGWLh05g)](https://codecov.io/gh/urfave/cli) @@ -94,4 +94,4 @@ cli is tested against multiple versions of Go on Linux, and against the latest released version of Go on OS X and Windows. This project uses GitHub Actions for builds. To see our currently supported go versions and platforms, look at the [github workflow -configuration](https://github.com/urfave/cli/blob/main/.github/workflows/cli.yml). +configuration](https://github.com/urfave/cli/blob/main/.github/workflows/test.yml). diff --git a/docs/v2/examples/flags.md b/docs/v2/examples/flags.md index eb87e6c6d4..2f16c4c3e8 100644 --- a/docs/v2/examples/flags.md +++ b/docs/v2/examples/flags.md @@ -104,9 +104,11 @@ See full list of flags at https://pkg.go.dev/github.com/urfave/cli/v2 For bool flags you can specify the flag multiple times to get a count(e.g -v -v -v or -vvv) +> If you want to support the `-vvv` flag, you need to set `App.UseShortOptionHandling`. + ```go package main @@ -123,10 +125,12 @@ func main() { var count int app := &cli.App{ + UseShortOptionHandling: true, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "foo", Usage: "foo greeting", + Aliases: []string{"f"}, Count: &count, }, }, diff --git a/docs/v2/examples/full-api-example.md b/docs/v2/examples/full-api-example.md index 289cffeb34..a692175ecc 100644 --- a/docs/v2/examples/full-api-example.md +++ b/docs/v2/examples/full-api-example.md @@ -107,10 +107,11 @@ func main() { Action: wopAction, }, }, - SkipFlagParsing: false, - HideHelp: false, - Hidden: false, - HelpName: "doo!", + SkipFlagParsing: false, + HideHelp: false, + HideHelpCommands: false, + Hidden: false, + HelpName: "doo!", BashComplete: func(cCtx *cli.Context) { fmt.Fprintf(cCtx.App.Writer, "--better\n") }, @@ -156,6 +157,7 @@ func main() { }, EnableBashCompletion: true, HideHelp: false, + HideHelpCommands: false, HideVersion: false, BashComplete: func(cCtx *cli.Context) { fmt.Fprintf(cCtx.App.Writer, "lipstick\nkiss\nme\nlipstick\nringo\n") diff --git a/docs/v3/examples/flags.md b/docs/v3/examples/flags.md index 1344c55182..04d1aefb44 100644 --- a/docs/v3/examples/flags.md +++ b/docs/v3/examples/flags.md @@ -106,9 +106,11 @@ See full list of flags at https://pkg.go.dev/github.com/urfave/cli/v3 For bool flags you can specify the flag multiple times to get a count(e.g -v -v -v or -vvv) +> If you want to support the `-vvv` flag, you need to set `Command.UseShortOptionHandling`. + ```go package main @@ -126,10 +128,12 @@ func main() { var count int cmd := &cli.Command{ + UseShortOptionHandling: true, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "foo", Usage: "foo greeting", + Aliases: []string{"f"}, Config: cli.BoolConfig{ Count: &count, }, diff --git a/docs/v3/examples/timestamp-flag.md b/docs/v3/examples/timestamp-flag.md index 7180824af6..0513977949 100644 --- a/docs/v3/examples/timestamp-flag.md +++ b/docs/v3/examples/timestamp-flag.md @@ -28,7 +28,12 @@ import ( func main() { cmd := &cli.Command{ Flags: []cli.Flag{ - &cli.TimestampFlag{Name: "meeting", Config: cli.TimestampConfig{Layout: "2006-01-02T15:04:05"}}, + &cli.TimestampFlag{ + Name: "meeting", + Config: cli.TimestampConfig{ + Layouts: []string{"2006-01-02T15:04:05"}, + }, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { fmt.Printf("%s", cmd.Timestamp("meeting").String()) @@ -54,7 +59,13 @@ change behavior, a default timezone can be provided with flag definition: ```go cmd := &cli.Command{ Flags: []cli.Flag{ - &cli.TimestampFlag{Name: "meeting", Config: cli.TimestampConfig{Layout: "2006-01-02T15:04:05", Timezone: time.Local}}, + &cli.TimestampFlag{ + Name: "meeting", + Config: cli.TimestampConfig{ + Timezone: time.Local, + AvailableLayouts: []string{"2006-01-02T15:04:05"}, + }, + }, }, } ``` diff --git a/examples_test.go b/examples_test.go index b7050b801d..28a34e4a96 100644 --- a/examples_test.go +++ b/examples_test.go @@ -143,8 +143,8 @@ func ExampleCommand_Run_appHelp() { // // GLOBAL OPTIONS: // --name value a name to say (default: "bob") - // --help, -h show help (default: false) - // --version, -v print the version (default: false) + // --help, -h show help + // --version, -v print the version } func ExampleCommand_Run_commandHelp() { @@ -190,7 +190,7 @@ func ExampleCommand_Run_commandHelp() { // help, h Shows a list of commands or help for one command // // OPTIONS: - // --help, -h show help (default: false) + // --help, -h show help } func ExampleCommand_Run_noAction() { @@ -211,7 +211,7 @@ func ExampleCommand_Run_noAction() { // help, h Shows a list of commands or help for one command // // GLOBAL OPTIONS: - // --help, -h show help (default: false) + // --help, -h show help } func ExampleCommand_Run_subcommandNoAction() { @@ -243,7 +243,7 @@ func ExampleCommand_Run_subcommandNoAction() { // This is how we describe describeit the function // // OPTIONS: - // --help, -h show help (default: false) + // --help, -h show help } func ExampleCommand_Run_shellComplete_bash_withShortFlag() { @@ -405,7 +405,7 @@ func ExampleCommand_Run_shellComplete_zsh() { // Simulate a zsh environment and command line arguments os.Args = []string{"greet", "--generate-shell-completion"} - os.Setenv("SHELL", "/usr/bin/zsh") + os.Setenv("0", "/usr/bin/zsh") _ = cmd.Run(context.Background(), os.Args) // Output: diff --git a/flag.go b/flag.go index f5c24b0f37..76a5d71669 100644 --- a/flag.go +++ b/flag.go @@ -2,7 +2,6 @@ package cli import ( "context" - "errors" "flag" "fmt" "io" @@ -35,18 +34,20 @@ var GenerateShellCompletionFlag Flag = &BoolFlag{ // VersionFlag prints the version for the application var VersionFlag Flag = &BoolFlag{ - Name: "version", - Aliases: []string{"v"}, - Usage: "print the version", + Name: "version", + Aliases: []string{"v"}, + Usage: "print the version", + HideDefault: true, } // HelpFlag prints the help for all commands and subcommands. // Set to nil to disable the flag. The subcommand // will still be added unless HideHelp or HideHelpCommand is set to true. var HelpFlag Flag = &BoolFlag{ - Name: "help", - Aliases: []string{"h"}, - Usage: "show help", + Name: "help", + Aliases: []string{"h"}, + Usage: "show help", + HideDefault: true, } // FlagStringer converts a flag definition to a string. This is used by help @@ -135,6 +136,10 @@ type DocGenerationFlag interface { // GetEnvVars returns the env vars for this flag GetEnvVars() []string + + // IsDefaultVisible returns whether the default value should be shown in + // help text + IsDefaultVisible() bool } // DocGenerationMultiValueFlag extends DocGenerationFlag for slice/map based flags. @@ -173,6 +178,11 @@ type PersistentFlag interface { IsPersistent() bool } +// IsDefaultVisible returns true if the flag is not hidden, otherwise false +func (f *FlagBase[T, C, V]) IsDefaultVisible() bool { + return !f.HideDefault +} + func newFlagSet(name string, flags []Flag) (*flag.FlagSet, error) { set := flag.NewFlagSet(name, flag.ContinueOnError) @@ -187,48 +197,6 @@ func newFlagSet(name string, flags []Flag) (*flag.FlagSet, error) { return set, nil } -func copyFlag(name string, ff *flag.Flag, set *flag.FlagSet) { - switch ff.Value.(type) { - case Serializer: - _ = set.Set(name, ff.Value.(Serializer).Serialize()) - default: - _ = set.Set(name, ff.Value.String()) - } -} - -func normalizeFlags(flags []Flag, set *flag.FlagSet) error { - visited := make(map[string]bool) - set.Visit(func(f *flag.Flag) { - visited[f.Name] = true - }) - for _, f := range flags { - parts := f.Names() - if len(parts) == 1 { - continue - } - var ff *flag.Flag - for _, name := range parts { - name = strings.Trim(name, " ") - if visited[name] { - if ff != nil { - return errors.New("Cannot use two forms of the same flag: " + name + " " + ff.Name) - } - ff = set.Lookup(name) - } - } - if ff == nil { - continue - } - for _, name := range parts { - name = strings.Trim(name, " ") - if !visited[name] { - copyFlag(name, ff, set) - } - } - } - return nil -} - func visibleFlags(fl []Flag) []Flag { var visible []Flag for _, f := range fl { @@ -347,8 +315,12 @@ func stringifyFlag(f Flag) string { defaultValueString := "" - if s := df.GetDefaultText(); s != "" { - defaultValueString = fmt.Sprintf(formatDefault("%s"), s) + // don't print default text for required flags + if rf, ok := f.(RequiredFlag); !ok || !rf.IsRequired() { + isVisible := df.IsDefaultVisible() + if s := df.GetDefaultText(); isVisible && s != "" { + defaultValueString = fmt.Sprintf(formatDefault("%s"), s) + } } usageWithDefault := strings.TrimSpace(usage + defaultValueString) diff --git a/flag_bool_with_inverse.go b/flag_bool_with_inverse.go index a7fea1ba92..371064c5f7 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -123,6 +123,14 @@ func (parent *BoolWithInverseFlag) inverseName() string { return parent.InversePrefix + parent.BoolFlag.Name } +func (parent *BoolWithInverseFlag) inversePrefix() string { + if parent.InversePrefix == "" { + return DefaultInverseBoolPrefix + } + + return parent.InversePrefix +} + func (parent *BoolWithInverseFlag) inverseAliases() (aliases []string) { if len(parent.BoolFlag.Aliases) > 0 { aliases = make([]string, len(parent.BoolFlag.Aliases)) @@ -170,11 +178,17 @@ func (parent *BoolWithInverseFlag) Names() []string { // String implements the standard Stringer interface. // // Example for BoolFlag{Name: "env"} -// --env (default: false) || --no-env (default: false) +// --[no-]env (default: false) func (parent *BoolWithInverseFlag) String() string { - if parent.positiveFlag == nil { - return fmt.Sprintf("%s || --%s", parent.BoolFlag.String(), parent.inverseName()) + out := FlagStringer(parent) + i := strings.Index(out, "\t") + + prefix := "--" + + // single character flags are prefixed with `-` instead of `--` + if len(parent.Name) == 1 { + prefix = "-" } - return fmt.Sprintf("%s || %s", parent.positiveFlag.String(), parent.negativeFlag.String()) + return fmt.Sprintf("%s[%s]%s%s", prefix, parent.inversePrefix(), parent.Name, out[i:]) } diff --git a/flag_bool_with_inverse_test.go b/flag_bool_with_inverse_test.go index 20030b4e28..62266ff714 100644 --- a/flag_bool_with_inverse_test.go +++ b/flag_bool_with_inverse_test.go @@ -334,10 +334,84 @@ func TestBoolWithInverseNames(t *testing.T) { require.Len(t, names, 2) require.Equal(t, "env", names[0], "expected first name to be `env`") require.Equal(t, "no-env", names[1], "expected first name to be `no-env`") +} + +func TestBoolWithInverseString(t *testing.T) { + tcs := []struct { + testName string + flagName string + required bool + usage string + inversePrefix string + expected string + }{ + { + testName: "empty inverse prefix", + flagName: "", + required: true, + expected: "--[no-]\t", + }, + { + testName: "single-char flag name", + flagName: "e", + required: true, + expected: "-[no-]e\t", + }, + { + testName: "multi-char flag name", + flagName: "env", + required: true, + expected: "--[no-]env\t", + }, + { + testName: "required with usage", + flagName: "env", + required: true, + usage: "env usage", + expected: "--[no-]env\tenv usage", + }, + { + testName: "required without usage", + flagName: "env", + required: true, + expected: "--[no-]env\t", + }, + { + testName: "not required with default usage", + flagName: "env", + required: false, + expected: "--[no-]env\t(default: false)", + }, + { + testName: "custom inverse prefix", + flagName: "env", + required: true, + inversePrefix: "nope-", + expected: "--[nope-]env\t", + }, + { + testName: "empty inverse prefix", + flagName: "env", + required: true, + inversePrefix: "", + expected: "--[no-]env\t", + }, + } + + for _, tc := range tcs { + t.Run(tc.testName, func(t *testing.T) { + flag := &BoolWithInverseFlag{ + BoolFlag: &BoolFlag{ + Name: tc.flagName, + Usage: tc.usage, + Required: tc.required, + }, + InversePrefix: tc.inversePrefix, + } - flagString := flag.String() - require.Contains(t, flagString, "--env") - require.Contains(t, flagString, "--no-env") + require.Equal(t, tc.expected, flag.String()) + }) + } } func TestBoolWithInverseDestination(t *testing.T) { diff --git a/flag_impl.go b/flag_impl.go index 36edcc903b..610920d64b 100644 --- a/flag_impl.go +++ b/flag_impl.go @@ -69,22 +69,24 @@ type NoConfig struct{} // C specifies the configuration required(if any for that flag type) // VC specifies the value creator which creates the flag.Value emulation type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { - Name string `json:"name"` // name of the flag - Category string `json:"category"` // category of the flag, if any - DefaultText string `json:"defaultText"` // default text of the flag for usage purposes - Usage string `json:"usage"` // usage string for help output - Sources ValueSourceChain `json:"-"` // sources to load flag value from - Required bool `json:"required"` // whether the flag is required or not - Hidden bool `json:"hidden"` // whether to hide the flag in help output - Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well - Value T `json:"defaultValue"` // default value for this flag if not set by from any source - Destination *T `json:"-"` // destination pointer for value when set - Aliases []string `json:"aliases"` // Aliases that are allowed for this flag - TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes - Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set - Config C `json:"config"` // Additional/Custom configuration associated with this flag type - OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line - Validator func(T) error `json:"-"` // custom function to validate this flag value + Name string `json:"name"` // name of the flag + Category string `json:"category"` // category of the flag, if any + DefaultText string `json:"defaultText"` // default text of the flag for usage purposes + HideDefault bool `json:"hideDefault"` // whether to hide the default value in output + Usage string `json:"usage"` // usage string for help output + Sources ValueSourceChain `json:"-"` // sources to load flag value from + Required bool `json:"required"` // whether the flag is required or not + Hidden bool `json:"hidden"` // whether to hide the flag in help output + Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well + Value T `json:"defaultValue"` // default value for this flag if not set by from any source + Destination *T `json:"-"` // destination pointer for value when set + Aliases []string `json:"aliases"` // Aliases that are allowed for this flag + TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes + Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set + Config C `json:"config"` // Additional/Custom configuration associated with this flag type + OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line + Validator func(T) error `json:"-"` // custom function to validate this flag value + ValidateDefaults bool `json:"validateDefaults"` // whether to validate defaults or not // unexported fields for internal use count int // number of times the flag has been set @@ -144,7 +146,7 @@ func (f *FlagBase[T, C, V]) Apply(set *flag.FlagSet) error { } // Validate the given default or values set from external sources as well - if f.Validator != nil { + if f.Validator != nil && f.ValidateDefaults { if v, ok := f.value.Get().(T); !ok { return &typeError[T]{ other: f.value.Get(), diff --git a/flag_test.go b/flag_test.go index 6f67066411..3b3ef5e768 100644 --- a/flag_test.go +++ b/flag_test.go @@ -2,11 +2,13 @@ package cli import ( "context" + "errors" "flag" "fmt" "io" "os" "reflect" + "regexp" "strings" "testing" "time" @@ -83,28 +85,38 @@ func TestBoolFlagCountFromCommand(t *testing.T) { expectedCount int }{ { - input: []string{"-tf", "-w", "-huh"}, + input: []string{"main", "-tf", "-w", "-huh"}, expectedVal: true, expectedCount: 3, }, { - input: []string{}, + input: []string{"main", "-huh"}, + expectedVal: true, + expectedCount: 1, + }, + { + input: []string{"main"}, expectedVal: false, expectedCount: 0, }, } + bf := &BoolFlag{Name: "tf", Aliases: []string{"w", "huh"}} for _, bct := range boolCountTests { - set := flag.NewFlagSet("test", 0) - cmd := &Command{flagSet: set} - tf := &BoolFlag{Name: "tf", Aliases: []string{"w", "huh"}} + cmd := &Command{ + Flags: []Flag{ + bf, + }, + } r := require.New(t) - r.NoError(tf.Apply(set)) - r.NoError(set.Parse(bct.input)) + r.NoError(cmd.Run(buildTestContext(t), bct.input)) - r.Equal(bct.expectedVal, tf.Get(cmd)) - r.Equal(bct.expectedCount, cmd.Count("tf")) + r.Equal(bct.expectedVal, cmd.Value(bf.Name)) + r.Equal(bct.expectedCount, cmd.Count(bf.Name)) + for _, alias := range bf.Aliases { + r.Equal(bct.expectedCount, cmd.Count(alias)) + } } } @@ -2249,23 +2261,23 @@ func TestTimestamp_set(t *testing.T) { ts := timestampValue{ timestamp: nil, hasBeenSet: false, - layout: "Jan 2, 2006 at 3:04pm (MST)", + layouts: []string{"Jan 2, 2006 at 3:04pm (MST)"}, } time1 := "Feb 3, 2013 at 7:54pm (PST)" - require.NoError(t, ts.Set(time1), "Failed to parse time %s with layout %s", time1, ts.layout) + require.NoError(t, ts.Set(time1), "Failed to parse time %s with layouts %v", time1, ts.layouts) require.True(t, ts.hasBeenSet, "hasBeenSet is not true after setting a time") ts.hasBeenSet = false - ts.layout = time.RFC3339 + ts.layouts = []string{time.RFC3339} time2 := "2006-01-02T15:04:05Z" - require.NoError(t, ts.Set(time2), "Failed to parse time %s with layout %s", time2, ts.layout) + require.NoError(t, ts.Set(time2), "Failed to parse time %s with layout %v", time2, ts.layouts) require.True(t, ts.hasBeenSet, "hasBeenSet is not true after setting a time") } -func TestTimestampFlagApply(t *testing.T) { +func TestTimestampFlagApply_SingleFormat(t *testing.T) { expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}} set := flag.NewFlagSet("test", 0) _ = fl.Apply(set) @@ -2274,9 +2286,226 @@ func TestTimestampFlagApply(t *testing.T) { assert.Equal(t, expectedResult, set.Lookup("time").Value.(flag.Getter).Get()) } +func TestTimestampFlagApply_MultipleFormats(t *testing.T) { + now := time.Now().UTC() + + testCases := []struct { + caseName string + layoutsPrecisions map[string]time.Duration + expRes time.Time + expErrValidation func(err error) (validation error) + }{ + { + caseName: "all_valid_layouts", + layoutsPrecisions: map[string]time.Duration{ + time.RFC3339: time.Second, + time.DateTime: time.Second, + time.RFC1123: time.Second, + }, + expRes: now.Truncate(time.Second), + }, + { + caseName: "one_invalid_layout", + layoutsPrecisions: map[string]time.Duration{ + time.RFC3339: time.Second, + time.DateTime: time.Second, + "foo": 0, + }, + expRes: now.Truncate(time.Second), + }, + { + caseName: "multiple_invalid_layouts", + layoutsPrecisions: map[string]time.Duration{ + time.RFC3339: time.Second, + "foo": 0, + time.DateTime: time.Second, + "bar": 0, + }, + expRes: now.Truncate(time.Second), + }, + { + caseName: "all_invalid_layouts", + layoutsPrecisions: map[string]time.Duration{ + "foo": 0, + "2024-08-07 74:01:82Z-100": 0, + "25:70": 0, + "": 0, + }, + expErrValidation: func(err error) error { + if err == nil { + return errors.New("got nil err") + } + + found := regexp.MustCompile(`(cannot parse ".+" as ".*")|(extra text: ".+")`).Match([]byte(err.Error())) + if !found { + return fmt.Errorf("given error does not satisfy pattern: %w", err) + } + + return nil + }, + }, + { + caseName: "empty_layout", + layoutsPrecisions: map[string]time.Duration{ + "": 0, + }, + expErrValidation: func(err error) error { + if err == nil { + return errors.New("got nil err") + } + + found := regexp.MustCompile(`extra text: ".+"`).Match([]byte(err.Error())) + if !found { + return fmt.Errorf("given error does not satisfy pattern: %w", err) + } + + return nil + }, + }, + { + caseName: "nil_layouts_slice", + expErrValidation: func(err error) error { + if err == nil { + return errors.New("got nil err") + } + + found := regexp.MustCompile(`got nil/empty layouts slice`).Match([]byte(err.Error())) + if !found { + return fmt.Errorf("given error does not satisfy pattern: %w", err) + } + + return nil + }, + }, + { + caseName: "empty_layouts_slice", + layoutsPrecisions: map[string]time.Duration{}, + expErrValidation: func(err error) error { + if err == nil { + return errors.New("got nil err") + } + + found := regexp.MustCompile(`got nil/empty layouts slice`).Match([]byte(err.Error())) + if !found { + return fmt.Errorf("given error does not satisfy pattern: %w", err) + } + + return nil + }, + }, + } + + // TODO: replace with maps.Keys() (go >= ), lo.Keys() if acceptable + getKeys := func(m map[string]time.Duration) []string { + if m == nil { + return nil + } + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys + } + + for idx := range testCases { + testCase := testCases[idx] + t.Run(testCase.caseName, func(t *testing.T) { + // t.Parallel() + fl := TimestampFlag{ + Name: "time", + Config: TimestampConfig{ + Layouts: getKeys(testCase.layoutsPrecisions), + }, + } + + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + if len(testCase.layoutsPrecisions) == 0 { + err := set.Parse([]string{"--time", now.Format(time.RFC3339)}) + if testCase.expErrValidation != nil { + assert.NoError(t, testCase.expErrValidation(err)) + } + } + + validLayouts := make([]string, 0, len(testCase.layoutsPrecisions)) + invalidLayouts := make([]string, 0, len(testCase.layoutsPrecisions)) + + // TODO: replace with lo.Filter if acceptable + for layout, prec := range testCase.layoutsPrecisions { + v, err := time.Parse(layout, now.Format(layout)) + if err != nil || prec == 0 || now.Truncate(prec).UnixNano() != v.Truncate(prec).UnixNano() { + invalidLayouts = append(invalidLayouts, layout) + continue + } + validLayouts = append(validLayouts, layout) + } + + for _, layout := range validLayouts { + err := set.Parse([]string{"--time", now.Format(layout)}) + assert.NoError(t, err) + if !testCase.expRes.IsZero() { + assert.Equal(t, testCase.expRes, set.Lookup("time").Value.(flag.Getter).Get()) + } + } + + for range invalidLayouts { + err := set.Parse([]string{"--time", now.Format(time.RFC3339)}) + if testCase.expErrValidation != nil { + assert.NoError(t, testCase.expErrValidation(err)) + } + } + }) + } +} + +func TestTimestampFlagApply_ShortenedLayouts(t *testing.T) { + now := time.Now().UTC() + + shortenedLayoutsPrecisions := map[string]time.Duration{ + time.Kitchen: time.Minute, + time.Stamp: time.Second, + time.StampMilli: time.Millisecond, + time.StampMicro: time.Microsecond, + time.StampNano: time.Nanosecond, + time.TimeOnly: time.Second, + "15:04": time.Minute, + } + + // TODO: replace with maps.Keys() (go >= ), lo.Keys() if acceptable + getKeys := func(m map[string]time.Duration) []string { + if m == nil { + return nil + } + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys + } + + fl := TimestampFlag{ + Name: "time", + Config: TimestampConfig{ + Layouts: getKeys(shortenedLayoutsPrecisions), + }, + } + + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + for layout, prec := range shortenedLayoutsPrecisions { + err := set.Parse([]string{"--time", now.Format(layout)}) + assert.NoError(t, err) + assert.Equal(t, now.Truncate(prec), set.Lookup("time").Value.(flag.Getter).Get()) + } +} + func TestTimestampFlagApplyValue(t *testing.T) { expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}, Value: expectedResult} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Value: expectedResult} set := flag.NewFlagSet("test", 0) _ = fl.Apply(set) @@ -2286,7 +2515,7 @@ func TestTimestampFlagApplyValue(t *testing.T) { } func TestTimestampFlagApply_Fail_Parse_Wrong_Layout(t *testing.T) { - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: "randomlayout"}} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{"randomlayout"}}} set := flag.NewFlagSet("test", 0) set.SetOutput(io.Discard) _ = fl.Apply(set) @@ -2296,7 +2525,7 @@ func TestTimestampFlagApply_Fail_Parse_Wrong_Layout(t *testing.T) { } func TestTimestampFlagApply_Fail_Parse_Wrong_Time(t *testing.T) { - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: "Jan 2, 2006 at 3:04pm (MST)"}} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{"Jan 2, 2006 at 3:04pm (MST)"}}} set := flag.NewFlagSet("test", 0) set.SetOutput(io.Discard) _ = fl.Apply(set) @@ -2308,7 +2537,7 @@ func TestTimestampFlagApply_Fail_Parse_Wrong_Time(t *testing.T) { func TestTimestampFlagApply_Timezoned(t *testing.T) { pdt := time.FixedZone("PDT", -7*60*60) expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.ANSIC, Timezone: pdt}} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.ANSIC}, Timezone: pdt}} set := flag.NewFlagSet("test", 0) _ = fl.Apply(set) @@ -2509,7 +2738,7 @@ func TestFlagDefaultValueWithEnv(t *testing.T) { }, { name: "timestamp", - flag: &TimestampFlag{Name: "flag", Value: ts, Config: TimestampConfig{Layout: time.RFC3339}, Sources: EnvVars("tflag")}, + flag: &TimestampFlag{Name: "flag", Value: ts, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Sources: EnvVars("tflag")}, toParse: []string{"--flag", "2006-11-02T15:04:05Z"}, expect: `--flag value (default: 2005-01-02 15:04:05 +0000 UTC)` + withEnvHint([]string{"tflag"}, ""), environ: map[string]string{ @@ -2593,7 +2822,7 @@ func TestFlagValue(t *testing.T) { func TestTimestampFlagApply_WithDestination(t *testing.T) { var destination time.Time expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") - fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layout: time.RFC3339}, Destination: &destination} + fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Destination: &destination} set := flag.NewFlagSet("test", 0) _ = fl.Apply(set) diff --git a/flag_timestamp.go b/flag_timestamp.go index d008080c1a..8b9dd16a09 100644 --- a/flag_timestamp.go +++ b/flag_timestamp.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "time" ) @@ -10,14 +11,19 @@ type TimestampFlag = FlagBase[time.Time, TimestampConfig, timestampValue] // TimestampConfig defines the config for timestamp flags type TimestampConfig struct { Timezone *time.Location - Layout string + // Available layouts for flag value. + // + // Note that value for formats with missing year/date will be interpreted as current year/date respectively. + // + // Read more about time layouts: https://pkg.go.dev/time#pkg-constants + Layouts []string } // timestampValue wrap to satisfy golang's flag interface. type timestampValue struct { timestamp *time.Time hasBeenSet bool - layout string + layouts []string location *time.Location } @@ -29,7 +35,7 @@ func (t timestampValue) Create(val time.Time, p *time.Time, c TimestampConfig) V *p = val return ×tampValue{ timestamp: p, - layout: c.Layout, + layouts: c.Layouts, location: c.Timezone, } } @@ -53,16 +59,65 @@ func (t *timestampValue) Set(value string) error { var timestamp time.Time var err error - if t.location != nil { - timestamp, err = time.ParseInLocation(t.layout, value, t.location) - } else { - timestamp, err = time.Parse(t.layout, value) + if t.location == nil { + t.location = time.UTC + } + + if len(t.layouts) == 0 { + return errors.New("got nil/empty layouts slice") + } + + for _, layout := range t.layouts { + var locErr error + + timestamp, locErr = time.ParseInLocation(layout, value, t.location) + if locErr != nil { + if err == nil { + err = locErr + continue + } + + err = newMultiError(err, locErr) + continue + } + + err = nil + break } if err != nil { return err } + defaultTS, _ := time.ParseInLocation(time.TimeOnly, time.TimeOnly, timestamp.Location()) + + n := time.Now() + + // If format is missing date (or year only), set it explicitly to current + if timestamp.Truncate(time.Hour*24).UnixNano() == defaultTS.Truncate(time.Hour*24).UnixNano() { + timestamp = time.Date( + n.Year(), + n.Month(), + n.Day(), + timestamp.Hour(), + timestamp.Minute(), + timestamp.Second(), + timestamp.Nanosecond(), + timestamp.Location(), + ) + } else if timestamp.Year() == 0 { + timestamp = time.Date( + n.Year(), + timestamp.Month(), + timestamp.Day(), + timestamp.Hour(), + timestamp.Minute(), + timestamp.Second(), + timestamp.Nanosecond(), + timestamp.Location(), + ) + } + if t.timestamp != nil { *t.timestamp = timestamp } diff --git a/flag_validation_test.go b/flag_validation_test.go index 8f1cef4af9..f4e69e6c71 100644 --- a/flag_validation_test.go +++ b/flag_validation_test.go @@ -20,6 +20,7 @@ func TestFlagDefaultValidation(t *testing.T) { } return fmt.Errorf("Value %d not in range [3,10] or [20,24]", i) }, + ValidateDefaults: true, }, }, } diff --git a/godoc-current.txt b/godoc-current.txt index 70b692a2be..eae97b42c7 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -45,7 +45,9 @@ DESCRIPTION: OPTIONS:{{template "visibleFlagCategoryTemplate" .}}{{else if .VisibleFlags}} -OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} +OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .VisiblePersistentFlags}} + +GLOBAL OPTIONS:{{template "visiblePersistentFlagTemplate" .}}{{end}} ` CommandHelpTemplate is the text template for the command help topic. cli.go uses text/template to render templates. You can render custom help text by @@ -281,8 +283,7 @@ func (parent *BoolWithInverseFlag) RunAction(ctx context.Context, cmd *Command) func (parent *BoolWithInverseFlag) String() string String implements the standard Stringer interface. - Example for BoolFlag{Name: "env"} --env (default: false) || --no-env - (default: false) + Example for BoolFlag{Name: "env"} --[no-]env (default: false) func (parent *BoolWithInverseFlag) Value() bool @@ -516,6 +517,10 @@ func (cmd *Command) VisibleFlagCategories() []VisibleFlagCategory func (cmd *Command) VisibleFlags() []Flag VisibleFlags returns a slice of the Flags with Hidden=false +func (cmd *Command) VisiblePersistentFlags() []Flag + VisiblePersistentFlags returns a slice of PersistentFlag with + Persistent=true and Hidden=false. + type CommandCategories interface { // AddCommand adds a command to a category, creating a new category if necessary. AddCommand(category string, command *Command) @@ -557,6 +562,10 @@ type DocGenerationFlag interface { // GetEnvVars returns the env vars for this flag GetEnvVars() []string + + // IsDefaultVisible returns whether the default value should be shown in + // help text + IsDefaultVisible() bool } DocGenerationFlag is an interface that allows documentation generation for the flag @@ -620,38 +629,42 @@ var GenerateShellCompletionFlag Flag = &BoolFlag{ GenerateShellCompletionFlag enables shell completion var HelpFlag Flag = &BoolFlag{ - Name: "help", - Aliases: []string{"h"}, - Usage: "show help", + Name: "help", + Aliases: []string{"h"}, + Usage: "show help", + HideDefault: true, } HelpFlag prints the help for all commands and subcommands. Set to nil to disable the flag. The subcommand will still be added unless HideHelp or HideHelpCommand is set to true. var VersionFlag Flag = &BoolFlag{ - Name: "version", - Aliases: []string{"v"}, - Usage: "print the version", + Name: "version", + Aliases: []string{"v"}, + Usage: "print the version", + HideDefault: true, } VersionFlag prints the version for the application type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { - Name string `json:"name"` // name of the flag - Category string `json:"category"` // category of the flag, if any - DefaultText string `json:"defaultText"` // default text of the flag for usage purposes - Usage string `json:"usage"` // usage string for help output - Sources ValueSourceChain `json:"-"` // sources to load flag value from - Required bool `json:"required"` // whether the flag is required or not - Hidden bool `json:"hidden"` // whether to hide the flag in help output - Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well - Value T `json:"defaultValue"` // default value for this flag if not set by from any source - Destination *T `json:"-"` // destination pointer for value when set - Aliases []string `json:"aliases"` // Aliases that are allowed for this flag - TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes - Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set - Config C `json:"config"` // Additional/Custom configuration associated with this flag type - OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line - Validator func(T) error `json:"-"` // custom function to validate this flag value + Name string `json:"name"` // name of the flag + Category string `json:"category"` // category of the flag, if any + DefaultText string `json:"defaultText"` // default text of the flag for usage purposes + HideDefault bool `json:"hideDefault"` // whether to hide the default value in output + Usage string `json:"usage"` // usage string for help output + Sources ValueSourceChain `json:"-"` // sources to load flag value from + Required bool `json:"required"` // whether the flag is required or not + Hidden bool `json:"hidden"` // whether to hide the flag in help output + Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well + Value T `json:"defaultValue"` // default value for this flag if not set by from any source + Destination *T `json:"-"` // destination pointer for value when set + Aliases []string `json:"aliases"` // Aliases that are allowed for this flag + TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes + Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set + Config C `json:"config"` // Additional/Custom configuration associated with this flag type + OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line + Validator func(T) error `json:"-"` // custom function to validate this flag value + ValidateDefaults bool `json:"validateDefaults"` // whether to validate defaults or not // Has unexported fields. } @@ -684,6 +697,9 @@ func (f *FlagBase[T, C, V]) GetValue() string GetValue returns the flags value as string representation and an empty string if the flag takes no value at all. +func (f *FlagBase[T, C, V]) IsDefaultVisible() bool + IsDefaultVisible returns true if the flag is not hidden, otherwise false + func (f *FlagBase[T, C, VC]) IsMultiValueFlag() bool IsMultiValueFlag returns true if the value type T can take multiple values from cmd line. This is true for slice and map type flags @@ -925,7 +941,12 @@ type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue] type TimestampConfig struct { Timezone *time.Location - Layout string + // Available layouts for flag value. + // + // Note that value for formats with missing year/date will be interpreted as current year/date respectively. + // + // Read more about time layouts: https://pkg.go.dev/time#pkg-constants + Layouts []string } TimestampConfig defines the config for timestamp flags @@ -969,6 +990,8 @@ type ValueSource interface { func EnvVar(key string) ValueSource +func File(path string) ValueSource + type ValueSourceChain struct { Chain []ValueSource } diff --git a/help.go b/help.go index ea9bc292c6..81fc1101fc 100644 --- a/help.go +++ b/help.go @@ -159,7 +159,7 @@ func printCommandSuggestions(commands []*Command, writer io.Writer) { if command.Hidden { continue } - if strings.HasSuffix(os.Getenv("SHELL"), "zsh") { + if strings.HasSuffix(os.Getenv("0"), "zsh") { _, _ = fmt.Fprintf(writer, "%s:%s\n", command.Name, command.Usage) } else { _, _ = fmt.Fprintf(writer, "%s\n", command.Name) @@ -415,6 +415,10 @@ func printHelpCustom(out io.Writer, templ string, data interface{}, customFuncs handleTemplateError(err) } + if _, err := t.New("visiblePersistentFlagTemplate").Parse(visiblePersistentFlagTemplate); err != nil { + handleTemplateError(err) + } + if _, err := t.New("visibleGlobalFlagCategoryTemplate").Parse(strings.Replace(visibleFlagCategoryTemplate, "OPTIONS", "GLOBAL OPTIONS", -1)); err != nil { handleTemplateError(err) } @@ -459,6 +463,15 @@ func checkShellCompleteFlag(c *Command, arguments []string) (bool, []string) { return false, arguments } + for _, arg := range arguments { + // If arguments include "--", shell completion is disabled + // because after "--" only positional arguments are accepted. + // https://unix.stackexchange.com/a/11382 + if arg == "--" { + return false, arguments + } + } + return true, arguments[:pos] } diff --git a/help_test.go b/help_test.go index b45e0d2191..dd0228b0e2 100644 --- a/help_test.go +++ b/help_test.go @@ -65,6 +65,36 @@ func Test_ShowAppHelp_MultiLineDescription(t *testing.T) { } } +func Test_Help_RequiredFlagsNoDefault(t *testing.T) { + output := new(bytes.Buffer) + + cmd := &Command{ + Flags: []Flag{ + &IntFlag{Name: "foo", Aliases: []string{"f"}, Required: true}, + }, + Writer: output, + } + + _ = cmd.Run(buildTestContext(t), []string{"test", "-h"}) + + expected := `NAME: + test - A new cli application + +USAGE: + test [global options] [command [command options]] [arguments...] + +COMMANDS: + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --foo value, -f value + --help, -h show help +` + + assert.Contains(t, output.String(), expected, + "expected output to include usage text") +} + func Test_Help_Custom_Flags(t *testing.T) { oldFlag := HelpFlag defer func() { @@ -657,6 +687,51 @@ UsageText`, "expected output to include usage text") } +func TestShowSubcommandHelp_GlobalOptions(t *testing.T) { + cmd := &Command{ + Flags: []Flag{ + &StringFlag{ + Name: "foo", + Persistent: true, + }, + }, + Commands: []*Command{ + { + Name: "frobbly", + Flags: []Flag{ + &StringFlag{ + Name: "bar", + }, + }, + Action: func(context.Context, *Command) error { + return nil + }, + }, + }, + } + + output := &bytes.Buffer{} + cmd.Writer = output + + _ = cmd.Run(buildTestContext(t), []string{"foo", "frobbly", "--help"}) + + expected := `NAME: + foo frobbly + +USAGE: + foo frobbly [command [command options]] + +OPTIONS: + --bar value + --help, -h show help + +GLOBAL OPTIONS: + --foo value +` + + assert.Contains(t, output.String(), expected, "expected output to include global options") +} + func TestShowSubcommandHelp_SubcommandUsageText(t *testing.T) { cmd := &Command{ Commands: []*Command{ @@ -1398,8 +1473,7 @@ COMMANDS: for one command OPTIONS: - --help, -h show help - (default: false) + --help, -h show help `, output.String(), ) @@ -1464,8 +1538,7 @@ USAGE: even more OPTIONS: - --help, -h show help - (default: false) + --help, -h show help ` assert.Equal(t, expected, output.String(), "Unexpected wrapping") @@ -1545,8 +1618,7 @@ COMMANDS: OPTIONS: --test-f value my test usage - --help, -h show help - (default: false) + --help, -h show help `, output.String(), ) @@ -1624,7 +1696,7 @@ COMMANDS: for one command GLOBAL OPTIONS: - --help, -h show help (default: false) + --help, -h show help --m2 value --strd value @@ -1635,3 +1707,68 @@ GLOBAL OPTIONS: `, output.String()) } + +func Test_checkShellCompleteFlag(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cmd *Command + arguments []string + wantShellCompletion bool + wantArgs []string + }{ + { + name: "disable-shell-completion", + arguments: []string{"--generate-shell-completion"}, + cmd: &Command{}, + wantShellCompletion: false, + wantArgs: []string{"--generate-shell-completion"}, + }, + { + name: "child-disable-shell-completion", + arguments: []string{"--generate-shell-completion"}, + cmd: &Command{ + parent: &Command{}, + }, + wantShellCompletion: false, + wantArgs: []string{"--generate-shell-completion"}, + }, + { + name: "last argument isn't --generate-shell-completion", + arguments: []string{"foo"}, + cmd: &Command{ + EnableShellCompletion: true, + }, + wantShellCompletion: false, + wantArgs: []string{"foo"}, + }, + { + name: "arguments include double dash", + arguments: []string{"--", "foo", "--generate-shell-completion"}, + cmd: &Command{ + EnableShellCompletion: true, + }, + wantShellCompletion: false, + wantArgs: []string{"--", "foo", "--generate-shell-completion"}, + }, + { + name: "shell completion", + arguments: []string{"foo", "--generate-shell-completion"}, + cmd: &Command{ + EnableShellCompletion: true, + }, + wantShellCompletion: true, + wantArgs: []string{"foo"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + shellCompletion, args := checkShellCompleteFlag(tt.cmd, tt.arguments) + assert.Equal(t, tt.wantShellCompletion, shellCompletion) + assert.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/template.go b/template.go index 8f8d96244d..d809dd780a 100644 --- a/template.go +++ b/template.go @@ -28,6 +28,9 @@ var visibleFlagCategoryTemplate = `{{range .VisibleFlagCategories}} var visibleFlagTemplate = `{{range $i, $e := .VisibleFlags}} {{wrap $e.String 6}}{{end}}` +var visiblePersistentFlagTemplate = `{{range $i, $e := .VisiblePersistentFlags}} + {{wrap $e.String 6}}{{end}}` + var versionTemplate = `{{if .Version}}{{if not .HideVersion}} VERSION: @@ -80,7 +83,9 @@ DESCRIPTION: OPTIONS:{{template "visibleFlagCategoryTemplate" .}}{{else if .VisibleFlags}} -OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} +OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .VisiblePersistentFlags}} + +GLOBAL OPTIONS:{{template "visiblePersistentFlagTemplate" .}}{{end}} ` // SubcommandHelpTemplate is the text template for the subcommand help topic. diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index 70b692a2be..71e46b266e 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -45,7 +45,9 @@ DESCRIPTION: OPTIONS:{{template "visibleFlagCategoryTemplate" .}}{{else if .VisibleFlags}} -OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} +OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .VisiblePersistentFlags}} + +GLOBAL OPTIONS:{{template "visiblePersistentFlagTemplate" .}}{{end}} ` CommandHelpTemplate is the text template for the command help topic. cli.go uses text/template to render templates. You can render custom help text by @@ -281,8 +283,7 @@ func (parent *BoolWithInverseFlag) RunAction(ctx context.Context, cmd *Command) func (parent *BoolWithInverseFlag) String() string String implements the standard Stringer interface. - Example for BoolFlag{Name: "env"} --env (default: false) || --no-env - (default: false) + Example for BoolFlag{Name: "env"} --[no-]env (default: false) func (parent *BoolWithInverseFlag) Value() bool @@ -516,6 +517,10 @@ func (cmd *Command) VisibleFlagCategories() []VisibleFlagCategory func (cmd *Command) VisibleFlags() []Flag VisibleFlags returns a slice of the Flags with Hidden=false +func (cmd *Command) VisiblePersistentFlags() []Flag + VisiblePersistentFlags returns a slice of PersistentFlag with + Persistent=true and Hidden=false. + type CommandCategories interface { // AddCommand adds a command to a category, creating a new category if necessary. AddCommand(category string, command *Command) @@ -557,6 +562,10 @@ type DocGenerationFlag interface { // GetEnvVars returns the env vars for this flag GetEnvVars() []string + + // IsDefaultVisible returns whether the default value should be shown in + // help text + IsDefaultVisible() bool } DocGenerationFlag is an interface that allows documentation generation for the flag @@ -620,38 +629,42 @@ var GenerateShellCompletionFlag Flag = &BoolFlag{ GenerateShellCompletionFlag enables shell completion var HelpFlag Flag = &BoolFlag{ - Name: "help", - Aliases: []string{"h"}, - Usage: "show help", + Name: "help", + Aliases: []string{"h"}, + Usage: "show help", + HideDefault: true, } HelpFlag prints the help for all commands and subcommands. Set to nil to disable the flag. The subcommand will still be added unless HideHelp or HideHelpCommand is set to true. var VersionFlag Flag = &BoolFlag{ - Name: "version", - Aliases: []string{"v"}, - Usage: "print the version", + Name: "version", + Aliases: []string{"v"}, + Usage: "print the version", + HideDefault: true, } VersionFlag prints the version for the application type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { - Name string `json:"name"` // name of the flag - Category string `json:"category"` // category of the flag, if any - DefaultText string `json:"defaultText"` // default text of the flag for usage purposes - Usage string `json:"usage"` // usage string for help output - Sources ValueSourceChain `json:"-"` // sources to load flag value from - Required bool `json:"required"` // whether the flag is required or not - Hidden bool `json:"hidden"` // whether to hide the flag in help output - Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well - Value T `json:"defaultValue"` // default value for this flag if not set by from any source - Destination *T `json:"-"` // destination pointer for value when set - Aliases []string `json:"aliases"` // Aliases that are allowed for this flag - TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes - Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set - Config C `json:"config"` // Additional/Custom configuration associated with this flag type - OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line - Validator func(T) error `json:"-"` // custom function to validate this flag value + Name string `json:"name"` // name of the flag + Category string `json:"category"` // category of the flag, if any + DefaultText string `json:"defaultText"` // default text of the flag for usage purposes + HideDefault bool `json:"hideDefault"` // whether to hide the default value in output + Usage string `json:"usage"` // usage string for help output + Sources ValueSourceChain `json:"-"` // sources to load flag value from + Required bool `json:"required"` // whether the flag is required or not + Hidden bool `json:"hidden"` // whether to hide the flag in help output + Persistent bool `json:"persistent"` // whether the flag needs to be applied to subcommands as well + Value T `json:"defaultValue"` // default value for this flag if not set by from any source + Destination *T `json:"-"` // destination pointer for value when set + Aliases []string `json:"aliases"` // Aliases that are allowed for this flag + TakesFile bool `json:"takesFileArg"` // whether this flag takes a file argument, mainly for shell completion purposes + Action func(context.Context, *Command, T) error `json:"-"` // Action callback to be called when flag is set + Config C `json:"config"` // Additional/Custom configuration associated with this flag type + OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line + Validator func(T) error `json:"-"` // custom function to validate this flag value + ValidateDefaults bool `json:"validateDefaults"` // whether to validate defaults or not // Has unexported fields. } @@ -684,6 +697,9 @@ func (f *FlagBase[T, C, V]) GetValue() string GetValue returns the flags value as string representation and an empty string if the flag takes no value at all. +func (f *FlagBase[T, C, V]) IsDefaultVisible() bool + IsDefaultVisible returns true if the flag is not hidden, otherwise false + func (f *FlagBase[T, C, VC]) IsMultiValueFlag() bool IsMultiValueFlag returns true if the value type T can take multiple values from cmd line. This is true for slice and map type flags @@ -925,7 +941,12 @@ type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue] type TimestampConfig struct { Timezone *time.Location - Layout string + // Available layouts for flag value. + // + // Note that value for formats with missing year/date will be interpreted as current year/date respectively. + // + // Read more about time layouts: https://pkg.go.dev/time#pkg-constants + Layouts []string } TimestampConfig defines the config for timestamp flags @@ -969,6 +990,8 @@ type ValueSource interface { func EnvVar(key string) ValueSource +func File(path string) ValueSource + type ValueSourceChain struct { Chain []ValueSource } diff --git a/value_source.go b/value_source.go index 266f5e54bf..7d3f7ee45c 100644 --- a/value_source.go +++ b/value_source.go @@ -128,6 +128,10 @@ func (f *fileValueSource) GoString() string { return fmt.Sprintf("&fileValueSource{Path:%[1]q}", f.Path) } +func File(path string) ValueSource { + return &fileValueSource{Path: path} +} + // Files is a helper function to encapsulate a number of // fileValueSource together as a ValueSourceChain func Files(paths ...string) ValueSourceChain { diff --git a/value_source_test.go b/value_source_test.go index 57b9cfdcb4..7f1c36ece9 100644 --- a/value_source_test.go +++ b/value_source_test.go @@ -71,7 +71,7 @@ func TestFileValueSource(t *testing.T) { r.Implements((*ValueSource)(nil), &fileValueSource{}) t.Run("not found", func(t *testing.T) { - src := &fileValueSource{Path: fmt.Sprintf("junk_file_name-%[1]v", rand.Int())} + src := File(fmt.Sprintf("junk_file_name-%[1]v", rand.Int())) _, ok := src.Lookup() r.False(ok) }) @@ -82,7 +82,7 @@ func TestFileValueSource(t *testing.T) { r.Nil(os.WriteFile(fileName, []byte("pita"), 0o644)) t.Run("found", func(t *testing.T) { - src := &fileValueSource{Path: fileName} + src := File(fileName) str, ok := src.Lookup() r.True(ok) r.Equal("pita", str) @@ -90,7 +90,7 @@ func TestFileValueSource(t *testing.T) { }) t.Run("implements fmt.Stringer", func(t *testing.T) { - src := &fileValueSource{Path: "/dev/null"} + src := File("/dev/null") r := require.New(t) r.Implements((*ValueSource)(nil), src) @@ -98,7 +98,7 @@ func TestFileValueSource(t *testing.T) { }) t.Run("implements fmt.GoStringer", func(t *testing.T) { - src := &fileValueSource{Path: "/dev/null"} + src := File("/dev/null") r := require.New(t) r.Implements((*ValueSource)(nil), src)