Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions command_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions docs/v3/examples/flags/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion flag_bool_with_inverse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")

Expand Down
22 changes: 17 additions & 5 deletions flag_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions flag_mutex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
}
99 changes: 99 additions & 0 deletions flag_mutex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
38 changes: 38 additions & 0 deletions godoc-current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading