From ba1266f7c4b9993b4803c132f15b2cce5e8815f8 Mon Sep 17 00:00:00 2001 From: upstream Date: Wed, 7 Oct 2026 19:37:46 +0200 Subject: [PATCH] Upstream urfave/cli state of the touched files before 9593c3306f --- flag_bool_with_inverse.go | 306 +++++++++++++++++ flag_bool_with_inverse_test.go | 605 +++++++++++++++++++++++++++++++++ 2 files changed, 911 insertions(+) create mode 100644 flag_bool_with_inverse.go create mode 100644 flag_bool_with_inverse_test.go diff --git a/flag_bool_with_inverse.go b/flag_bool_with_inverse.go new file mode 100644 index 0000000..d885bbd --- /dev/null +++ b/flag_bool_with_inverse.go @@ -0,0 +1,306 @@ +package cli + +import ( + "context" + "fmt" + "slices" + "strings" +) + +var DefaultInverseBoolPrefix = "no-" + +type BoolWithInverseFlag 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 + 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 + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set + Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well + Value bool `json:"defaultValue"` // default value for this flag if not set by from any source + Destination *bool `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, bool) error `json:"-"` // Action callback to be called when flag is set + OnlyOnce bool `json:"onlyOnce"` // whether this flag can be duplicated on the command line + Validator func(bool) error `json:"-"` // custom function to validate this flag value + ValidateDefaults bool `json:"validateDefaults"` // whether to validate defaults or not + Config BoolConfig `json:"config"` // Additional/Custom configuration associated with this flag type + InversePrefix string `json:"invPrefix"` // The prefix used to indicate a negative value. Default: `env` becomes `no-env` + + // 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 + fromSource bool // whether the current value came from Sources + applied bool // whether the flag has been applied to a flag set already + 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 { + return bif.hasBeenSet +} + +func (bif *BoolWithInverseFlag) Get() any { + return bif.value.Get() +} + +func (bif *BoolWithInverseFlag) RunAction(ctx context.Context, cmd *Command) error { + if bif.Action != nil { + return bif.Action(ctx, cmd, bif.Get().(bool)) + } + + return nil +} + +func (bif *BoolWithInverseFlag) IsLocal() bool { + return bif.Local +} + +func (bif *BoolWithInverseFlag) inversePrefix() string { + if bif.InversePrefix == "" { + bif.InversePrefix = DefaultInverseBoolPrefix + } + + return bif.InversePrefix +} + +func (bif *BoolWithInverseFlag) PreParse() error { + dest := bif.Destination + if dest == nil { + dest = new(bool) + } + *dest = bif.Value + bif.value = &boolValue{ + destination: dest, + count: bif.Config.Count, + } + + // Validate the given default or values set from external sources as well + if bif.Validator != nil && bif.ValidateDefaults { + if err := bif.Validator(bif.value.Get().(bool)); err != nil { + return err + } + } + bif.applied = true + return nil +} + +func (bif *BoolWithInverseFlag) PostParse() error { + tracef("postparse (flag=%[1]q)", bif.Name) + + if !bif.hasBeenSet { + if val, source, found := bif.Sources.LookupWithSource(); found { + if val == "" { + val = "false" + } + if err := bif.Set(bif.Name, val); err != nil { + return fmt.Errorf( + "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", + val, bif.Value, source, bif.Name, err, + ) + } + + bif.hasBeenSet = true + bif.fromSource = true + } + } + + return nil +} + +func (bif *BoolWithInverseFlag) Set(name, val string) error { + // A value from Sources is only a fallback, so a value set afterwards + // replaces it (see FlagBase.Set). + if bif.fromSource { + bif.fromSource = false + bif.count = 0 + bif.pset = false + bif.nset = false + } + + if bif.count > 0 && bif.OnlyOnce { + return fmt.Errorf("can't duplicate this flag") + } + + bif.hasBeenSet = true + + if slices.Contains(append([]string{bif.Name}, bif.Aliases...), name) { + if bif.nset { + return fmt.Errorf("cannot set both flags `--%s` and `--%s`", bif.Name, bif.inversePrefix()+bif.Name) + } + if err := bif.value.Set(val); err != nil { + return err + } + bif.pset = true + } else { + if bif.pset { + return fmt.Errorf("cannot set both flags `--%s` and `--%s`", bif.Name, bif.inversePrefix()+bif.Name) + } + if err := bif.value.Set("false"); err != nil { + return err + } + bif.nset = true + } + bif.count++ + + if bif.Validator != nil { + return bif.Validator(bif.value.Get().(bool)) + } + return nil +} + +func (bif *BoolWithInverseFlag) Names() []string { + names := append([]string{bif.Name}, bif.Aliases...) + + for _, name := range names { + names = append(names, bif.inversePrefix()+name) + } + + return names +} + +func (bif *BoolWithInverseFlag) IsRequired() bool { + return bif.Required +} + +func (bif *BoolWithInverseFlag) IsVisible() bool { + return !bif.Hidden +} + +// String implements the standard Stringer interface. +// +// Example for BoolFlag{Name: "env"} +// --[no-]env (default: false) +// +// Example for BoolFlag{Name: "env", Aliases: []string{"e"}} +// --[no-]env, -e (default: false) +func (bif *BoolWithInverseFlag) String() string { + fs := FlagStringer + if bif.stringer != nil { + fs = bif.stringer + } + out := fs(bif) + + i := strings.Index(out, "\t") + + prefix := "--" + + // single character flags are prefixed with `-` instead of `--` + if len(bif.Name) == 1 { + prefix = "-" + } + + // Guard against a FlagStringer that returns a string without a tab (e.g. + // a custom stringer or the default stringer when the flag does not + // implement DocGenerationFlag). In that case treat the entire output as + // the tab-delimited suffix so the slice never goes out of bounds. + if i < 0 { + i = 0 + } + + var aliasParts []string + for _, alias := range bif.Aliases { + aPrefix := "--" + if len(alias) == 1 { + aPrefix = "-" + } + aliasParts = append(aliasParts, aPrefix+alias) + } + + names := fmt.Sprintf("%s[%s]%s", prefix, bif.inversePrefix(), bif.Name) + if len(aliasParts) > 0 { + names = names + ", " + strings.Join(aliasParts, ", ") + } + + return fmt.Sprintf("%s%s", names, out[i:]) +} + +// IsBoolFlag returns whether the flag doesn't need to accept args +func (bif *BoolWithInverseFlag) IsBoolFlag() bool { + return true +} + +// Count returns the number of times this flag has been invoked +func (bif *BoolWithInverseFlag) Count() int { + return bif.count +} + +// GetDefaultText returns the default text for this flag +func (bif *BoolWithInverseFlag) GetDefaultText() string { + if bif.DefaultText != "" { + return bif.DefaultText + } + return boolValue{}.ToString(bif.Value) +} + +// GetDeprecated returns the deprecation message of the flag +func (bif *BoolWithInverseFlag) GetDeprecated() string { + return bif.Deprecated +} + +// GetCategory returns the category of the flag +func (bif *BoolWithInverseFlag) GetCategory() string { + return bif.Category +} + +func (bif *BoolWithInverseFlag) SetCategory(c string) { + bif.Category = c +} + +// GetUsage returns the usage string for the flag +func (bif *BoolWithInverseFlag) GetUsage() string { + return bif.Usage +} + +// GetEnvVars returns the env vars for this flag +func (bif *BoolWithInverseFlag) GetEnvVars() []string { + return bif.Sources.EnvKeys() +} + +// GetValue returns the flags value as string representation and an empty +// string if the flag takes no value at all. +func (bif *BoolWithInverseFlag) GetValue() string { + return "" +} + +func (bif *BoolWithInverseFlag) TakesValue() bool { + return false +} + +// IsDefaultVisible returns true if the flag is not hidden, otherwise false +func (bif *BoolWithInverseFlag) IsDefaultVisible() bool { + return !bif.HideDefault +} + +// TypeName is used for stringify/docs. For bool its a no-op +func (bif *BoolWithInverseFlag) TypeName() string { + return "bool" +} + +func (bif *BoolWithInverseFlag) SchemaType() string { + return "boolean" +} + +func (bif *BoolWithInverseFlag) SchemaItemsType() string { + return "" +} diff --git a/flag_bool_with_inverse_test.go b/flag_bool_with_inverse_test.go new file mode 100644 index 0000000..895c33d --- /dev/null +++ b/flag_bool_with_inverse_test.go @@ -0,0 +1,605 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var errBothEnvFlagsAreSet = fmt.Errorf("cannot set both flags `--env` and `--no-env`") + +type boolWithInverseTestCase struct { + args []string + toBeSet bool + value bool + err error + envVars map[string]string +} + +func (tc *boolWithInverseTestCase) Run(t *testing.T, flagWithInverse *BoolWithInverseFlag) error { + cmd := &Command{ + Flags: []Flag{flagWithInverse}, + Action: func(context.Context, *Command) error { return nil }, + } + + for key, val := range tc.envVars { + t.Setenv(key, val) + } + + err := cmd.Run(buildTestContext(t), append([]string{"prog"}, tc.args...)) + if err != nil { + return err + } + + if flagWithInverse.IsSet() != tc.toBeSet { + return fmt.Errorf("flag should be set %t, but got %t", tc.toBeSet, flagWithInverse.IsSet()) + } + + if flagWithInverse.Get() != tc.value { + return fmt.Errorf("flag value should be %t, but got %t", tc.value, flagWithInverse.Get()) + } + + return nil +} + +func runBoolWithInverseFlagTests(t *testing.T, newFlagMethod func() *BoolWithInverseFlag, cases []*boolWithInverseTestCase) error { + for _, tc := range cases { + t.Run(strings.Join(tc.args, " ")+fmt.Sprintf("%[1]v %[2]v %[3]v", tc.value, tc.toBeSet, tc.err), func(t *testing.T) { + r := require.New(t) + + fl := newFlagMethod() + + err := tc.Run(t, fl) + if err != nil && tc.err == nil { + r.NoError(err) + } + + if err == nil && tc.err != nil { + r.Error(err) + } + + if err != nil && tc.err != nil { + r.ErrorContains(err, tc.err.Error()) + } + }) + } + + return nil +} + +func TestBoolWithInverseBasic(t *testing.T) { + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + } + } + + testCases := []*boolWithInverseTestCase{ + { + args: []string{"--no-env"}, + toBeSet: true, + value: false, + }, + { + args: []string{"--env"}, + toBeSet: true, + value: true, + }, + { + toBeSet: false, + value: false, + }, + { + args: []string{"--env", "--no-env"}, + err: errBothEnvFlagsAreSet, + }, + } + + err := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseAction(t *testing.T) { + err := fmt.Errorf("action called") + flagMethod := func() *BoolWithInverseFlag { + bif := &BoolWithInverseFlag{ + Name: "env", + + // Setting env to the opposite to test flag Action is working as intended + Action: func(_ context.Context, cmd *Command, value bool) error { + return err + }, + } + return bif + } + + testCases := []*boolWithInverseTestCase{ + { + args: []string{"--no-env"}, + toBeSet: true, + value: false, + err: err, + }, + { + args: []string{"--env"}, + toBeSet: true, + value: true, + err: err, + }, + + // This test is not inverse because the flag action is never called + { + toBeSet: false, + value: false, + }, + { + args: []string{"--env", "--no-env"}, + err: errBothEnvFlagsAreSet, + }, + } + + errr := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if errr != nil { + t.Error(errr) + return + } +} + +func TestBoolWithInverseAlias(t *testing.T) { + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + Aliases: []string{"e", "do-env"}, + } + } + + testCases := []*boolWithInverseTestCase{ + { + args: []string{"--no-e"}, + toBeSet: true, + value: false, + }, + { + args: []string{"--e"}, + toBeSet: true, + value: true, + }, + { + toBeSet: false, + value: false, + }, + { + args: []string{"--do-env", "--no-do-env"}, + err: errBothEnvFlagsAreSet, + }, + } + + err := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseEnvVars(t *testing.T) { + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + Sources: EnvVars("ENV", "NO-ENV"), + Local: true, + } + } + + testCases := []*boolWithInverseTestCase{ + { + toBeSet: true, + value: false, + envVars: map[string]string{ + "NO-ENV": "false", + }, + }, + { + toBeSet: true, + value: true, + envVars: map[string]string{ + "ENV": "true", + }, + }, + { + toBeSet: true, + value: false, + envVars: map[string]string{ + "ENV": "false", + }, + }, + { + toBeSet: false, + value: false, + }, + // TODO + /*{ + err: errBothEnvFlagsAreSet, + envVars: map[string]string{ + "ENV": "true", + "NO-ENV": "true", + }, + },*/ + { + err: fmt.Errorf("could not parse \"true_env\" as bool value from environment variable \"ENV\" for flag env: parse error"), + envVars: map[string]string{ + "ENV": "true_env", + }, + }, + { + err: fmt.Errorf("could not parse \"false_env\" as bool value from environment variable \"NO-ENV\" for flag env: parse error"), + envVars: map[string]string{ + "NO-ENV": "false_env", + }, + }, + } + + err := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseWithPrefix(t *testing.T) { + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + InversePrefix: "without-", + } + } + + testCases := []*boolWithInverseTestCase{ + { + args: []string{"--without-env"}, + toBeSet: true, + value: false, + }, + { + args: []string{"--env"}, + toBeSet: true, + value: true, + }, + { + toBeSet: false, + value: false, + }, + { + args: []string{"--env", "--without-env"}, + err: fmt.Errorf("cannot set both flags `--env` and `--without-env`"), + }, + { + args: []string{"--without-env", "--env"}, + err: fmt.Errorf("cannot set both flags `--env` and `--without-env`"), + }, + } + + err := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseRequired(t *testing.T) { + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + Required: true, + } + } + + testCases := []*boolWithInverseTestCase{ + { + args: []string{"--no-env"}, + toBeSet: true, + value: false, + }, + { + args: []string{"--env"}, + toBeSet: true, + value: true, + }, + { + args: []string{"--env", "--no-env"}, + err: errBothEnvFlagsAreSet, + }, + } + + err := runBoolWithInverseFlagTests(t, flagMethod, testCases) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseNames(t *testing.T) { + flag := &BoolWithInverseFlag{ + Name: "env", + Required: true, + } + names := flag.Names() + + 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`") + + var d DocGenerationFlag = flag + require.Equal(t, "bool", d.TypeName()) +} + +func TestBoolWithInverseDefaultText(t *testing.T) { + require.Equal(t, "false", (&BoolWithInverseFlag{}).GetDefaultText()) + require.Equal(t, "true", (&BoolWithInverseFlag{Value: true}).GetDefaultText()) + require.Equal(t, "auto", (&BoolWithInverseFlag{DefaultText: "auto"}).GetDefaultText()) +} + +func TestBoolWithInverseString(t *testing.T) { + tcs := []struct { + testName string + flagName string + required bool + usage string + inversePrefix string + aliases []string + expected string + }{ + { + testName: "empty inverse prefix no flag", + 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, + expected: "--[no-]env\t", + }, + { + testName: "short alias", + flagName: "color", + required: false, + aliases: []string{"c"}, + expected: "--[no-]color, -c\t(default: false)", + }, + { + testName: "long alias", + flagName: "color", + required: false, + aliases: []string{"colour"}, + expected: "--[no-]color, --colour\t(default: false)", + }, + { + testName: "multiple aliases", + flagName: "color", + required: false, + aliases: []string{"c", "colour"}, + expected: "--[no-]color, -c, --colour\t(default: false)", + }, + } + + for _, tc := range tcs { + t.Run(tc.testName, func(t *testing.T) { + flag := &BoolWithInverseFlag{ + Name: tc.flagName, + Usage: tc.usage, + Required: tc.required, + InversePrefix: tc.inversePrefix, + Aliases: tc.aliases, + } + + require.Equal(t, tc.expected, flag.String()) + }) + } +} + +func TestBoolWithInverseDestination(t *testing.T) { + destination := new(bool) + count := new(int) + + flagMethod := func() *BoolWithInverseFlag { + return &BoolWithInverseFlag{ + Name: "env", + Destination: destination, + Config: BoolConfig{ + Count: count, + }, + } + } + + checkAndReset := func(expectedCount int, expectedValue bool) error { + if *count != expectedCount { + return fmt.Errorf("expected count to be %d, got %d", expectedCount, *count) + } + + if *destination != expectedValue { + return fmt.Errorf("expected destination to be %t, got %t", expectedValue, *destination) + } + + *count = 0 + *destination = false + + return nil + } + + err := (&boolWithInverseTestCase{ + args: []string{"--env"}, + toBeSet: true, + value: true, + }).Run(t, flagMethod()) + if err != nil { + t.Error(err) + return + } + + err = checkAndReset(1, true) + if err != nil { + t.Error(err) + return + } + + err = (&boolWithInverseTestCase{ + args: []string{"--no-env"}, + toBeSet: true, + value: false, + }).Run(t, flagMethod()) + if err != nil { + t.Error(err) + return + } + + err = checkAndReset(1, false) + if err != nil { + t.Error(err) + return + } + + err = (&boolWithInverseTestCase{ + args: []string{}, + toBeSet: false, + value: false, + }).Run(t, flagMethod()) + if err != nil { + t.Error(err) + return + } + + err = checkAndReset(0, false) + if err != nil { + t.Error(err) + return + } + + f := flagMethod() + f.Value = true + err = (&boolWithInverseTestCase{ + args: []string{}, + toBeSet: false, + value: true, + }).Run(t, f) + if err != nil { + t.Error(err) + return + } + + err = checkAndReset(0, true) + if err != nil { + t.Error(err) + return + } +} + +func TestBoolWithInverseFlag_SatisfiesRequiredFlagInterface(t *testing.T) { + var f RequiredFlag = &BoolWithInverseFlag{} + + _ = f.IsRequired() +} + +func TestBoolWithInverseFlag_SatisfiesVisibleFlagInterface(t *testing.T) { + var f VisibleFlag = &BoolWithInverseFlag{} + + _ = f.IsVisible() +} + +// TestBoolWithInverseFlagStringNoPanicWithNoTabStringer is a regression test for +// https://github.com/urfave/cli/issues/2303. +// BoolWithInverseFlag.String() panicked with "slice bounds out of range [-1:]" +// when the FlagStringer returned a string without a tab character. +func TestBoolWithInverseFlagStringNoPanicWithNoTabStringer(t *testing.T) { + orig := FlagStringer + defer func() { FlagStringer = orig }() + + FlagStringer = func(f Flag) string { + return "no tab here" + } + + flag := &BoolWithInverseFlag{ + Name: "verbose", + } + + // Must not panic. + got := flag.String() + if !strings.Contains(got, "verbose") { + t.Errorf("expected String() to contain the flag name, got %q", got) + } +} + +func TestBoolWithInverseFlagCount(t *testing.T) { + for _, externalCount := range []bool{false, true} { + for _, onlyOnce := range []bool{false, true} { + for _, name := range []string{"--env", "--no-env", "-e", "--no-e"} { + t.Run(fmt.Sprintf("external=%v/onlyOnce=%v/%s", externalCount, onlyOnce, name), func(t *testing.T) { + const initialCount = 7 + count := initialCount + fl := &BoolWithInverseFlag{Name: "env", Aliases: []string{"e"}, OnlyOnce: onlyOnce} + if externalCount { + fl.Config.Count = &count + } + cmd := &Command{Flags: []Flag{fl}, Writer: io.Discard, ErrWriter: io.Discard} + err := cmd.Run(buildTestContext(t), []string{"prog", name, name}) + wantCount := 2 + if onlyOnce { + require.ErrorContains(t, err, "can't duplicate this flag") + wantCount = 1 + } else { + require.NoError(t, err) + } + require.Equal(t, wantCount, fl.Count()) + if externalCount { + require.Equal(t, initialCount+wantCount, count) + } + }) + } + } + } +}