Repository: compose-spec/compose-go
Pull request #418 (2023-06-16 17:18:01)

Add substitution with options
This PR introduces the `SubstituteWithOptions()` function including options for custom patterns, custom substitution functions and a **new** replacement function for allowing to customize the replacement behavior if e.g. no mapping was found.
The existing `SubstituteWith` function is switched over to the newly introduced function.


Patch:
diff --git a/go.mod b/go.mod
index fc0730d..eca6d13 100644
--- a/go.mod
+++ b/go.mod
@@ -15,7 +15,7 @@ require (
 	github.com/sirupsen/logrus v1.9.0
 	github.com/stretchr/testify v1.8.4
 	github.com/xeipuuv/gojsonschema v1.2.0
-	golang.org/x/sync v0.3.0
+	golang.org/x/sync v0.2.0
 	gopkg.in/yaml.v3 v3.0.1
 	gotest.tools/v3 v3.4.0
 )
diff --git a/go.sum b/go.sum
index 7d64833..fa7b156 100644
--- a/go.sum
+++ b/go.sum
@@ -47,8 +47,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
+golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/template/template.go b/template/template.go
index 3d548e0..141b56d 100644
--- a/template/template.go
+++ b/template/template.go
@@ -17,6 +17,7 @@
 package template
 
 import (
+	"errors"
 	"fmt"
 	"regexp"
 	"sort"
@@ -71,77 +72,143 @@ type Mapping func(string) (string, bool)
 // the substitution and an error.
 type SubstituteFunc func(string, Mapping) (string, bool, error)
 
-// SubstituteWith substitute variables in the string with their values.
-// It accepts additional substitute function.
-func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) {
-	var outerErr error
-	var returnErr error
+// ReplacementFunc is a user-supplied function that is apply to the matching
+// substring. Returns the value as a string and an error.
+type ReplacementFunc func(string, Mapping, *Config) (string, error)
 
-	result := pattern.ReplaceAllStringFunc(template, func(substring string) string {
-		_, subsFunc := getSubstitutionFunctionForTemplate(substring)
-		if len(subsFuncs) > 0 {
-			subsFunc = subsFuncs[0]
-		}
+type Config struct {
+	pattern         *regexp.Regexp
+	substituteFunc  SubstituteFunc
+	replacementFunc ReplacementFunc
+	logging         bool
+}
 
-		closingBraceIndex := getFirstBraceClosingIndex(substring)
-		rest := ""
-		if closingBraceIndex > -1 {
-			rest = substring[closingBraceIndex+1:]
-			substring = substring[0 : closingBraceIndex+1]
-		}
+type Option func(*Config)
 
-		matches := pattern.FindStringSubmatch(substring)
-		groups := matchGroups(matches, pattern)
-		if escaped := groups["escaped"]; escaped != "" {
-			return escaped
-		}
+func WithPattern(pattern *regexp.Regexp) Option {
+	return func(cfg *Config) {
+		cfg.pattern = pattern
+	}
+}
 
-		braced := false
-		substitution := groups["named"]
-		if substitution == "" {
-			substitution = groups["braced"]
-			braced = true
-		}
+func WithSubstitutionFunction(subsFunc SubstituteFunc) Option {
+	return func(cfg *Config) {
+		cfg.substituteFunc = subsFunc
+	}
+}
 
-		if substitution == "" {
-			outerErr = &InvalidTemplateError{Template: template}
-			if returnErr == nil {
-				returnErr = outerErr
-			}
-			return ""
-		}
+func WithReplacementFunction(replacementFunc ReplacementFunc) Option {
+	return func(cfg *Config) {
+		cfg.replacementFunc = replacementFunc
+	}
+}
+
+func WithoutLogging(cfg *Config) {
+	cfg.logging = false
+}
 
-		if braced {
-			var (
-				value   string
-				applied bool
-			)
-			value, applied, outerErr = subsFunc(substitution, mapping)
-			if outerErr != nil {
-				if returnErr == nil {
-					returnErr = outerErr
+// 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,
+		logging:         true,
+	}
+	for _, o := range options {
+		o(cfg)
+	}
+
+	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
+			var tmplErr *InvalidTemplateError
+			if errors.As(err, &tmplErr) {
+				if tmplErr.Template == "" {
+					tmplErr.Template = template
 				}
-				return ""
 			}
-			if applied {
-				interpolatedNested, err := SubstituteWith(rest, mapping, pattern)
-				if err != nil {
-					return ""
-				}
-				return value + interpolatedNested
+			// Save the first error to be returned
+			if returnErr == nil {
+				returnErr = err
 			}
-		}
 
-		value, ok := mapping(substitution)
-		if !ok {
-			logrus.Warnf("The %q variable is not set. Defaulting to a blank string.", substitution)
 		}
-		return value
+		return replacement
 	})
 
 	return result, returnErr
 }
 
+func DefaultReplacementFunc(substring string, mapping Mapping, cfg *Config) (string, error) {
+	pattern := cfg.pattern
+	subsFunc := cfg.substituteFunc
+	if subsFunc == nil {
+		_, subsFunc = getSubstitutionFunctionForTemplate(substring)
+	}
+
+	closingBraceIndex := getFirstBraceClosingIndex(substring)
+	rest := ""
+	if closingBraceIndex > -1 {
+		rest = substring[closingBraceIndex+1:]
+		substring = substring[0 : closingBraceIndex+1]
+	}
+
+	matches := pattern.FindStringSubmatch(substring)
+	groups := matchGroups(matches, pattern)
+	if escaped := groups["escaped"]; escaped != "" {
+		return escaped, nil
+	}
+
+	braced := false
+	substitution := groups["named"]
+	if substitution == "" {
+		substitution = groups["braced"]
+		braced = true
+	}
+
+	if substitution == "" {
+		return "", &InvalidTemplateError{}
+	}
+
+	if braced {
+		value, applied, err := subsFunc(substitution, mapping)
+		if err != nil {
+			return "", err
+		}
+		if applied {
+			interpolatedNested, err := SubstituteWith(rest, mapping, pattern)
+			if err != nil {
+				return "", err
+			}
+			return value + interpolatedNested, nil
+		}
+	}
+
+	value, ok := mapping(substitution)
+	if !ok && cfg.logging {
+		logrus.Warnf("The %q variable is not set. Defaulting to a blank string.", substitution)
+	}
+
+	return value, nil
+}
+
+// SubstituteWith substitute variables in the string with their values.
+// It accepts additional substitute function.
+func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) {
+	options := []Option{
+		WithPattern(pattern),
+	}
+	if len(subsFuncs) > 0 {
+		options = append(options, WithSubstitutionFunction(subsFuncs[0]))
+	}
+
+	return SubstituteWithOptions(template, mapping, options...)
+}
+
 func getSubstitutionFunctionForTemplate(template string) (string, SubstituteFunc) {
 	interpolationMapping := []struct {
 		string


Test patch:
diff --git a/template/template_test.go b/template/template_test.go
index 1a5c6a9..f314e92 100644
--- a/template/template_test.go
+++ b/template/template_test.go
@@ -377,9 +377,33 @@ func TestSubstituteWithCustomFunc(t *testing.T) {
 	assert.Check(t, is.ErrorContains(err, "required variable"))
 }
 
+func TestSubstituteWithReplacementFunc(t *testing.T) {
+	options := []Option{
+		WithReplacementFunction(func(s string, m Mapping, c *Config) (string, error) {
+			if s == "${NOTHERE}" {
+				return "", fmt.Errorf("bad choice: %q", s)
+			}
+			r, err := DefaultReplacementFunc(s, m, c)
+			if err == nil && r != "" {
+				return r, nil
+			}
+			return "foobar", nil
+		}),
+	}
+	result, err := SubstituteWithOptions("ok ${FOO}", defaultMapping, options...)
+	assert.NilError(t, err)
+	assert.Check(t, is.Equal("ok first", result))
+
+	result, err = SubstituteWithOptions("ok ${BAR}", defaultMapping, options...)
+	assert.NilError(t, err)
+	assert.Check(t, is.Equal("ok foobar", result))
+
+	_, err = SubstituteWithOptions("ok ${NOTHERE}", defaultMapping, options...)
+	assert.Check(t, is.ErrorContains(err, "bad choice"))
+}
+
 // TestPrecedence tests is the precedence on '-' and '?' is of the first match
 func TestPrecedence(t *testing.T) {
-
 	testCases := []struct {
 		template string
 		expected string