diff --git a/command_setup.go b/command_setup.go index 13ef706315..3ea2fa851e 100644 --- a/command_setup.go +++ b/command_setup.go @@ -153,6 +153,7 @@ func (cmd *Command) setupDefaults(osArgs []string) { tracef("setting category on mutually exclusive flags (cmd=%[1]q)", cmd.Name) for _, grp := range cmd.MutuallyExclusiveFlags { grp.propagateCategory() + grp.propagateStringer() } tracef("setting flag categories (cmd=%[1]q)", cmd.Name) @@ -196,6 +197,7 @@ func (cmd *Command) setupSubcommand() { tracef("setting category on mutually exclusive flags (cmd=%[1]q)", cmd.Name) for _, grp := range cmd.MutuallyExclusiveFlags { grp.propagateCategory() + grp.propagateStringer() } tracef("setting flag categories (cmd=%[1]q)", cmd.Name) diff --git a/docs/v3/examples/flags/advanced.md b/docs/v3/examples/flags/advanced.md index e5a5fff7e5..d334177fae 100644 --- a/docs/v3/examples/flags/advanced.md +++ b/docs/v3/examples/flags/advanced.md @@ -427,6 +427,13 @@ func getUser(ctx context.Context, cmd *cli.Command) (User, error) { } ``` +You can also customize how the flags within a group are rendered in `--help` +output by setting `MutuallyExclusiveFlags.Stringer` to a `cli.FlagStringFunc`. +It's applied to every flag in the group that supports overriding its string +representation (i.e. implements `cli.StringerSetter`, which all `FlagBase`-based +flags do). The stringer's output must be unique per flag within the group, +since help rendering keys flags by their string representation. + If the command is run without either the `login` or `id` flag, the user will see the following message diff --git a/flag.go b/flag.go index 3cb9ab9608..9dbe0199ef 100644 --- a/flag.go +++ b/flag.go @@ -190,6 +190,18 @@ type CategorizableFlag interface { SetCategory(string) } +// StringerSetter is an optional interface that allows an individual +// flag to be given a per-flag override of [FlagStringer]. FlagBase and +// BoolWithInverseFlag implement this. It's used by +// [MutuallyExclusiveFlags.Stringer] to customize how flags within a +// mutually exclusive group are displayed in help output. +type StringerSetter interface { + // SetStringer overrides the [FlagStringFunc] used by this flag's + // String method. Passing nil restores the default behavior of using + // the package-level [FlagStringer]. + SetStringer(FlagStringFunc) +} + // LocalFlag is an interface to enable detection of flags which are local // to current command type LocalFlag interface { diff --git a/flag_bool_with_inverse.go b/flag_bool_with_inverse.go index 33c0927257..d57225dc17 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -37,6 +37,21 @@ type BoolWithInverseFlag struct { value Value // value representing this flag's value pset bool nset bool + stringer FlagStringFunc // optional per-flag override of FlagStringer +} + +// SetStringer overrides the [FlagStringFunc] used by this flag's String +// method. Passing nil restores the default behavior of using the +// package-level [FlagStringer]. This is used e.g. by +// [MutuallyExclusiveFlags.Stringer]. +// +// Note: unlike [FlagBase], BoolWithInverseFlag.String only honors the +// stringer partially. The names segment (the part before the first tab, +// e.g. "--[no-]env, -e") is always recomputed from Name/Aliases/InversePrefix +// and cannot be overridden; only the tab-delimited details after it come +// from the stringer's output. +func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) { + bif.stringer = s } func (bif *BoolWithInverseFlag) IsSet() bool { @@ -171,7 +186,11 @@ func (bif *BoolWithInverseFlag) IsVisible() bool { // Example for BoolFlag{Name: "env", Aliases: []string{"e"}} // --[no-]env, -e (default: false) func (bif *BoolWithInverseFlag) String() string { - out := FlagStringer(bif) + fs := FlagStringer + if bif.stringer != nil { + fs = bif.stringer + } + out := fs(bif) i := strings.Index(out, "\t") diff --git a/flag_impl.go b/flag_impl.go index be702d33f4..e829d6814e 100644 --- a/flag_impl.go +++ b/flag_impl.go @@ -75,11 +75,12 @@ type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { 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 - hasBeenSet bool // whether the flag has been set from env or file - applied bool // whether the flag has been applied to a flag set already - creator VC // value creator for this flag type - value Value // value representing this flag's value + count int // number of times the flag has been set + hasBeenSet bool // whether the flag has been set from env or file + applied bool // whether the flag has been applied to a flag set already + creator VC // value creator for this flag type + value Value // value representing this flag's value + stringer FlagStringFunc // optional per-flag override of FlagStringer } // GetValue returns the flags value as string representation and an empty @@ -232,9 +233,20 @@ func (f *FlagBase[T, C, V]) IsDefaultVisible() bool { // String returns a readable representation of this value (for usage defaults) func (f *FlagBase[T, C, V]) String() string { + if f.stringer != nil { + return f.stringer(f) + } return FlagStringer(f) } +// SetStringer overrides the [FlagStringFunc] used by this flag's String +// method. Passing nil restores the default behavior of using the +// package-level [FlagStringer]. This is used e.g. by +// [MutuallyExclusiveFlags.Stringer]. +func (f *FlagBase[T, C, V]) SetStringer(s FlagStringFunc) { + f.stringer = s +} + // IsSet returns whether or not the flag has been set through env or file func (f *FlagBase[T, C, V]) IsSet() bool { return f.hasBeenSet diff --git a/flag_mutex.go b/flag_mutex.go index 5a1e87cfb5..bd016988e9 100644 --- a/flag_mutex.go +++ b/flag_mutex.go @@ -14,6 +14,17 @@ type MutuallyExclusiveFlags struct { // Category to apply to all flags within group Category string + + // Stringer overrides how each flag within this group is displayed in + // help output. If nil, flags use [FlagStringer] as usual. + // + // The returned string must be unique per flag within the group (the + // default [stringifyFlag] guarantees this by embedding the flag's + // name). Help rendering keys flags by their String() output + // (flagCategories.AddFlag), so a Stringer that returns identical text + // for two or more flags in the same category will cause the later + // flag to silently overwrite the earlier one in help output. + Stringer FlagStringFunc `json:"-"` } func (grp MutuallyExclusiveFlags) check(_ *Command) error { @@ -69,3 +80,22 @@ func (grp MutuallyExclusiveFlags) propagateCategory() { } } } + +// propagateStringer applies [MutuallyExclusiveFlags.Stringer], if set, to +// every flag within the group that supports a [StringerSetter]. Like +// [MutuallyExclusiveFlags.propagateCategory], this only runs during command +// setup, so mutating Stringer between runs of a reused [Command] will not +// re-propagate the change. +func (grp MutuallyExclusiveFlags) propagateStringer() { + if grp.Stringer == nil { + return + } + + for _, grpf := range grp.Flags { + for _, f := range grpf { + if sf, ok := f.(StringerSetter); ok { + sf.SetStringer(grp.Stringer) + } + } + } +} diff --git a/flag_mutex_test.go b/flag_mutex_test.go index 114ab57c44..617ba2e29b 100644 --- a/flag_mutex_test.go +++ b/flag_mutex_test.go @@ -130,3 +130,102 @@ func TestFlagMutuallyExclusiveFlags(t *testing.T) { }) } } + +func TestMutuallyExclusiveFlags_PropagateStringer(t *testing.T) { + customStringer := func(f Flag) string { + return "custom:" + f.Names()[0] + } + + grp := MutuallyExclusiveFlags{ + Stringer: customStringer, + Flags: [][]Flag{ + { + &StringFlag{Name: "foo"}, + &BoolWithInverseFlag{Name: "bar"}, + }, + { + &Int64Flag{Name: "baz"}, + }, + }, + } + + grp.propagateStringer() + + assert.Equal(t, "custom:foo", grp.Flags[0][0].String()) + assert.Contains(t, grp.Flags[0][1].String(), "custom:bar") + assert.Equal(t, "custom:baz", grp.Flags[1][0].String()) +} + +func TestMutuallyExclusiveFlags_PropagateStringerNil(t *testing.T) { + grp := MutuallyExclusiveFlags{ + Flags: [][]Flag{ + { + &StringFlag{Name: "foo"}, + }, + }, + } + + // should not panic and should leave flags using the default FlagStringer + grp.propagateStringer() + + assert.NotEqual(t, "", grp.Flags[0][0].String()) +} + +func TestMutuallyExclusiveFlags_PropagateStringerNilResetsToDefault(t *testing.T) { + f := &StringFlag{Name: "foo"} + defaultOut := f.String() + + customStringer := func(f Flag) string { + return "custom:" + f.Names()[0] + } + + grp := MutuallyExclusiveFlags{ + Stringer: customStringer, + Flags: [][]Flag{{f}}, + } + grp.propagateStringer() + assert.Equal(t, "custom:foo", f.String()) + + // Resetting the stringer to nil via SetStringer must restore the + // default FlagStringer-based behavior. + grp.Stringer = nil + f.SetStringer(nil) + assert.Equal(t, defaultOut, f.String()) +} + +// nonStringerSettingFlag is a minimal Flag implementation that deliberately +// does not implement StringerSetter, so that propagateStringer must skip it +// via its type assertion rather than panicking or otherwise misbehaving. +type nonStringerSettingFlag struct { + name string +} + +func (f *nonStringerSettingFlag) String() string { return "plain:" + f.name } +func (f *nonStringerSettingFlag) Get() any { return nil } +func (f *nonStringerSettingFlag) PreParse() error { return nil } +func (f *nonStringerSettingFlag) PostParse() error { return nil } +func (f *nonStringerSettingFlag) Set(string, string) error { return nil } +func (f *nonStringerSettingFlag) Names() []string { return []string{f.name} } +func (f *nonStringerSettingFlag) IsSet() bool { return false } + +func TestMutuallyExclusiveFlags_PropagateStringerSkipsNonImplementor(t *testing.T) { + customStringer := func(f Flag) string { + return "custom:" + f.Names()[0] + } + + plain := &nonStringerSettingFlag{name: "plain"} + + grp := MutuallyExclusiveFlags{ + Stringer: customStringer, + Flags: [][]Flag{ + {plain}, + }, + } + + // should not panic + grp.propagateStringer() + + // plain does not implement StringerSetter, so it must keep its own + // String() implementation rather than picking up the custom stringer. + assert.Equal(t, "plain:plain", grp.Flags[0][0].String()) +} diff --git a/godoc-current.txt b/godoc-current.txt index 45e60e2566..dc41e1bd8b 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -423,6 +423,17 @@ func (bif *BoolWithInverseFlag) Set(name, val string) error func (bif *BoolWithInverseFlag) SetCategory(c string) +func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) + SetStringer overrides the FlagStringFunc used by this flag's String method. + Passing nil restores the default behavior of using the package-level + FlagStringer. This is used e.g. by MutuallyExclusiveFlags.Stringer. + + Note: unlike FlagBase, BoolWithInverseFlag.String only honors the + stringer partially. The names segment (the part before the first tab, e.g. + "--[no-]env, -e") is always recomputed from Name/Aliases/InversePrefix and + cannot be overridden; only the tab-delimited details after it come from the + stringer's output. + func (bif *BoolWithInverseFlag) String() string String implements the standard Stringer interface. @@ -1081,6 +1092,11 @@ func (f *FlagBase[T, C, V]) Set(_ string, val string) error func (f *FlagBase[T, C, V]) SetCategory(c string) +func (f *FlagBase[T, C, V]) SetStringer(s FlagStringFunc) + SetStringer overrides the FlagStringFunc used by this flag's String method. + Passing nil restores the default behavior of using the package-level + FlagStringer. This is used e.g. by MutuallyExclusiveFlags.Stringer. + func (f *FlagBase[T, C, V]) String() string String returns a readable representation of this value (for usage defaults) @@ -1318,6 +1334,17 @@ type MutuallyExclusiveFlags struct { // Category to apply to all flags within group Category string + + // Stringer overrides how each flag within this group is displayed in + // help output. If nil, flags use [FlagStringer] as usual. + // + // The returned string must be unique per flag within the group (the + // default [stringifyFlag] guarantees this by embedding the flag's + // name). Help rendering keys flags by their String() output + // (flagCategories.AddFlag), so a Stringer that returns identical text + // for two or more flags in the same category will cause the later + // flag to silently overwrite the earlier one in help output. + Stringer FlagStringFunc `json:"-"` } MutuallyExclusiveFlags defines a mutually exclusive flag group Multiple option paths can be provided out of which only one can be defined on cmdline @@ -1415,6 +1442,17 @@ type StringSlice = SliceBase[string, StringConfig, stringValue] type StringSliceFlag = FlagBase[[]string, StringConfig, StringSlice] +type StringerSetter interface { + // SetStringer overrides the [FlagStringFunc] used by this flag's + // String method. Passing nil restores the default behavior of using + // the package-level [FlagStringer]. + SetStringer(FlagStringFunc) +} + StringerSetter is an optional interface that allows an individual flag to be + given a per-flag override of FlagStringer. FlagBase and BoolWithInverseFlag + implement this. It's used by MutuallyExclusiveFlags.Stringer to customize + how flags within a mutually exclusive group are displayed in help output. + type SuggestCommandFunc func(commands []*Command, provided string) string type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string diff --git a/help_test.go b/help_test.go index a56c98f34d..56f727efb0 100644 --- a/help_test.go +++ b/help_test.go @@ -1671,6 +1671,42 @@ func TestMutuallyExclusiveFlags(t *testing.T) { assert.Contains(t, writer.String(), "--s1", "written help does not include mutex flag") } +func TestMutuallyExclusiveFlags_StringerInHelpOutput(t *testing.T) { + writer := &bytes.Buffer{} + cmd := &Command{ + Name: "cmd", + Writer: writer, + MutuallyExclusiveFlags: []MutuallyExclusiveFlags{ + { + Stringer: func(f Flag) string { + return "--" + f.Names()[0] + "\tcustom stringer output" + }, + Flags: [][]Flag{ + { + &StringFlag{Name: "s1"}, + }, + { + &StringFlag{Name: "s2"}, + }, + }, + }, + }, + } + + r, w, _ := os.Pipe() + cmd.Writer = w + + assert.NoError(t, cmd.Run(buildTestContext(t), []string{"cmd", "--help"})) + + w.Close() + buf := make([]byte, 4096) + n, _ := r.Read(buf) + out := string(buf[:n]) + + assert.Contains(t, out, "custom stringer output", "help output does not reflect the group's custom Stringer") + assert.NotContains(t, out, "(default:", "help output should not fall back to the default FlagStringer format") +} + func TestWrap(t *testing.T) { emptywrap := wrap("", 4, 16) assert.Empty(t, emptywrap, "Wrapping empty line should return empty line") diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index 45e60e2566..dc41e1bd8b 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -423,6 +423,17 @@ func (bif *BoolWithInverseFlag) Set(name, val string) error func (bif *BoolWithInverseFlag) SetCategory(c string) +func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) + SetStringer overrides the FlagStringFunc used by this flag's String method. + Passing nil restores the default behavior of using the package-level + FlagStringer. This is used e.g. by MutuallyExclusiveFlags.Stringer. + + Note: unlike FlagBase, BoolWithInverseFlag.String only honors the + stringer partially. The names segment (the part before the first tab, e.g. + "--[no-]env, -e") is always recomputed from Name/Aliases/InversePrefix and + cannot be overridden; only the tab-delimited details after it come from the + stringer's output. + func (bif *BoolWithInverseFlag) String() string String implements the standard Stringer interface. @@ -1081,6 +1092,11 @@ func (f *FlagBase[T, C, V]) Set(_ string, val string) error func (f *FlagBase[T, C, V]) SetCategory(c string) +func (f *FlagBase[T, C, V]) SetStringer(s FlagStringFunc) + SetStringer overrides the FlagStringFunc used by this flag's String method. + Passing nil restores the default behavior of using the package-level + FlagStringer. This is used e.g. by MutuallyExclusiveFlags.Stringer. + func (f *FlagBase[T, C, V]) String() string String returns a readable representation of this value (for usage defaults) @@ -1318,6 +1334,17 @@ type MutuallyExclusiveFlags struct { // Category to apply to all flags within group Category string + + // Stringer overrides how each flag within this group is displayed in + // help output. If nil, flags use [FlagStringer] as usual. + // + // The returned string must be unique per flag within the group (the + // default [stringifyFlag] guarantees this by embedding the flag's + // name). Help rendering keys flags by their String() output + // (flagCategories.AddFlag), so a Stringer that returns identical text + // for two or more flags in the same category will cause the later + // flag to silently overwrite the earlier one in help output. + Stringer FlagStringFunc `json:"-"` } MutuallyExclusiveFlags defines a mutually exclusive flag group Multiple option paths can be provided out of which only one can be defined on cmdline @@ -1415,6 +1442,17 @@ type StringSlice = SliceBase[string, StringConfig, stringValue] type StringSliceFlag = FlagBase[[]string, StringConfig, StringSlice] +type StringerSetter interface { + // SetStringer overrides the [FlagStringFunc] used by this flag's + // String method. Passing nil restores the default behavior of using + // the package-level [FlagStringer]. + SetStringer(FlagStringFunc) +} + StringerSetter is an optional interface that allows an individual flag to be + given a per-flag override of FlagStringer. FlagBase and BoolWithInverseFlag + implement this. It's used by MutuallyExclusiveFlags.Stringer to customize + how flags within a mutually exclusive group are displayed in help output. + type SuggestCommandFunc func(commands []*Command, provided string) string type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string