diff --git a/template/template.go b/template/template.go index d9483cbd..27e1b457 100644 --- a/template/template.go +++ b/template/template.go @@ -29,23 +29,28 @@ import ( var delimiter = "\\$" var substitutionNamed = "[_a-z][_a-z0-9]*" var substitutionBraced = "[_a-z][_a-z0-9]*(?::?[-+?](.*))?" +var substitutionMapping = "[_a-z][_a-z0-9]*\\[(.*)\\](?::?[-+?}](.*))?" var groupEscaped = "escaped" var groupNamed = "named" var groupBraced = "braced" +var groupMapping = "mapping" var groupInvalid = "invalid" var patternString = fmt.Sprintf( - "%s(?i:(?P<%s>%s)|(?P<%s>%s)|{(?:(?P<%s>%s)}|(?P<%s>)))", + "%s(?i:(?P<%s>%s)|(?P<%s>%s)|{(?:(?P<%s>%s)}|(?P<%s>%s)}|(?P<%s>)))", delimiter, groupEscaped, delimiter, groupNamed, substitutionNamed, groupBraced, substitutionBraced, + groupMapping, substitutionMapping, groupInvalid, ) var DefaultPattern = regexp.MustCompile(patternString) +var NamedMappingKeyPattern = regexp.MustCompile("^[_A-Za-z0-9.-]*$") + // InvalidTemplateError is returned when a variable template is not in a valid // format type InvalidTemplateError struct { @@ -69,12 +74,26 @@ func (e MissingRequiredError) Error() string { return fmt.Sprintf("required variable %s is missing a value", e.Variable) } +// MissingNamedMappingError is returned when a specific named mapping is missing +// Guaranteed to not return if `Config.namedMappings` is nil (named mappings not enabled) +type MissingNamedMappingError struct { + Name string +} + +func (e MissingNamedMappingError) Error() string { + return fmt.Sprintf("named mapping not found: %q", e.Name) +} + // Mapping is a user-supplied function which maps from variable names to values. // Returns the value as a string and a bool indicating whether // the value is present, to distinguish between an empty string // and the absence of a value. type Mapping func(string) (string, bool) +// NamedMappings is a collection of mappings indexed by a name key. +// It allows temporarily switching to other mappings other than default during interpolation +type NamedMappings map[string]Mapping + // SubstituteFunc is a user-supplied function that apply substitution. // Returns the value as a string, a bool indicating if the function could apply // the substitution and an error. @@ -88,6 +107,7 @@ type Config struct { pattern *regexp.Regexp substituteFunc SubstituteFunc replacementFunc ReplacementFunc + namedMappings NamedMappings logging bool } @@ -111,6 +131,12 @@ func WithReplacementFunction(replacementFunc ReplacementFunc) Option { } } +func WithNamedMappings(namedMappings NamedMappings) Option { + return func(cfg *Config) { + cfg.namedMappings = namedMappings + } +} + func WithoutLogging(cfg *Config) { cfg.logging = false } @@ -118,8 +144,6 @@ func WithoutLogging(cfg *Config) { // SubstituteWithOptions substitute variables in the string with their values. // It accepts additional options such as a custom function or pattern. func SubstituteWithOptions(template string, mapping Mapping, options ...Option) (string, error) { - var returnErr error - cfg := &Config{ pattern: DefaultPattern, replacementFunc: DefaultReplacementFunc, @@ -129,7 +153,33 @@ func SubstituteWithOptions(template string, mapping Mapping, options ...Option) o(cfg) } - result := cfg.pattern.ReplaceAllStringFunc(template, func(substring string) string { + return substituteWithConfig(template, mapping, cfg) +} + +func substituteWithConfig(template string, mapping Mapping, cfg *Config) (result string, returnErr error) { + if cfg == nil { + cfg = &Config{ + pattern: DefaultPattern, + replacementFunc: DefaultReplacementFunc, + logging: true, + } + } + + defer func() { + // Convert panic message to error, so mappings can use panic to report error + if r := recover(); r != nil { + switch r := r.(type) { + case string: + returnErr = errors.New(r) + case error: + returnErr = r + default: + returnErr = errors.New(fmt.Sprint(r)) + } + } + }() + + result = cfg.pattern.ReplaceAllStringFunc(template, func(substring string) string { replacement, err := cfg.replacementFunc(substring, mapping, cfg) if err != nil { // Add the template for template errors @@ -156,12 +206,9 @@ func DefaultReplacementFunc(substring string, mapping Mapping, cfg *Config) (str return value, err } -func DefaultReplacementAppliedFunc(substring string, mapping Mapping, cfg *Config) (string, bool, error) { +func DefaultReplacementAppliedFunc(substring string, mapping Mapping, cfg *Config) (value string, applied bool, err error) { + template := substring pattern := cfg.pattern - subsFunc := cfg.substituteFunc - if subsFunc == nil { - _, subsFunc = getSubstitutionFunctionForTemplate(substring) - } closingBraceIndex := getFirstBraceClosingIndex(substring) rest := "" @@ -176,37 +223,50 @@ func DefaultReplacementAppliedFunc(substring string, mapping Mapping, cfg *Confi return escaped, true, nil } - braced := false - substitution := groups[groupNamed] - if substitution == "" { + substitution := "" + subsFunc := cfg.substituteFunc + switch { + case groups[groupNamed] != "": + substitution = groups[groupNamed] + case groups[groupBraced] != "": substitution = groups[groupBraced] - braced = true - } - - if substitution == "" { + if subsFunc == nil { + _, subsFunc = getSubstitutionFunctionForTemplate(template, cfg) + } + case groups[groupMapping] != "": + substitution = groups[groupMapping] + if subsFunc == nil { + subsFunc = getSubstitutionFunctionForNamedMapping(cfg) + } + default: return "", false, &InvalidTemplateError{} } - if braced { - value, applied, err := subsFunc(substitution, mapping) + if subsFunc != nil { + value, applied, err = subsFunc(substitution, mapping) if err != nil { return "", false, err } - if applied { - interpolatedNested, err := SubstituteWith(rest, mapping, pattern) - if err != nil { - return "", false, err - } - return value + interpolatedNested, true, nil + if !applied { + value = substring // Keep the original substring ${...} if not applied + } + } else { + value, applied = mapping(substitution) + if !applied && cfg.logging { + logrus.Warnf("The %q variable is not set. Defaulting to a blank string.", substitution) } } - value, ok := mapping(substitution) - if !ok && cfg.logging { - logrus.Warnf("The %q variable is not set. Defaulting to a blank string.", substitution) + if rest != "" { + interpolatedNested, err := substituteWithConfig(rest, mapping, cfg) + applied = applied || rest != interpolatedNested + if err != nil { + return "", false, err + } + value += interpolatedNested } - return value, ok, nil + return value, applied, nil } // SubstituteWith substitute variables in the string with their values. @@ -222,10 +282,10 @@ func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, su return SubstituteWithOptions(template, mapping, options...) } -func getSubstitutionFunctionForTemplate(template string) (string, SubstituteFunc) { +func getSubstitutionFunctionForTemplate(template string, cfg *Config) (string, SubstituteFunc) { interpolationMapping := []struct { - string - SubstituteFunc + SubstituteType string + SubstituteFunc func(string, Mapping, *Config) (string, bool, error) }{ {":?", requiredErrorWhenEmptyOrUnset}, {"?", requiredErrorWhenUnset}, @@ -234,9 +294,22 @@ func getSubstitutionFunctionForTemplate(template string) (string, SubstituteFunc {":+", defaultWhenNotEmpty}, {"+", defaultWhenSet}, } + + mappingIndices := make(map[string]int) + hasInterpolationMapping := false + for _, m := range interpolationMapping { + mappingIndices[m.SubstituteType] = strings.Index(template, m.SubstituteType) + if mappingIndices[m.SubstituteType] >= 0 { + hasInterpolationMapping = true + } + } + if !hasInterpolationMapping { + return "", nil + } + sort.Slice(interpolationMapping, func(i, j int) bool { - idxI := strings.Index(template, interpolationMapping[i].string) - idxJ := strings.Index(template, interpolationMapping[j].string) + idxI := mappingIndices[interpolationMapping[i].SubstituteType] + idxJ := mappingIndices[interpolationMapping[j].SubstituteType] if idxI < 0 { return false } @@ -246,7 +319,95 @@ func getSubstitutionFunctionForTemplate(template string) (string, SubstituteFunc return idxI < idxJ }) - return interpolationMapping[0].string, interpolationMapping[0].SubstituteFunc + return interpolationMapping[0].SubstituteType, func(s string, m Mapping) (string, bool, error) { + return interpolationMapping[0].SubstituteFunc(s, m, cfg) + } +} + +func getSubstitutionFunctionForNamedMapping(cfg *Config) SubstituteFunc { + return func(substitution string, mapping Mapping) (string, bool, error) { + namedMapping, key, rest, err := getNamedMapping(substitution, cfg) + if err != nil || namedMapping == nil { + return "", false, err + } + + resolvedKey, err := getResolvedNamedMappingKey(key, mapping, cfg) + if err != nil { + return "", false, err + } + + // If subsitution function found, delegate substitution string (with key resolved) to it + if rest != "" { + subsType, subsFunc := getSubstitutionFunctionForTemplate(rest, cfg) + if subsType == "" { + return "", false, &InvalidTemplateError{Template: substitution} + } + substitution := strings.Replace(substitution, key, resolvedKey, 1) + value, applied, err := subsFunc(substitution, mapping) + if applied || err != nil { + return value, applied, err + } + } + + value, _ := namedMapping(resolvedKey) + return value, true, nil + } +} + +func getNamedMapping(substitution string, cfg *Config) (Mapping, string, string, error) { + if cfg.namedMappings == nil { // Named mappings not enabled + return nil, "", "", nil + } + + openBracketIndex := -1 + closeBracketIndex := -1 + openBrackets := 0 + for i := 0; i < len(substitution); i++ { + if substitution[i] == '[' { + if openBrackets == 0 { + openBracketIndex = i + } + openBrackets += 1 + } else if substitution[i] == ']' { + openBrackets -= 1 + if openBrackets == 0 { + closeBracketIndex = i + } + if openBrackets <= 0 { + break + } + } + } + if openBracketIndex < 0 || closeBracketIndex < 0 { + return nil, "", "", nil + } + name := substitution[0:openBracketIndex] + key := substitution[openBracketIndex+1 : closeBracketIndex] + rest := substitution[closeBracketIndex+1:] + + namedMapping, ok := cfg.namedMappings[name] + if !ok { // When namd mappings config provided, it must be able to resolve all mapping names in the template + return nil, "", "", &MissingNamedMappingError{Name: name} + } + + return namedMapping, key, rest, nil +} + +func getResolvedNamedMappingKey(key string, mapping Mapping, cfg *Config) (string, error) { + resolvedKey, err := substituteWithConfig(key, mapping, cfg) + if err != nil { + return "", err + } + + if !NamedMappingKeyPattern.MatchString(resolvedKey) { + if resolvedKey != key { + return "", fmt.Errorf("invalid key in named mapping: %q (resolved to %q)", key, resolvedKey) + } else { + return "", fmt.Errorf("invalid key in named mapping: %q", key) + } + } + + return resolvedKey, nil } func getFirstBraceClosingIndex(s string) int { @@ -272,32 +433,32 @@ func Substitute(template string, mapping Mapping) (string, error) { } // Soft default (fall back if unset or empty) -func defaultWhenEmptyOrUnset(substitution string, mapping Mapping) (string, bool, error) { - return withDefaultWhenAbsence(substitution, mapping, true) +func defaultWhenEmptyOrUnset(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withDefaultWhenAbsence(substitution, mapping, cfg, true) } // Hard default (fall back if-and-only-if empty) -func defaultWhenUnset(substitution string, mapping Mapping) (string, bool, error) { - return withDefaultWhenAbsence(substitution, mapping, false) +func defaultWhenUnset(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withDefaultWhenAbsence(substitution, mapping, cfg, false) } -func defaultWhenNotEmpty(substitution string, mapping Mapping) (string, bool, error) { - return withDefaultWhenPresence(substitution, mapping, true) +func defaultWhenNotEmpty(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withDefaultWhenPresence(substitution, mapping, cfg, true) } -func defaultWhenSet(substitution string, mapping Mapping) (string, bool, error) { - return withDefaultWhenPresence(substitution, mapping, false) +func defaultWhenSet(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withDefaultWhenPresence(substitution, mapping, cfg, false) } -func requiredErrorWhenEmptyOrUnset(substitution string, mapping Mapping) (string, bool, error) { - return withRequired(substitution, mapping, ":?", func(v string) bool { return v != "" }) +func requiredErrorWhenEmptyOrUnset(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withRequired(substitution, mapping, cfg, ":?", func(v string) bool { return v != "" }) } -func requiredErrorWhenUnset(substitution string, mapping Mapping) (string, bool, error) { - return withRequired(substitution, mapping, "?", func(_ string) bool { return true }) +func requiredErrorWhenUnset(substitution string, mapping Mapping, cfg *Config) (string, bool, error) { + return withRequired(substitution, mapping, cfg, "?", func(_ string) bool { return true }) } -func withDefaultWhenPresence(substitution string, mapping Mapping, notEmpty bool) (string, bool, error) { +func withDefaultWhenPresence(substitution string, mapping Mapping, cfg *Config, notEmpty bool) (value string, ok bool, err error) { sep := "+" if notEmpty { sep = ":+" @@ -306,18 +467,29 @@ func withDefaultWhenPresence(substitution string, mapping Mapping, notEmpty bool return "", false, nil } name, defaultValue := partition(substitution, sep) - defaultValue, err := Substitute(defaultValue, mapping) + defaultValue, err = substituteWithConfig(defaultValue, mapping, cfg) + if err != nil { + return "", false, err + } + namedMapping, key, rest, err := getNamedMapping(name, cfg) if err != nil { return "", false, err } - value, ok := mapping(name) + if rest != "" { + return "", false, &InvalidTemplateError{Template: substitution} + } + if namedMapping != nil { + value, ok = namedMapping(key) + } else { + value, ok = mapping(name) + } if ok && (!notEmpty || (notEmpty && value != "")) { return defaultValue, true, nil } return value, true, nil } -func withDefaultWhenAbsence(substitution string, mapping Mapping, emptyOrUnset bool) (string, bool, error) { +func withDefaultWhenAbsence(substitution string, mapping Mapping, cfg *Config, emptyOrUnset bool) (value string, ok bool, err error) { sep := "-" if emptyOrUnset { sep = ":-" @@ -326,27 +498,49 @@ func withDefaultWhenAbsence(substitution string, mapping Mapping, emptyOrUnset b return "", false, nil } name, defaultValue := partition(substitution, sep) - defaultValue, err := Substitute(defaultValue, mapping) + defaultValue, err = substituteWithConfig(defaultValue, mapping, cfg) + if err != nil { + return "", false, err + } + namedMapping, key, rest, err := getNamedMapping(name, cfg) if err != nil { return "", false, err } - value, ok := mapping(name) + if rest != "" { + return "", false, &InvalidTemplateError{Template: substitution} + } + if namedMapping != nil { + value, ok = namedMapping(key) + } else { + value, ok = mapping(name) + } if !ok || (emptyOrUnset && value == "") { return defaultValue, true, nil } return value, true, nil } -func withRequired(substitution string, mapping Mapping, sep string, valid func(string) bool) (string, bool, error) { +func withRequired(substitution string, mapping Mapping, cfg *Config, sep string, valid func(string) bool) (value string, ok bool, err error) { if !strings.Contains(substitution, sep) { return "", false, nil } name, errorMessage := partition(substitution, sep) - errorMessage, err := Substitute(errorMessage, mapping) + errorMessage, err = substituteWithConfig(errorMessage, mapping, cfg) + if err != nil { + return "", false, err + } + namedMapping, key, rest, err := getNamedMapping(name, cfg) if err != nil { return "", false, err } - value, ok := mapping(name) + if rest != "" { + return "", false, &InvalidTemplateError{Template: substitution} + } + if namedMapping != nil { + value, ok = namedMapping(key) + } else { + value, ok = mapping(name) + } if !ok || !valid(value) { return "", true, &MissingRequiredError{ Reason: errorMessage, @@ -375,3 +569,38 @@ func partition(s, sep string) (string, string) { } return s, "" } + +// Merge stacks new mapping on top of current mapping, i.e. the merged mapping will: +// 1. Lookup in current mapping first +// 2. If not present in current mapping, then lookup in provided mapping +func (m Mapping) Merge(other Mapping) Mapping { + return func(key string) (string, bool) { + if value, ok := m(key); ok { + return value, ok + } + return other(key) + } +} + +func (m NamedMappings) Merge(other NamedMappings) NamedMappings { + if m == nil { + return other + } + if other == nil { + return m + } + merged := make(NamedMappings) + for name, mapping := range m { + if otherMapping, ok := other[name]; ok { + merged[name] = mapping.Merge(otherMapping) + } else { + merged[name] = mapping + } + } + for name, mapping := range other { + if _, ok := merged[name]; !ok { + merged[name] = mapping + } + } + return merged +} diff --git a/template/template_test.go b/template/template_test.go index c52fdcd9..6e756bdd 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -19,6 +19,7 @@ package template import ( "fmt" "reflect" + "strings" "testing" "gotest.tools/v3/assert" @@ -506,7 +507,7 @@ func TestSubstitutionFunctionChoice(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - symbol, _ := getSubstitutionFunctionForTemplate(tc.input) + symbol, _ := getSubstitutionFunctionForTemplate(tc.input, nil) assert.Equal(t, symbol, tc.symbol, fmt.Sprintf("Wrong on output for: %s got symbol -> %#v", tc.input, symbol), ) @@ -529,3 +530,461 @@ func TestValueWithCurlyBracesDefault(t *testing.T) { assert.Check(t, is.Equal(`ok {"json":2}`, result)) } } + +func envMapping(name string) (string, bool) { + return defaultMapping(name) +} + +func TestEscapedWithNamedMappings(t *testing.T) { + result, err := SubstituteWithOptions("$${foo[bar]}", defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal("${foo[bar]}", result)) +} + +func TestInvalidWithNamedMappings(t *testing.T) { + invalidTemplates := []string{ + "${[]", + "${[]}", + "${ [ ] }", + "${ foo[bar]}", + "${foo[bar] }", + "${foo [bar] }", + "${foo [bar }", + "${foo bar]}", + "${$FOO[bar]}", // Cannot use interpolation for named mapping's name + "${FOO_${foo[bar]}}", // Cannot use named mapping for environment variable + "${foo[bar]a:-def}", // Cannot present characters (`a`) between closing bracket `]` and substitution function `:-` + } + + for _, template := range invalidTemplates { + _, err := SubstituteWithOptions(template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.ErrorContains(t, err, "Invalid template") + } + + invalidMappings := NamedMappings{ + "FOO": func(name string) (string, bool) { return "invalid]", true }, + } + testCases := []struct { + template string + expectedError string + }{ + { + template: "${FOO[~invalid]}", + expectedError: `invalid key in named mapping: "~invalid"`, + }, + { + template: "${FOO[${FOO[invalid]}]}", + expectedError: `invalid key in named mapping: "${FOO[invalid]}" (resolved to "invalid]")`, + }, + { + template: "${FOO[$$]}", + expectedError: `invalid key in named mapping: "$$" (resolved to "$")`, + }, + { + template: "${FOO[$$FOO]}", + expectedError: `invalid key in named mapping: "$$FOO" (resolved to "$FOO")`, + }, + { + template: "${FOO[$${FOO}]}", + expectedError: `invalid key in named mapping: "$${FOO}" (resolved to "${FOO}")`, + }, + { + template: "${FOO[${FOO}~invalid]}", + expectedError: `invalid key in named mapping: "${FOO}~invalid" (resolved to "first~invalid")`, + }, + { + template: "${FOO[${BAR[UNSET]}valid]}", + expectedError: `named mapping not found: "BAR"`, + }, + } + for _, tc := range testCases { + _, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(invalidMappings)) + assert.ErrorContains(t, err, tc.expectedError) + } +} + +func TestNonBracedWithNamedMappings(t *testing.T) { + substituted, err := SubstituteWithOptions("$foo[bar]-bar", defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Equal(t, substituted, "[bar]-bar", "Named mappings bust be wrapped in brace to work. Expected [bar]-bar, got %s", substituted) +} + +func TestNoValueNoDefaultWithNamedMappingsDisabled(t *testing.T) { + // NOTE: Named mappings disabled, and `missing[foo]/FOO[BAR]` is not valid environment variable names + // So they will be treated as literal strings and not be substituted + for _, template := range []string{"This ${missing[foo]} var", "This ${FOO[BAR]} var"} { + result, err := Substitute(template, defaultMapping) + assert.NilError(t, err) + assert.Check(t, is.Equal(template, result)) + } +} + +func TestNoValueNoDefaultWithNamedMappings(t *testing.T) { + // NOTE: ${FOO[BAR]} will lookup named mapping `FOO`, instead of environment variable `FOO` + // and ${missing[foo]} will lookup named mapping `missing`, which does not exist and error will be thrown + for _, template := range []string{"This ${missing[foo]} var", "This ${FOO[BAR]} var"} { + result, err := SubstituteWithOptions(template, defaultMapping, WithNamedMappings(NamedMappings{"FOO": envMapping})) + if err != nil { + assert.ErrorContains(t, err, fmt.Sprintf("named mapping not found: %q", "missing")) + assert.ErrorType(t, err, reflect.TypeOf(&MissingNamedMappingError{})) + } else { + assert.Check(t, is.Equal("This var", result)) + } + } +} + +func TestValueNoDefaultWithNamedMappings(t *testing.T) { + // NOTE: In first case, $FOO still looks up environment variable `FOO`, + // while in second case, ${FOO[FOO]} will lookup named mapping `FOO`, which reuses defaultMapping to continue looking up environment variable `FOO` + for _, template := range []string{"This $FOO var", "This ${FOO[FOO]} var"} { + result, err := SubstituteWithOptions(template, defaultMapping, WithNamedMappings(NamedMappings{"FOO": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal("This first var", result)) + } +} + +func TestChainedSubstitutionFunctionWithNamedMappings(t *testing.T) { + testCases := []struct { + name string + template string + expected string + }{ + { + template: "ok ${env[BAR]:-def}", // EmptyValueWithSoftDefault + expected: "ok def", + }, + { + template: "ok ${env[FOO]:-def}", // ValueWithSoftDefault + expected: "ok first", + }, + { + template: "ok ${env[BAR]-def}", // EmptyValueWithHardDefault + expected: "ok ", + }, + { + template: "ok ${env[UNSET_VAR]:+presence_value}", // PresentValueWithUnset + expected: "ok ", + }, + { + template: "ok ${env[UNSET_VAR]+presence_value}", // PresentValueWithUnset2 + expected: "ok ", + }, + { + template: "ok ${env[FOO]:+presence_value}", // PresentValueWithNonEmpty + expected: "ok presence_value", + }, + { + template: "ok ${env[FOO]+presence_value}", // PresentValueAndNonEmptyWithNonEmpty + expected: "ok presence_value", + }, + { + template: "ok ${env[BAR]+presence_value}", // PresentValueWithSet + expected: "ok presence_value", + }, + { + template: "ok ${env[BAR]:+presence_value}", // PresentValueAndNotEmptyWithSet + expected: "ok ", + }, + { + template: "ok ${env[BAR]:-/non:-alphanumeric}", // NonAlphanumericDefault + expected: "ok /non:-alphanumeric", + }, + } + for i, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("Named mapping should be able to be used with subsitution functions: %d", i), func(t *testing.T) { + result, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal(tc.expected, result)) + }) + } +} + +func TestInterpolationExternalInterferenceWithNamedMappings(t *testing.T) { + testCases := []struct { + name string + template string + expected string + }{ + { + template: "[ok] ${env[UNSET]}", + expected: "[ok] ", + }, + { + template: "[ok] ${env[FOO]}", + expected: "[ok] first", + }, + { + template: "[ok] ${env[FOO]} ${env[${BAR:-FOO}]}", + expected: "[ok] first first", // Properly handled with `getFirstBraceClosingIndex` + }, + { + template: "aaa-${env[${env[${UNSET:-$FOO}]}]}?-:${env[${BAR:-FOO}]}}-${BAR:-test${UNSET:-$FOO}}:-bbb", + expected: "aaa-?-:first}-testfirst:-bbb", + }, + { + template: "aaa-${env[${env[${UNSET:-$FOO}]}]}?-:${env[${BAR:-FOO}]}}-${BAR:-test${UNSET:-${env[FOO]}}}:-bbb", // The beginning `${env[...` and `...]}}}` will and shall be matched + expected: "aaa-?-:first}-testfirst:-bbb", + }, + { + template: "aaa-${env[${env[${UNSET:-$FOO}]}]}?-:${env[${BAR:-FOO}]}}-${BAR:-test${UNSET:-${env[BAR]:-$FOO}}}:-bbb", + expected: "aaa-?-:first}-testfirst:-bbb", + }, + { + template: "aaa-${env[${env[${UNSET:-$FOO}]}]}?-:${env[${BAR:-FOO}]}}-${BAR:-test${UNSET:-${env[BAR]:-${env[FOO]}}}}:-bbb", + expected: "aaa-?-:first}-testfirst:-bbb", + }, + } + for i, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("Interpolation Should not be impacted by outer text: %d", i), func(t *testing.T) { + result, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal(tc.expected, result)) + }) + } +} + +func TestDefaultsWithNestedExpansionWithNamedMappings(t *testing.T) { + testCases := []struct { + template string + expected string + }{ + { + template: "ok ${env[${UNSET_VAR-FOO}]}", + expected: "ok first", + }, + { + template: "ok ${UNSET_VAR-${env[FOO]}}", + expected: "ok first", + }, + { + template: "ok ${env[UNSET_VAR]-${env[FOO]}}", + expected: "ok first", + }, + { + template: "ok ${env[UNSET_VAR]-${FOO} ${FOO}}", + expected: "ok first first", + }, + { + template: "ok ${env[UNSET_VAR]-${env[FOO]} ${env[FOO]}}", + expected: "ok first first", + }, + { + template: "ok ${env[BAR]+$FOO}", + expected: "ok first", + }, + { + template: "ok ${env[BAR]+${env[FOO]}}", + expected: "ok first", + }, + { + template: "ok ${env[BAR]+${env[FOO]} ${env[FOO]:+second}}", + expected: "ok first second", + }, + { + template: "ok ${env[${env[FOO]+BAR}]+${env[FOO]+${env[BAR]:-firstPresence}} ${env[FOO]:+second}}", + expected: "ok firstPresence second", + }, + } + + for _, tc := range testCases { + result, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal(tc.expected, result)) + } +} + +func TestMandatoryVariableErrorsWithNamedMappings(t *testing.T) { + testCases := []struct { + template string + expectedError string + }{ + { + template: "not ok ${env[UNSET_VAR]:?Mandatory Variable Unset}", + expectedError: "required variable env[UNSET_VAR] is missing a value: Mandatory Variable Unset", + }, + { + template: "not ok ${env[BAR]:?Mandatory Variable Empty}", + expectedError: "required variable env[BAR] is missing a value: Mandatory Variable Empty", + }, + { + template: "not ok ${env[UNSET_VAR]:?}", + expectedError: "required variable env[UNSET_VAR] is missing a value", + }, + { + template: "not ok ${env[UNSET_VAR]?Mandatory Variable Unset}", + expectedError: "required variable env[UNSET_VAR] is missing a value: Mandatory Variable Unset", + }, + { + template: "not ok ${env[UNSET_VAR]?}", + expectedError: "required variable env[UNSET_VAR] is missing a value", + }, + } + + for _, tc := range testCases { + _, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.ErrorContains(t, err, tc.expectedError) + assert.ErrorType(t, err, reflect.TypeOf(&MissingRequiredError{})) + } +} + +func TestMandatoryVariableErrorsWithNestedExpansionWithNamedMappings(t *testing.T) { + testCases := []struct { + template string + expectedError string + }{ + { + template: "not ok ${env[UNSET_VAR]:?Mandatory Variable ${env[FOO]}}", + expectedError: "required variable env[UNSET_VAR] is missing a value: Mandatory Variable first", + }, + { + template: "not ok ${env[UNSET_VAR]?Mandatory Variable ${env[FOO]}}", + expectedError: "required variable env[UNSET_VAR] is missing a value: Mandatory Variable first", + }, + } + + for _, tc := range testCases { + _, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.ErrorContains(t, err, tc.expectedError) + assert.ErrorType(t, err, reflect.TypeOf(&MissingRequiredError{})) + } +} + +func TestDefaultsForMandatoryVariablesWithNamedMappings(t *testing.T) { + testCases := []struct { + template string + expected string + }{ + { + template: "ok ${env[FOO]:?err}", + expected: "ok first", + }, + { + template: "ok ${env[FOO]?err}", + expected: "ok first", + }, + { + template: "ok ${env[BAR]?err}", + expected: "ok ", + }, + } + + for _, tc := range testCases { + result, err := SubstituteWithOptions(tc.template, defaultMapping, WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal(tc.expected, result)) + } +} + +func TestSubstituteWithCustomFuncWithNamedMappings(t *testing.T) { + errIsMissing := func(substitution string, mapping Mapping) (string, bool, error) { + var value string + var found bool + if strings.HasPrefix(substitution, "env[") { + substitution = strings.TrimSuffix(strings.TrimPrefix(substitution, "env["), "]") + value, found = envMapping(substitution) + } + if !found { + value, found = mapping(substitution) + } + if !found { + return "", true, &InvalidTemplateError{ + Template: fmt.Sprintf("required variable %s is missing a value", substitution), + } + } + return value, true, nil + } + + result, err := SubstituteWithOptions("ok ${env[FOO]}", defaultMapping, + WithPattern(DefaultPattern), WithSubstitutionFunction(errIsMissing), WithNamedMappings(NamedMappings{"env": envMapping})) + assert.NilError(t, err) + assert.Check(t, is.Equal("ok first", result)) + + result, err = SubstituteWith("ok ${BAR}", defaultMapping, DefaultPattern, errIsMissing) + assert.NilError(t, err) + assert.Check(t, is.Equal("ok ", result)) + + _, err = SubstituteWith("ok ${NOTHERE}", defaultMapping, DefaultPattern, errIsMissing) + assert.Check(t, is.ErrorContains(err, "required variable")) +} + +func TestPanicAsErrorInNamedMappings(t *testing.T) { + panicMapping := func(name string) (string, bool) { + panic("panic") + } + _, err := SubstituteWithOptions("${env[FOO]}", defaultMapping, WithNamedMappings(NamedMappings{"env": panicMapping})) + assert.ErrorContains(t, err, "panic") +} + +func TestMergeMappings(t *testing.T) { + mapping1 := Mapping(func(key string) (string, bool) { + if key == "FOO" { + return "first", true + } + return "", false + }) + mapping2 := Mapping(func(key string) (string, bool) { + if key == "FOO" { + return "first_shadowed", true + } + if key == "BAR" { + return "second", true + } + return "", false + }) + meregedMapping := mapping1.Merge(mapping2) + result, ok := meregedMapping("FOO") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "first")) // mapping1 should take precedence + result, ok = meregedMapping("BAR") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "second")) +} + +func TestMergeNamedMappings(t *testing.T) { + namedMappings1 := NamedMappings{ + "env": Mapping(func(key string) (string, bool) { + if key == "FOO" { + return "first", true + } + return "", false + }), + "secret": Mapping(func(key string) (string, bool) { + if key == "access_key" { + return "access_key_value", true + } + return "", false + }), + } + namedMappings2 := NamedMappings{ + "env": Mapping(func(key string) (string, bool) { + if key == "FOO" { + return "first_shadowed", true + } + if key == "BAR" { + return "second", true + } + return "", false + }), + "labels": Mapping(func(key string) (string, bool) { + if key == "label" { + return "value", true + } + return "", false + }), + } + mergedNamedMappings := namedMappings1.Merge(namedMappings2) + result, ok := mergedNamedMappings["env"]("FOO") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "first")) // namedMappings1 should take precedence + result, ok = mergedNamedMappings["env"]("BAR") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "second")) + result, ok = mergedNamedMappings["secret"]("access_key") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "access_key_value")) + result, ok = mergedNamedMappings["labels"]("label") + assert.Check(t, is.Equal(ok, true)) + assert.Check(t, is.Equal(result, "value")) +}