Repository: compose-spec/compose-go
Pull request #378 (2023-03-27 12:13:50)

env var name CAN be any unicode char, not just ASCII
make godotenv use _runes_, not `byte` to process dot env text file, so we support unicode in variable names

closes https://github.com/compose-spec/compose-go/issues/277


Patch:
diff --git a/dotenv/godotenv.go b/dotenv/godotenv.go
index ae34ffa..cd19cb4 100644
--- a/dotenv/godotenv.go
+++ b/dotenv/godotenv.go
@@ -111,8 +111,13 @@ func Read(filenames ...string) (map[string]string, error) {
 
 // UnmarshalBytesWithLookup parses env file from byte slice of chars, returning a map of keys and values.
 func UnmarshalBytesWithLookup(src []byte, lookupFn LookupFn) (map[string]string, error) {
+	return UnmarshalWithLookup(string(src), lookupFn)
+}
+
+// UnmarshalWithLookup parses env file from string, returning a map of keys and values.
+func UnmarshalWithLookup(src string, lookupFn LookupFn) (map[string]string, error) {
 	out := make(map[string]string)
-	err := newParser().parseBytes(src, out, lookupFn)
+	err := newParser().parse(src, out, lookupFn)
 	return out, err
 }
 
diff --git a/dotenv/parser.go b/dotenv/parser.go
index cc6f933..bbcd318 100644
--- a/dotenv/parser.go
+++ b/dotenv/parser.go
@@ -1,7 +1,6 @@
 package dotenv
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 	"regexp"
@@ -31,14 +30,14 @@ func newParser() *parser {
 	}
 }
 
-func (p *parser) parseBytes(src []byte, out map[string]string, lookupFn LookupFn) error {
+func (p *parser) parse(src string, out map[string]string, lookupFn LookupFn) error {
 	cutset := src
 	if lookupFn == nil {
 		lookupFn = noLookupFn
 	}
 	for {
 		cutset = p.getStatementStart(cutset)
-		if cutset == nil {
+		if cutset == "" {
 			// reached end of file
 			break
 		}
@@ -75,10 +74,10 @@ func (p *parser) parseBytes(src []byte, out map[string]string, lookupFn LookupFn
 // getStatementPosition returns position of statement begin.
 //
 // It skips any comment line or non-whitespace character.
-func (p *parser) getStatementStart(src []byte) []byte {
+func (p *parser) getStatementStart(src string) string {
 	pos := p.indexOfNonSpaceChar(src)
 	if pos == -1 {
-		return nil
+		return ""
 	}
 
 	src = src[pos:]
@@ -87,70 +86,69 @@ func (p *parser) getStatementStart(src []byte) []byte {
 	}
 
 	// skip comment section
-	pos = bytes.IndexFunc(src, isCharFunc('\n'))
+	pos = strings.IndexFunc(src, isCharFunc('\n'))
 	if pos == -1 {
-		return nil
+		return ""
 	}
 	return p.getStatementStart(src[pos:])
 }
 
 // locateKeyName locates and parses key name and returns rest of slice
-func (p *parser) locateKeyName(src []byte) (string, []byte, bool, error) {
+func (p *parser) locateKeyName(src string) (string, string, bool, error) {
 	var key string
 	var inherited bool
 	// trim "export" and space at beginning
-	src = bytes.TrimLeftFunc(exportRegex.ReplaceAll(src, nil), isSpace)
+	src = strings.TrimLeftFunc(exportRegex.ReplaceAllString(src, ""), isSpace)
 
 	// locate key name end and validate it in single loop
 	offset := 0
 loop:
-	for i, char := range src {
-		rchar := rune(char)
-		if isSpace(rchar) {
+	for i, rune := range src {
+		if isSpace(rune) {
 			continue
 		}
 
-		switch char {
+		switch rune {
 		case '=', ':', '\n':
 			// library also supports yaml-style value declaration
 			key = string(src[0:i])
 			offset = i + 1
-			inherited = char == '\n'
+			inherited = rune == '\n'
 			break loop
 		case '_', '.', '-', '[', ']':
 		default:
 			// variable name should match [A-Za-z0-9_.-]
-			if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) {
+			if unicode.IsLetter(rune) || unicode.IsNumber(rune) {
 				continue
 			}
 
-			return "", nil, inherited, fmt.Errorf(
+			return "", "", inherited, fmt.Errorf(
 				`line %d: unexpected character %q in variable name`,
-				p.line, string(char))
+				p.line, string(rune))
 		}
 	}
 
-	if len(src) == 0 {
-		return "", nil, inherited, errors.New("zero length string")
+	if src == "" {
+		return "", "", inherited, errors.New("zero length string")
 	}
 
 	// trim whitespace
 	key = strings.TrimRightFunc(key, unicode.IsSpace)
-	cutset := bytes.TrimLeftFunc(src[offset:], isSpace)
+	cutset := strings.TrimLeftFunc(src[offset:], isSpace)
 	return key, cutset, inherited, nil
 }
 
 // extractVarValue extracts variable value and returns rest of slice
-func (p *parser) extractVarValue(src []byte, envMap map[string]string, lookupFn LookupFn) (string, []byte, error) {
+func (p *parser) extractVarValue(src string, envMap map[string]string, lookupFn LookupFn) (string, string, error) {
 	quote, isQuoted := hasQuotePrefix(src)
 	if !isQuoted {
 		// unquoted value - read until new line
-		value, rest, _ := bytes.Cut(src, []byte("\n"))
+		value, rest, _ := strings.Cut(src, "\n")
 		p.line++
 
 		// Remove inline comments on unquoted lines
-		value, _, _ = bytes.Cut(value, []byte(" #"))
-		value = bytes.TrimRightFunc(value, unicode.IsSpace)
+		value, _, _ = strings.Cut(value, " #")
+		value = strings.TrimRightFunc(value, unicode.IsSpace)
 		retVal, err := expandVariables(string(value), envMap, lookupFn)
 		return retVal, rest, err
 	}
@@ -176,7 +174,7 @@ func (p *parser) extractVarValue(src []byte, envMap map[string]string, lookupFn
 			// variables on the result
 			retVal, err := expandVariables(expandEscapes(value), envMap, lookupFn)
 			if err != nil {
-				return "", nil, err
+				return "", "", err
 			}
 			value = retVal
 		}
@@ -185,12 +183,12 @@ func (p *parser) extractVarValue(src []byte, envMap map[string]string, lookupFn
 	}
 
 	// return formatted error if quoted string is not terminated
-	valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
+	valEndIndex := strings.IndexFunc(src, isCharFunc('\n'))
 	if valEndIndex == -1 {
 		valEndIndex = len(src)
 	}
 
-	return "", nil, fmt.Errorf("line %d: unterminated quoted value %s", p.line, src[:valEndIndex])
+	return "", "", fmt.Errorf("line %d: unterminated quoted value %s", p.line, src[:valEndIndex])
 }
 
 func expandEscapes(str string) string {
@@ -225,8 +223,8 @@ func expandEscapes(str string) string {
 	return out
 }
 
-func (p *parser) indexOfNonSpaceChar(src []byte) int {
-	return bytes.IndexFunc(src, func(r rune) bool {
+func (p *parser) indexOfNonSpaceChar(src string) int {
+	return strings.IndexFunc(src, func(r rune) bool {
 		if r == '\n' {
 			p.line++
 		}
@@ -235,8 +233,8 @@ func (p *parser) indexOfNonSpaceChar(src []byte) int {
 }
 
 // hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
-func hasQuotePrefix(src []byte) (byte, bool) {
-	if len(src) == 0 {
+func hasQuotePrefix(src string) (byte, bool) {
+	if src == "" {
 		return 0, false
 	}
 


Test patch:
diff --git a/dotenv/godotenv_test.go b/dotenv/godotenv_test.go
index cee78c6..994026d 100644
--- a/dotenv/godotenv_test.go
+++ b/dotenv/godotenv_test.go
@@ -465,7 +465,7 @@ func TestLinesToIgnore(t *testing.T) {
 
 	for n, c := range cases {
 		t.Run(n, func(t *testing.T) {
-			got := string(newParser().getStatementStart([]byte(c.input)))
+			got := string(newParser().getStatementStart(c.input))
 			if got != c.want {
 				t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got)
 			}
diff --git a/dotenv/parser_test.go b/dotenv/parser_test.go
index 5df05b3..ce580f0 100644
--- a/dotenv/parser_test.go
+++ b/dotenv/parser_test.go
@@ -10,20 +10,21 @@ var testInput = `
 a=b
 a[1]=c
 a.propertyKey=d
+árvíztűrő-TÜKÖRFÚRÓGÉP=ÁRVÍZTŰRŐ-tükörfúrógép
 `
 
 func TestParseBytes(t *testing.T) {
 	p := newParser()
 
-	var inputBytes = []byte(testInput)
 	expectedOutput := map[string]string{
-		"a":             "b",
-		"a[1]":          "c",
-		"a.propertyKey": "d",
+		"a":                      "b",
+		"a[1]":                   "c",
+		"a.propertyKey":          "d",
+		"árvíztűrő-TÜKÖRFÚRÓGÉP": "ÁRVÍZTŰRŐ-tükörfúrógép",
 	}
 
 	out := map[string]string{}
-	err := p.parseBytes([]byte(inputBytes), out, nil)
+	err := p.parse(testInput, out, nil)
 
 	assert.NilError(t, err)
 	assert.Equal(t, len(expectedOutput), len(out))