diff --git a/app_test.go b/app_test.go index 5d2373e7d6..a87b1a6018 100644 --- a/app_test.go +++ b/app_test.go @@ -2874,6 +2874,16 @@ func TestFlagAction(t *testing.T) { return nil }, }, + &StringMapFlag{ + Name: "f_string_map", + Action: func(c *Context, v map[string]string) error { + if _, ok := v["err"]; ok { + return fmt.Errorf("error string map") + } + c.App.Writer.Write([]byte(fmt.Sprintf("%v", v))) + return nil + }, + }, }, Action: func(ctx *Context) error { return nil }, } @@ -3034,6 +3044,16 @@ func TestFlagAction(t *testing.T) { args: []string{"app", "--f_string=app", "--f_uint=1", "--f_int_slice=1,2,3", "--f_duration=1h30m20s", "c1", "--f_string=c1", "sub1", "--f_string=sub1"}, exp: "app 1h30m20s [1 2 3] 1 c1 sub1 ", }, + { + name: "flag_string_map", + args: []string{"app", "--f_string_map=s1=s2,s3="}, + exp: "map[s1:s2 s3:]", + }, + { + name: "flag_string_map_error", + args: []string{"app", "--f_string_map=err="}, + err: fmt.Errorf("error string map"), + }, } for _, test := range tests { diff --git a/flag.go b/flag.go index 6754863050..5fc55e1566 100644 --- a/flag.go +++ b/flag.go @@ -16,8 +16,9 @@ import ( const defaultPlaceholder = "value" var ( - defaultSliceFlagSeparator = "," - disableSliceFlagSeparator = false + defaultSliceFlagSeparator = "," + defaultMapFlagKeyValueSeparator = "=" + disableSliceFlagSeparator = false ) var ( diff --git a/flag_impl.go b/flag_impl.go index cd3d3a5e14..422c2ad58d 100644 --- a/flag_impl.go +++ b/flag_impl.go @@ -192,7 +192,8 @@ func (f *FlagBase[T, C, V]) RunAction(ctx *Context) error { // IsSliceFlag returns true if the value type T is of kind slice func (f *FlagBase[T, C, VC]) IsSliceFlag() bool { // TBD how to specify - return reflect.TypeOf(f.Value).Kind() == reflect.Slice + kind := reflect.TypeOf(f.Value).Kind() + return kind == reflect.Slice || kind == reflect.Map } // IsPersistent returns true if flag needs to be persistent across subcommands diff --git a/flag_map_impl.go b/flag_map_impl.go new file mode 100644 index 0000000000..c15e2870e9 --- /dev/null +++ b/flag_map_impl.go @@ -0,0 +1,116 @@ +package cli + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// MapBase wraps map[string]T to satisfy flag.Value +type MapBase[T any, C any, VC ValueCreator[T, C]] struct { + dict *map[string]T + hasBeenSet bool + value Value +} + +func (i MapBase[T, C, VC]) Create(val map[string]T, p *map[string]T, c C) Value { + *p = map[string]T{} + for k, v := range val { + (*p)[k] = v + } + var t T + np := new(T) + var vc VC + return &MapBase[T, C, VC]{ + dict: p, + value: vc.Create(t, np, c), + } +} + +// NewMapBase makes a *MapBase with default values +func NewMapBase[T any, C any, VC ValueCreator[T, C]](defaults map[string]T) *MapBase[T, C, VC] { + return &MapBase[T, C, VC]{ + dict: &defaults, + } +} + +// Set parses the value and appends it to the list of values +func (i *MapBase[T, C, VC]) Set(value string) error { + if !i.hasBeenSet { + *i.dict = map[string]T{} + i.hasBeenSet = true + } + + if strings.HasPrefix(value, slPfx) { + // Deserializing assumes overwrite + _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &i.dict) + i.hasBeenSet = true + return nil + } + + for _, item := range flagSplitMultiValues(value) { + key, value, ok := strings.Cut(item, defaultMapFlagKeyValueSeparator) + if !ok { + return fmt.Errorf("item %q is missing separator %q", item, defaultMapFlagKeyValueSeparator) + } + if err := i.value.Set(strings.TrimSpace(value)); err != nil { + return err + } + tmp, ok := i.value.Get().(T) + if !ok { + return fmt.Errorf("unable to cast %v", i.value) + } + (*i.dict)[key] = tmp + } + + return nil +} + +// String returns a readable representation of this value (for usage defaults) +func (i *MapBase[T, C, VC]) String() string { + v := i.Value() + var t T + if reflect.TypeOf(t).Kind() == reflect.String { + return fmt.Sprintf("%v", v) + } + return fmt.Sprintf("%T{%s}", v, i.ToString(v)) +} + +// Serialize allows MapBase to fulfill Serializer +func (i *MapBase[T, C, VC]) Serialize() string { + jsonBytes, _ := json.Marshal(i.dict) + return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) +} + +// Value returns the mapping of values set by this flag +func (i *MapBase[T, C, VC]) Value() map[string]T { + if i.dict == nil { + return map[string]T{} + } + return *i.dict +} + +// Get returns the mapping of values set by this flag +func (i *MapBase[T, C, VC]) Get() interface{} { + return *i.dict +} + +func (i MapBase[T, C, VC]) ToString(t map[string]T) string { + var defaultVals []string + var vc VC + for _, k := range sortedKeys(t) { + defaultVals = append(defaultVals, k+defaultMapFlagKeyValueSeparator+vc.ToString(t[k])) + } + return strings.Join(defaultVals, ", ") +} + +func sortedKeys[T any](dict map[string]T) []string { + keys := make([]string, 0, len(dict)) + for k := range dict { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/flag_string_map.go b/flag_string_map.go new file mode 100644 index 0000000000..c7b855c903 --- /dev/null +++ b/flag_string_map.go @@ -0,0 +1,27 @@ +package cli + +import "flag" + +type StringMap = MapBase[string, NoConfig, stringValue] +type StringMapFlag = FlagBase[map[string]string, NoConfig, StringMap] + +var NewStringMap = NewMapBase[string, NoConfig, stringValue] + +// StringMap looks up the value of a local StringMapFlag, returns +// nil if not found +func (cCtx *Context) StringMap(name string) map[string]string { + if fs := cCtx.lookupFlagSet(name); fs != nil { + return lookupStringMap(name, fs) + } + return nil +} + +func lookupStringMap(name string, set *flag.FlagSet) map[string]string { + f := set.Lookup(name) + if f != nil { + if mapping, ok := f.Value.(*StringMap); ok { + return mapping.Value() + } + } + return nil +} diff --git a/flag_test.go b/flag_test.go index 95e3bd9348..b5fab4957d 100644 --- a/flag_test.go +++ b/flag_test.go @@ -174,6 +174,8 @@ func TestFlagsFromEnv(t *testing.T) { {"08", 0, &Uint64Flag{Name: "seconds", EnvVars: []string{"SECONDS"}, Config: IntegerConfig{Base: 0}}, `could not parse "08" as uint64 value from environment variable "SECONDS" for flag seconds: .*`}, {"1.2", 0, &Uint64Flag{Name: "seconds", EnvVars: []string{"SECONDS"}}, `could not parse "1.2" as uint64 value from environment variable "SECONDS" for flag seconds: .*`}, {"foobar", 0, &Uint64Flag{Name: "seconds", EnvVars: []string{"SECONDS"}}, `could not parse "foobar" as uint64 value from environment variable "SECONDS" for flag seconds: .*`}, + + {"foo=bar,empty=", map[string]string{"foo": "bar", "empty": ""}, &StringMapFlag{Name: "names", EnvVars: []string{"NAMES"}}, ""}, } for i, test := range flagTests { @@ -2652,6 +2654,22 @@ func TestUint64Slice_Serialized_Set(t *testing.T) { } } +func TestStringMap_Serialized_Set(t *testing.T) { + m0 := NewStringMap(map[string]string{"a": "b"}) + ser0 := m0.Serialize() + + if len(ser0) < len(slPfx) { + t.Fatalf("serialized shorter than expected: %q", ser0) + } + + m1 := NewStringMap(map[string]string{"c": "d"}) + _ = m1.Set(ser0) + + if m0.String() != m1.String() { + t.Fatalf("pre and post serialization do not match: %v != %v", m0, m1) + } +} + func TestTimestamp_set(t *testing.T) { ts := timestampValue{ timestamp: nil, @@ -2804,6 +2822,12 @@ func TestFlagDefaultValue(t *testing.T) { toParse: []string{"--flag", "13"}, expect: `--flag value (default: 1)`, }, + { + name: "stringMap", + flag: &StringMapFlag{Name: "flag", Value: map[string]string{"default1": "default2"}}, + toParse: []string{"--flag", "parsed="}, + expect: `--flag value [ --flag value ] (default: default1="default2")`, + }, } for i, v := range cases { set := flag.NewFlagSet("test", 0) @@ -2961,6 +2985,15 @@ func TestFlagDefaultValueWithEnv(t *testing.T) { "tflag": "2010-01-02T15:04:05Z", }, }, + { + name: "stringMap", + flag: &StringMapFlag{Name: "flag", Value: map[string]string{"default1": "default2"}, EnvVars: []string{"ssflag"}}, + toParse: []string{"--flag", "parsed="}, + expect: `--flag value [ --flag value ] (default: default1="default2")` + withEnvHint([]string{"ssflag"}, ""), + environ: map[string]string{ + "ssflag": "some-other-env_value=", + }, + }, } for i, v := range cases { for key, val := range v.environ { @@ -3025,6 +3058,12 @@ func TestFlagValue(t *testing.T) { toParse: []string{"--flag", "13,14", "--flag", "15,16"}, expect: `[]uint{13, 14, 15, 16}`, }, + { + name: "stringMap", + flag: &StringMapFlag{Name: "flag", Value: map[string]string{"default1": "default2"}}, + toParse: []string{"--flag", "parsed=parsed2", "--flag", "parsed3=parsed4"}, + expect: `map[parsed:parsed2 parsed3:parsed4]`, + }, } for i, v := range cases { set := flag.NewFlagSet("test", 0) @@ -3125,3 +3164,112 @@ func TestFlagSplitMultiValues_Disabled(t *testing.T) { t.Fatalf("failed to disable split slice flag, want: %s, but got: %s", strings.Join(opts, defaultSliceFlagSeparator), ret[0]) } } + +var stringMapFlagTests = []struct { + name string + aliases []string + value map[string]string + expected string +}{ + {"foo", nil, nil, "--foo value [ --foo value ]\t"}, + {"f", nil, nil, "-f value [ -f value ]\t"}, + {"f", nil, map[string]string{"Lipstick": ""}, "-f value [ -f value ]\t(default: Lipstick=)"}, + {"test", nil, map[string]string{"Something": ""}, "--test value [ --test value ]\t(default: Something=)"}, + {"dee", []string{"d"}, map[string]string{"Inka": "Dinka", "dooo": ""}, "--dee value, -d value [ --dee value, -d value ]\t(default: Inka=\"Dinka\", dooo=)"}, +} + +func TestStringMapFlagHelpOutput(t *testing.T) { + for _, test := range stringMapFlagTests { + f := &StringMapFlag{Name: test.name, Aliases: test.aliases, Value: test.value} + output := f.String() + + if output != test.expected { + t.Errorf("%q does not match %q", output, test.expected) + } + } +} + +func TestStringMapFlagWithEnvVarHelpOutput(t *testing.T) { + defer resetEnv(os.Environ()) + os.Clearenv() + _ = os.Setenv("APP_QWWX", "11,4") + + for _, test := range stringMapFlagTests { + fl := &StringMapFlag{Name: test.name, Aliases: test.aliases, Value: test.value, EnvVars: []string{"APP_QWWX"}} + output := fl.String() + + expectedSuffix := withEnvHint([]string{"APP_QWWX"}, "") + if !strings.HasSuffix(output, expectedSuffix) { + t.Errorf("%q does not end with"+expectedSuffix, output) + } + } +} + +func TestStringMapFlagApply_SetsAllNames(t *testing.T) { + fl := StringMapFlag{Name: "goat", Aliases: []string{"G", "gooots"}} + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + err := set.Parse([]string{"--goat", "aaa=", "-G", "bbb=", "--gooots", "eeeee="}) + expect(t, err, nil) +} + +func TestStringMapFlagApply_UsesEnvValues_noDefault(t *testing.T) { + defer resetEnv(os.Environ()) + os.Clearenv() + _ = os.Setenv("MY_GOAT", "vincent van goat=scape goat") + var val map[string]string + fl := StringMapFlag{Name: "goat", EnvVars: []string{"MY_GOAT"}, Value: val} + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + err := set.Parse(nil) + expect(t, err, nil) + expect(t, val, map[string]string(nil)) + expect(t, set.Lookup("goat").Value.(*StringMap).Value(), map[string]string{"vincent van goat": "scape goat"}) +} + +func TestStringMapFlagApply_UsesEnvValues_withDefault(t *testing.T) { + defer resetEnv(os.Environ()) + os.Clearenv() + _ = os.Setenv("MY_GOAT", "vincent van goat=scape goat") + val := map[string]string{`some default`: `values here`} + fl := StringMapFlag{Name: "goat", EnvVars: []string{"MY_GOAT"}, Value: val} + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + err := set.Parse(nil) + expect(t, err, nil) + expect(t, val, map[string]string{`some default`: `values here`}) + expect(t, set.Lookup("goat").Value.(*StringMap).Value(), map[string]string{"vincent van goat": "scape goat"}) +} + +func TestStringMapFlagApply_DefaultValueWithDestination(t *testing.T) { + defValue := map[string]string{"UA": "US"} + + fl := StringMapFlag{Name: "country", Value: defValue, Destination: &map[string]string{"CA": ""}} + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + err := set.Parse([]string{}) + expect(t, err, nil) + expect(t, defValue, *fl.Destination) +} + +func TestStringMapFlagValueFromContext(t *testing.T) { + set := flag.NewFlagSet("test", 0) + set.Var(NewStringMap(map[string]string{"a": "b", "c": ""}), "myflag", "doc") + ctx := NewContext(nil, set, nil) + f := &StringMapFlag{Name: "myflag"} + expect(t, f.Get(ctx), map[string]string{"a": "b", "c": ""}) +} + +func TestStringMapFlagApply_Error(t *testing.T) { + fl := StringMapFlag{Name: "goat"} + set := flag.NewFlagSet("test", 0) + _ = fl.Apply(set) + + err := set.Parse([]string{"--goat", "aaa", "bbb="}) + if err == nil { + t.Errorf("expected error, but got none") + } +} diff --git a/godoc-current.txt b/godoc-current.txt index 4f426b7ace..3cd7c2e963 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -55,8 +55,8 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .Copyright}} COPYRIGHT: {{template "copyrightTemplate" .}}{{end}} ` - AppHelpTemplate is the text template for the Default help topic. cli.go - uses text/template to render templates. You can render custom help text by + AppHelpTemplate is the text template for the Default help topic. cli.go uses + text/template to render templates. You can render custom help text by setting this variable. var CommandHelpTemplate = `NAME: @@ -131,6 +131,7 @@ var MarkdownDocTemplate = `{{if gt .SectionNum 0}}% {{ .App.Name }} {{ .SectionN var NewFloat64Slice = NewSliceBase[float64, NoConfig, float64Value] var NewInt64Slice = NewSliceBase[int64, IntegerConfig, int64Value] var NewIntSlice = NewSliceBase[int, IntegerConfig, intValue] +var NewStringMap = NewMapBase[string, NoConfig, stringValue] var NewStringSlice = NewSliceBase[string, NoConfig, stringValue] var NewUint64Slice = NewSliceBase[uint64, IntegerConfig, uint64Value] var NewUintSlice = NewSliceBase[uint, IntegerConfig, uintValue] @@ -190,9 +191,9 @@ func DefaultAppComplete(cCtx *Context) func DefaultCompleteWithFlags(cmd *Command) func(cCtx *Context) func FlagNames(name string, aliases []string) []string func HandleAction(action interface{}, cCtx *Context) (err error) - HandleAction attempts to figure out which Action signature was used. - If it's an ActionFunc or a func with the legacy signature for Action, - the func is run! + HandleAction attempts to figure out which Action signature was used. If it's + an ActionFunc or a func with the legacy signature for Action, the func is + run! func HandleExitCoder(err error) HandleExitCoder handles errors implementing ExitCoder by printing their @@ -366,14 +367,14 @@ func (a *App) RunAsSubcommand(ctx *Context) (err error) Deprecated: use App.Run or App.RunContext func (a *App) RunContext(ctx context.Context, arguments []string) (err error) - RunContext is like Run except it takes a Context that will be passed to - its commands and sub-commands. Through this, you can propagate timeouts and + RunContext is like Run except it takes a Context that will be passed to its + commands and sub-commands. Through this, you can propagate timeouts and cancellation requests func (a *App) Setup() - Setup runs initialization code to ensure all data structures are ready - for `Run` or inspection prior to `Run`. It is internally called by `Run`, - but will return early if setup has already happened. + Setup runs initialization code to ensure all data structures are ready for + `Run` or inspection prior to `Run`. It is internally called by `Run`, but + will return early if setup has already happened. func (a *App) ToFishCompletion() (string, error) ToFishCompletion creates a fish completion string for the `*App` The @@ -641,6 +642,10 @@ func (cCtx *Context) Set(name, value string) error func (cCtx *Context) String(name string) string +func (cCtx *Context) StringMap(name string) map[string]string + StringMap looks up the value of a local StringMapFlag, returns nil if not + found + func (cCtx *Context) StringSlice(name string) []string StringSlice looks up the value of a local StringSliceFlag, returns nil if not found @@ -719,9 +724,9 @@ func Exit(message interface{}, exitCode int) ExitCoder Exit wraps a message and exit code into an error, which by default is handled with a call to os.Exit during default error handling. - This is the simplest way to trigger a non-zero exit code for an App - without having to call os.Exit manually. During testing, this behavior - can be avoided by overriding the ExitErrHandler function on an App or the + This is the simplest way to trigger a non-zero exit code for an App without + having to call os.Exit manually. During testing, this behavior can be + avoided by overriding the ExitErrHandler function on an App or the package-global OsExiter function. func NewExitError(message interface{}, exitCode int) ExitCoder @@ -930,6 +935,33 @@ type InvalidFlagAccessFunc func(*Context, string) InvalidFlagAccessFunc is executed when an invalid flag is accessed from the context. +type MapBase[T any, C any, VC ValueCreator[T, C]] struct { + // Has unexported fields. +} + MapBase wraps map[string]T to satisfy flag.Value + +func NewMapBase[T any, C any, VC ValueCreator[T, C]](defaults map[string]T) *MapBase[T, C, VC] + NewMapBase makes a *MapBase with default values + +func (i MapBase[T, C, VC]) Create(val map[string]T, p *map[string]T, c C) Value + +func (i *MapBase[T, C, VC]) Get() interface{} + Get returns the mapping of values set by this flag + +func (i *MapBase[T, C, VC]) Serialize() string + Serialize allows MapBase to fulfill Serializer + +func (i *MapBase[T, C, VC]) Set(value string) error + Set parses the value and appends it to the list of values + +func (i *MapBase[T, C, VC]) String() string + String returns a readable representation of this value (for usage defaults) + +func (i MapBase[T, C, VC]) ToString(t map[string]T) string + +func (i *MapBase[T, C, VC]) Value() map[string]T + Value returns the mapping of values set by this flag + type MultiError interface { error Errors() []error @@ -957,8 +989,8 @@ type RequiredFlag interface { // whether the flag is a required flag or not IsRequired() bool } - RequiredFlag is an interface that allows us to mark flags as required - it allows flags required flags to be backwards compatible with the Flag + RequiredFlag is an interface that allows us to mark flags as required it + allows flags required flags to be backwards compatible with the Flag interface type Serializer interface { @@ -998,6 +1030,10 @@ func (i *SliceBase[T, C, VC]) Value() []T type StringFlag = FlagBase[string, NoConfig, stringValue] +type StringMap = MapBase[string, NoConfig, stringValue] + +type StringMapFlag = FlagBase[map[string]string, NoConfig, StringMap] + type StringSlice = SliceBase[string, NoConfig, stringValue] type StringSliceFlag = FlagBase[[]string, NoConfig, StringSlice]