Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(placeholders): refactor matching capture and add regression test #42

Merged
merged 1 commit into from
Jul 9, 2024
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
57 changes: 45 additions & 12 deletions framework/substitution.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (

var (
// placeholderRegEx will search for ${...} with any sequence of characters between them.
placeholderRegEx = regexp.MustCompile(`\$(\$|{([^}]*)})`)
placeholderRegEx = regexp.MustCompile(`\$((?:\$?{([^}]*)})|\$)`)
)

func SplitRefParts(ref string) []string {
Expand All @@ -36,9 +36,24 @@ func SplitRefParts(ref string) []string {
return parts
}

// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
// from the inner function. Double $'s are unescaped.
func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
// A Substituter is a type that supports substitutions of $-sign placeholders in strings. This detects and replaces
// patterns like: fizz ${var} buzz while supporting custom un-escaping of patterns like $$ and $${var}. The Replacer
// function is _required_ and the substituter will not function without it, but the UnEscaper is optional and will
// default to simply replacing sequences of $$ with a $.
// Overriding the UnEscaper may be necessary if non default behavior is required.
type Substituter struct {
Replacer func(string) (string, error)
UnEscaper func(string) (string, error)
}

func DefaultUnEscaper(original string) (string, error) {
return original[1:], nil
}

func (s *Substituter) SubstituteString(src string) (string, error) {
if s.Replacer == nil {
return "", errors.New("replacer function is nil")
}
var err error
result := placeholderRegEx.ReplaceAllStringFunc(src, func(str string) string {
// WORKAROUND: ReplaceAllStringFunc(..) does not provide match details
Expand All @@ -52,29 +67,36 @@ func SubstituteString(src string, inner func(string) (string, error)) (string, e
}

// support escaped dollars
if matches[1] == "$" {
return matches[1]
if strings.HasPrefix(matches[1], "$") {
ue := DefaultUnEscaper
if s.UnEscaper != nil {
ue = s.UnEscaper
}
res, subErr := ue(matches[0])
if subErr != nil {
err = errors.Join(err, fmt.Errorf("failed to unescape '%s': %w", matches[0], subErr))
}
return res
}

result, subErr := inner(matches[2])
result, subErr := s.Replacer(matches[2])
err = errors.Join(err, subErr)
return result
})
return result, err
}

// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
func (s *Substituter) Substitute(source interface{}) (interface{}, error) {
if source == nil {
return nil, nil
}
switch v := source.(type) {
case string:
return SubstituteString(v, inner)
return s.SubstituteString(v)
case map[string]interface{}:
out := make(map[string]interface{}, len(v))
for k, v := range v {
v2, err := Substitute(v, inner)
v2, err := s.Substitute(v)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
Expand All @@ -84,7 +106,7 @@ func Substitute(source interface{}, inner func(string) (string, error)) (interfa
case []interface{}:
out := make([]interface{}, len(v))
for i, i2 := range v {
i3, err := Substitute(i2, inner)
i3, err := s.Substitute(i2)
if err != nil {
return nil, fmt.Errorf("%d: %w", i, err)
}
Expand All @@ -96,6 +118,17 @@ func Substitute(source interface{}, inner func(string) (string, error)) (interfa
}
}

// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
// from the inner function. Double $'s are unescaped using DefaultUnEscaper.
func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
return (&Substituter{Replacer: inner, UnEscaper: DefaultUnEscaper}).SubstituteString(src)
}

// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
return (&Substituter{Replacer: inner, UnEscaper: DefaultUnEscaper}).Substitute(source)
}

func mapLookupOutput(ctx map[string]interface{}) func(keys ...string) (interface{}, error) {
return func(keys ...string) (interface{}, error) {
var resolvedValue interface{}
Expand Down
22 changes: 22 additions & 0 deletions framework/substitution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func TestSubstituteString(t *testing.T) {
{Input: "$abc", Expected: "$abc"},
{Input: "abc $$ abc", Expected: "abc $ abc"},
{Input: "$${abc}", Expected: "${abc}"},
{Input: "$$${abc}", ExpectedError: "invalid ref 'abc': unknown reference root, use $$ to escape the substitution"},
{Input: "$$$${abc}", Expected: "$${abc}"},
{Input: "$$$$${abc}", ExpectedError: "invalid ref 'abc': unknown reference root, use $$ to escape the substitution"},
{Input: "$${abc .4t3298y *(^&(*}", Expected: "${abc .4t3298y *(^&(*}"},
{Input: "my name is ${metadata.name}", Expected: "my name is test-name"},
{Input: "my name is ${metadata.thing\\.two}", ExpectedError: "invalid ref 'metadata.thing\\.two': key 'thing.two' not found"},
Expand Down Expand Up @@ -161,3 +164,22 @@ func TestSubstituteMap_fail(t *testing.T) {
}, substitutionFunction)
assert.EqualError(t, err, "a: 0: b: invalid ref 'metadata.unknown': key 'unknown' not found")
}

func TestCustomSubstituter_nil(t *testing.T) {
s := new(Substituter)
_, err := s.SubstituteString("${fizz}")
assert.EqualError(t, err, "replacer function is nil")
}

func TestCustomerUnescaper(t *testing.T) {
s := new(Substituter)
s.Replacer = func(s string) (string, error) {
return strings.ToUpper(s), nil
}
s.UnEscaper = func(s string) (string, error) {
return strings.Repeat(s, 2), nil
}
x, err := s.SubstituteString("$$ $${thing}")
assert.NoError(t, err)
assert.Equal(t, "$$$$ $${thing}$${thing}", x)
}
Loading