From 7eeafc5381e984e39d8cfbaa866091c35bacf85e Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Tue, 18 Aug 2026 21:52:25 +0530 Subject: [PATCH 1/5] Support custom FlagStringer for MutuallyExclusiveFlags Adds a Stringer field to MutuallyExclusiveFlags that lets callers override how flags within the group are rendered in help output. Flags opt in via the new FlagStringerOverrider interface. Fixes #2220 --- command_setup.go | 2 ++ flag.go | 12 ++++++++++++ flag_bool_with_inverse.go | 15 ++++++++++++++- flag_impl.go | 22 ++++++++++++++++----- flag_mutex.go | 20 ++++++++++++++++++++ flag_mutex_test.go | 40 +++++++++++++++++++++++++++++++++++++++ godoc-current.txt | 26 +++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 6 deletions(-) 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/flag.go b/flag.go index 3cb9ab9608..564c5159f3 100644 --- a/flag.go +++ b/flag.go @@ -190,6 +190,18 @@ type CategorizableFlag interface { SetCategory(string) } +// FlagStringerOverrider 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 FlagStringerOverrider 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..583cef8c92 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -37,6 +37,15 @@ 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]. +func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) { + bif.stringer = s } func (bif *BoolWithInverseFlag) IsSet() bool { @@ -171,7 +180,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..33361d850a 100644 --- a/flag_mutex.go +++ b/flag_mutex.go @@ -14,6 +14,10 @@ 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. + Stringer FlagStringFunc } func (grp MutuallyExclusiveFlags) check(_ *Command) error { @@ -69,3 +73,19 @@ func (grp MutuallyExclusiveFlags) propagateCategory() { } } } + +// propagateStringer applies [MutuallyExclusiveFlags.Stringer], if set, to +// every flag within the group that supports a [FlagStringerOverrider]. +func (grp MutuallyExclusiveFlags) propagateStringer() { + if grp.Stringer == nil { + return + } + + for _, grpf := range grp.Flags { + for _, f := range grpf { + if sf, ok := f.(FlagStringerOverrider); ok { + sf.SetStringer(grp.Stringer) + } + } + } +} diff --git a/flag_mutex_test.go b/flag_mutex_test.go index 114ab57c44..c82c6b3653 100644 --- a/flag_mutex_test.go +++ b/flag_mutex_test.go @@ -130,3 +130,43 @@ 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()) +} diff --git a/godoc-current.txt b/godoc-current.txt index a5382b2d87..5832486382 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -423,6 +423,11 @@ 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. + func (bif *BoolWithInverseFlag) String() string String implements the standard Stringer interface. @@ -1078,6 +1083,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) @@ -1127,6 +1137,18 @@ var FlagStringer FlagStringFunc = stringifyFlag FlagStringer converts a flag definition to a string. This is used by help to display a flag. +type FlagStringerOverrider 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) +} + FlagStringerOverrider 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 FlagsByName []Flag FlagsByName is a slice of Flag. @@ -1315,6 +1337,10 @@ 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. + Stringer FlagStringFunc } MutuallyExclusiveFlags defines a mutually exclusive flag group Multiple option paths can be provided out of which only one can be defined on cmdline From b600c6ea4a848bac961bc2f7695dbc97cb735f62 Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Tue, 18 Aug 2026 22:07:25 +0530 Subject: [PATCH 2/5] Exclude Stringer field from JSON marshaling of MutuallyExclusiveFlags FlagStringFunc is a func type and cannot be marshaled to JSON, which broke the staticcheck SA1026 lint check via json.Marshal(cmd) in existing tests. Tag the field with json:"-" to exclude it. --- flag_mutex.go | 2 +- godoc-current.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flag_mutex.go b/flag_mutex.go index 33361d850a..a6a0c72d1e 100644 --- a/flag_mutex.go +++ b/flag_mutex.go @@ -17,7 +17,7 @@ type MutuallyExclusiveFlags struct { // Stringer overrides how each flag within this group is displayed in // help output. If nil, flags use [FlagStringer] as usual. - Stringer FlagStringFunc + Stringer FlagStringFunc `json:"-"` } func (grp MutuallyExclusiveFlags) check(_ *Command) error { diff --git a/godoc-current.txt b/godoc-current.txt index 5832486382..9dbe1047d9 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -1340,7 +1340,7 @@ type MutuallyExclusiveFlags struct { // Stringer overrides how each flag within this group is displayed in // help output. If nil, flags use [FlagStringer] as usual. - Stringer FlagStringFunc + 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 From 82b23b604635e17689cc3dfb69fb91bc10fc51aa Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Tue, 18 Aug 2026 22:13:45 +0530 Subject: [PATCH 3/5] Approve v3 godoc baseline for new Stringer API --- testdata/godoc-v3.x.txt | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index a5382b2d87..9dbe1047d9 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -423,6 +423,11 @@ 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. + func (bif *BoolWithInverseFlag) String() string String implements the standard Stringer interface. @@ -1078,6 +1083,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) @@ -1127,6 +1137,18 @@ var FlagStringer FlagStringFunc = stringifyFlag FlagStringer converts a flag definition to a string. This is used by help to display a flag. +type FlagStringerOverrider 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) +} + FlagStringerOverrider 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 FlagsByName []Flag FlagsByName is a slice of Flag. @@ -1315,6 +1337,10 @@ 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. + 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 From d12c96e1393422a215b077ed2b37a6e0227014c9 Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Sat, 5 Sep 2026 15:58:15 +0530 Subject: [PATCH 4/5] Address review feedback: rename FlagStringerOverrider to StringerSetter, add skip-non-implementor test --- flag.go | 4 ++-- flag_mutex.go | 4 ++-- flag_mutex_test.go | 37 +++++++++++++++++++++++++++++++++++++ godoc-current.txt | 23 +++++++++++------------ testdata/godoc-v3.x.txt | 23 +++++++++++------------ 5 files changed, 63 insertions(+), 28 deletions(-) diff --git a/flag.go b/flag.go index 564c5159f3..9dbe0199ef 100644 --- a/flag.go +++ b/flag.go @@ -190,12 +190,12 @@ type CategorizableFlag interface { SetCategory(string) } -// FlagStringerOverrider is an optional interface that allows an individual +// 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 FlagStringerOverrider interface { +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]. diff --git a/flag_mutex.go b/flag_mutex.go index a6a0c72d1e..adfd77dadf 100644 --- a/flag_mutex.go +++ b/flag_mutex.go @@ -75,7 +75,7 @@ func (grp MutuallyExclusiveFlags) propagateCategory() { } // propagateStringer applies [MutuallyExclusiveFlags.Stringer], if set, to -// every flag within the group that supports a [FlagStringerOverrider]. +// every flag within the group that supports a [StringerSetter]. func (grp MutuallyExclusiveFlags) propagateStringer() { if grp.Stringer == nil { return @@ -83,7 +83,7 @@ func (grp MutuallyExclusiveFlags) propagateStringer() { for _, grpf := range grp.Flags { for _, f := range grpf { - if sf, ok := f.(FlagStringerOverrider); ok { + if sf, ok := f.(StringerSetter); ok { sf.SetStringer(grp.Stringer) } } diff --git a/flag_mutex_test.go b/flag_mutex_test.go index c82c6b3653..fc8c5ec286 100644 --- a/flag_mutex_test.go +++ b/flag_mutex_test.go @@ -170,3 +170,40 @@ func TestMutuallyExclusiveFlags_PropagateStringerNil(t *testing.T) { assert.NotEqual(t, "", grp.Flags[0][0].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 9dbe1047d9..c03f9735e5 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -1137,18 +1137,6 @@ var FlagStringer FlagStringFunc = stringifyFlag FlagStringer converts a flag definition to a string. This is used by help to display a flag. -type FlagStringerOverrider 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) -} - FlagStringerOverrider 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 FlagsByName []Flag FlagsByName is a slice of Flag. @@ -1438,6 +1426,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/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index 9dbe1047d9..c03f9735e5 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -1137,18 +1137,6 @@ var FlagStringer FlagStringFunc = stringifyFlag FlagStringer converts a flag definition to a string. This is used by help to display a flag. -type FlagStringerOverrider 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) -} - FlagStringerOverrider 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 FlagsByName []Flag FlagsByName is a slice of Flag. @@ -1438,6 +1426,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 From 4709169ce43b9ea9d3388c81d2617ea87e5008e7 Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Mon, 14 Sep 2026 11:01:52 +0530 Subject: [PATCH 5/5] Address second round of review feedback - Document the BoolWithInverseFlag.String() name-segment asymmetry on SetStringer - Add end-to-end integration test asserting group Stringer output reaches --help - Document the category-map uniqueness requirement on MutuallyExclusiveFlags.Stringer - Add test covering SetStringer(nil) resetting to default behavior - Note the didSetupDefaults short-circuit limitation on propagateStringer - Document the Stringer field in docs/v3/examples/flags/advanced.md - Regenerate godoc-current.txt / testdata/godoc-v3.x.txt via make v3approve --- docs/v3/examples/flags/advanced.md | 7 ++++++ flag_bool_with_inverse.go | 6 +++++ flag_mutex.go | 12 +++++++++- flag_mutex_test.go | 22 ++++++++++++++++++ godoc-current.txt | 13 +++++++++++ help_test.go | 36 ++++++++++++++++++++++++++++++ testdata/godoc-v3.x.txt | 13 +++++++++++ 7 files changed, 108 insertions(+), 1 deletion(-) 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_bool_with_inverse.go b/flag_bool_with_inverse.go index 583cef8c92..d57225dc17 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -44,6 +44,12 @@ type BoolWithInverseFlag struct { // 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 } diff --git a/flag_mutex.go b/flag_mutex.go index adfd77dadf..bd016988e9 100644 --- a/flag_mutex.go +++ b/flag_mutex.go @@ -17,6 +17,13 @@ type MutuallyExclusiveFlags struct { // 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:"-"` } @@ -75,7 +82,10 @@ func (grp MutuallyExclusiveFlags) propagateCategory() { } // propagateStringer applies [MutuallyExclusiveFlags.Stringer], if set, to -// every flag within the group that supports a [StringerSetter]. +// 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 diff --git a/flag_mutex_test.go b/flag_mutex_test.go index fc8c5ec286..617ba2e29b 100644 --- a/flag_mutex_test.go +++ b/flag_mutex_test.go @@ -171,6 +171,28 @@ func TestMutuallyExclusiveFlags_PropagateStringerNil(t *testing.T) { 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. diff --git a/godoc-current.txt b/godoc-current.txt index b821ee3f7f..dc41e1bd8b 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -428,6 +428,12 @@ func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) 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. @@ -1331,6 +1337,13 @@ type MutuallyExclusiveFlags struct { // 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 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 b821ee3f7f..dc41e1bd8b 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -428,6 +428,12 @@ func (bif *BoolWithInverseFlag) SetStringer(s FlagStringFunc) 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. @@ -1331,6 +1337,13 @@ type MutuallyExclusiveFlags struct { // 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