diff --git a/README.md b/README.md index 1b3a449..257f9a3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ 1. [Maps](#maps) 1. [Custom named types](#custom-named-types) 1. [Supported tags](#supported-tags) +1. [Variable interpolation](#variable-interpolation) 1. [Modifying Kong's behaviour](#modifying-kongs-behaviour) 1. [`Name(help)` and `Description(help)` - set the application name description](#namehelp-and-descriptionhelp---set-the-application-name-description) 1. [`Configuration(loader, paths...)` - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files) @@ -313,8 +314,8 @@ function `NamedMapper(name, mapper)`. | Name | Description | |-------------------|---------------------------------------------------| -| `file` | A path. ~ expansion is applied. | -| `existingfile` | An existing path. ~ expansion is applied. | +| `path` | A path. ~ expansion is applied. | +| `existingfile` | An existing file. ~ expansion is applied. | | `existingdir` | An existing directory. ~ expansion is applied. | @@ -348,6 +349,41 @@ Both can coexist with standard Tag parsing. | `hidden` | If present, command or flag is hidden. | | `format:"X"` | Format for parsing input, if supported. | | `sep:"X"` | Separator for sequences (defaults to ","). May be `none` to disable splitting. | +| `enum:"X,Y,..."` | + +## Variable interpolation + +Kong supports limited variable interpolation into help strings, enum lists and +default values. + +Variables are in the form: + + ${} + +Variables are set with the `Vars(map[string]string)` option. Undefined +variable references in the grammar will result in an error at construction +time. + +When interpolating into flag or argument help strings, some extra variables +are defined from the value itself: + + ${default} + ${enum} + +eg. + +```go +type cli struct { + Config string `type:"path" default:"${config_file}"` +} + +func main() { + kong.Parse(&cli, + kong.Vars(map[string]string{ + "config_file": "~/.app.conf", + })) +} +``` ## Modifying Kong's behaviour diff --git a/build.go b/build.go index 3bea4ae..e8e36b1 100644 --- a/build.go +++ b/build.go @@ -151,6 +151,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv Mapper: mapper, Tag: tag, Target: fv, + Enum: tag.Enum, // Flags are optional by default, and args are required by default. Required: (!tag.Arg && tag.Required) || (tag.Arg && !tag.Optional), diff --git a/interpolate.go b/interpolate.go new file mode 100644 index 0000000..296f9a4 --- /dev/null +++ b/interpolate.go @@ -0,0 +1,27 @@ +package kong + +import ( + "fmt" + "regexp" +) + +var interpolationRegex = regexp.MustCompile(`(\${[[:alpha:]_][[:word:]]*})|(\$)|([^$]+)`) + +// Interpolate variables from vars into s for substrings in the form ${var}. +func interpolate(s string, vars map[string]string) (string, error) { + out := "" + matches := interpolationRegex.FindAllStringSubmatch(s, -1) + for _, match := range matches { + if match[1] != "" { + name := match[1][2 : len(match[1])-1] + value, ok := vars[name] + if !ok { + return "", fmt.Errorf("undefined variable ${%s}", name) + } + out += value + } else { + out += match[0] + } + } + return out, nil +} diff --git a/interpolate_test.go b/interpolate_test.go new file mode 100644 index 0000000..f049e38 --- /dev/null +++ b/interpolate_test.go @@ -0,0 +1,17 @@ +package kong + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInterpolate(t *testing.T) { + vars := map[string]string{ + "name": "Bobby Brown", + "age": "35", + } + actual, err := interpolate("${name} is ${age} years old", vars) + require.NoError(t, err) + require.Equal(t, `Bobby Brown is 35 years old`, actual) +} diff --git a/kong.go b/kong.go index 9d2c5ca..0771fe9 100644 --- a/kong.go +++ b/kong.go @@ -51,6 +51,7 @@ type Kong struct { help HelpPrinter helpOptions HelpOptions helpFlag *Flag + vars map[string]string // Set temporarily by Options. These are applied after build(). postBuildOptions []Option @@ -67,6 +68,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { before: map[reflect.Value]HookFunc{}, registry: NewRegistry().RegisterDefaults(), resolvers: []ResolverFunc{Envars()}, + vars: map[string]string{}, } for _, option := range options { @@ -88,15 +90,71 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { k.Model.HelpFlag = k.helpFlag for _, option := range k.postBuildOptions { - if err := option(k); err != nil { + if err = option(k); err != nil { return nil, err } } k.postBuildOptions = nil + if err = k.interpolate(k.Model.Node); err != nil { + return nil, err + } + return k, nil } +// Interpolate variables into model. +func (k *Kong) interpolate(node *Node) (err error) { + node.Help, err = interpolate(node.Help, k.vars) + if err != nil { + return fmt.Errorf("help for %s: %s", node.Path(), err) + } + for _, flag := range node.Flags { + if err = k.interpolateValue(flag.Value); err != nil { + return err + } + } + for _, pos := range node.Positional { + if err = k.interpolateValue(pos); err != nil { + return err + } + } + for _, child := range node.Children { + if err = k.interpolate(child); err != nil { + return err + } + } + return nil +} + +func (k *Kong) interpolateValue(value *Value) (err error) { + if value.Default, err = interpolate(value.Default, k.vars); err != nil { + return fmt.Errorf("default value for %s: %s", value.Summary(), err) + } + if value.Enum, err = interpolate(value.Enum, k.vars); err != nil { + return fmt.Errorf("enum value for %s: %s", value.Summary(), err) + } + vars := mergeVars(k.vars, map[string]string{ + "default": value.Default, + "enum": value.Enum, + }) + if value.Help, err = interpolate(value.Help, vars); err != nil { + return fmt.Errorf("help for %s: %s", value.Summary(), err) + } + return nil +} + +func mergeVars(base, extra map[string]string) map[string]string { + out := make(map[string]string, len(base)+len(extra)) + for k, v := range base { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out +} + // Provide additional builtin flags, if any. func (k *Kong) extraFlags() []*Flag { if k.noDefaultHelp { diff --git a/kong_test.go b/kong_test.go index 596833b..90cf24a 100644 --- a/kong_test.go +++ b/kong_test.go @@ -512,3 +512,24 @@ func TestRun(t *testing.T) { err = ctx.Run("ERROR") require.Error(t, err) } + +func TestInterpolationIntoModel(t *testing.T) { + var cli struct { + Flag string `default:"${default}" help:"Help, I need ${somebody}" enum:"${enum}"` + EnumRef string `enum:"a,b" help:"One of ${enum}"` + } + _, err := kong.New(&cli) + require.Error(t, err) + p, err := kong.New(&cli, kong.Vars(map[string]string{ + "default": "Some default value.", + "somebody": "chickens!", + "enum": "a,b,c,d", + })) + require.NoError(t, err) + flag := p.Model.Flags[1] + flag2 := p.Model.Flags[2] + require.Equal(t, "Some default value.", flag.Default) + require.Equal(t, "Help, I need chickens!", flag.Help) + require.Equal(t, map[string]bool{"a": true, "b": true, "c": true, "d": true}, flag.EnumMap()) + require.Equal(t, "One of a,b", flag2.Help) +} diff --git a/model.go b/model.go index 40c7030..2974c91 100644 --- a/model.go +++ b/model.go @@ -185,10 +185,11 @@ func (n *Node) Path() (out string) { // A Value is either a flag or a variable positional argument. type Value struct { - Flag *Flag + Flag *Flag // Nil if positional argument. Name string Help string Default string + Enum string Mapper Mapper Tag *Tag Target reflect.Value @@ -198,6 +199,16 @@ type Value struct { Position int // Position (for positional arguments). } +// EnumMap returns a map of the enums in this value. +func (v *Value) EnumMap() map[string]bool { + parts := strings.Split(v.Enum, ",") + out := make(map[string]bool, len(parts)) + for _, part := range parts { + out[strings.TrimSpace(part)] = true + } + return out +} + // Summary returns a human-readable summary of the value. func (v *Value) Summary() string { if v.Flag != nil { diff --git a/options.go b/options.go index ef78f82..4b99281 100644 --- a/options.go +++ b/options.go @@ -12,6 +12,16 @@ import ( // An Option applies optional changes to the Kong application. type Option func(k *Kong) error +// Vars sets the variables to use for interpolation into help strings and default values. +// +// See README for details. +func Vars(vars map[string]string) Option { + return func(k *Kong) error { + k.vars = vars + return nil + } +} + // Exit overrides the function used to terminate. This is useful for testing or interactive use. func Exit(exit func(int)) Option { return func(k *Kong) error { diff --git a/tag.go b/tag.go index 2904d44..e871f16 100644 --- a/tag.go +++ b/tag.go @@ -24,7 +24,7 @@ type Tag struct { Short rune Hidden bool Sep rune - Enum map[string]bool + Enum string // Storage for all tag keys for arbitrary lookups. items map[string]string @@ -114,7 +114,6 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag { s, chars := getTagInfo(ft) t := &Tag{ items: parseTagItems(s, chars), - Enum: map[string]bool{}, } t.Cmd = t.Has("cmd") t.Arg = t.Has("arg") @@ -149,10 +148,7 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag { if t.PlaceHolder == "" { t.PlaceHolder = strings.ToUpper(dashedString(fv.Type().Name())) } - for _, part := range strings.Split(t.Get("enum"), ",") { - t.Enum[part] = true - } - + t.Enum = t.Get("enum") return t }