Compare commits

..
15 changed files with 211 additions and 415 deletions
+4 -4
View File
@@ -8,13 +8,13 @@ jobs:
strategy:
fail-fast: false
matrix:
go: [ '1.26', '1.25', '1.24', '1.23', '1.22' ]
os: [ ubuntu-latest, macOS-latest, windows-2025-vs2026 ]
go: [ '1.19', '1.18', '1.17', '1.16', '1.15' ]
os: [ ubuntu-latest, macOS-latest, windows-latest ]
name: ${{ matrix.os }} Go ${{ matrix.go }} Tests
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: Setup go
uses: actions/setup-go@v6
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go }}
- run: go test
+4 -4
View File
@@ -38,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v2
+2 -2
View File
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v3
- name: Generate build files
uses: thatisuday/go-cross-build@v1.1.0
uses: thatisuday/go-cross-build@v1.0.2
with:
platforms: 'linux/amd64, linux/ppc64le, darwin/amd64, darwin/arm64, windows/amd64'
package: 'cmd/godotenv'
-2
View File
@@ -1,3 +1 @@
.DS_Store
CLAUDE.md
.claude
+12 -19
View File
@@ -20,19 +20,18 @@ As a library
go get github.com/joho/godotenv
```
as a tool dependency:
go >= 1.24
```shell
go get -tool github.com/joho/godotenv/cmd/godotenv
```
or if you want to use it as a bin command
go >= 1.17
```shell
go install github.com/joho/godotenv/cmd/godotenv@latest
```
go < 1.17
```shell
go get github.com/joho/godotenv/cmd/godotenv
```
## Usage
Add your application configuration to your `.env` file in the root of your project:
@@ -76,8 +75,8 @@ import _ "github.com/joho/godotenv/autoload"
While `.env` in the project root is the default, you don't have to be constrained, both examples below are 100% legit
```go
godotenv.Load("somerandomfile")
godotenv.Load("filenumberone.env", "filenumbertwo.env")
_ = godotenv.Load("somerandomfile")
_ = godotenv.Load("filenumberone.env", "filenumbertwo.env")
```
If you want to be really fancy with your env file you can do comments and exports (below is a valid env file)
@@ -100,7 +99,7 @@ as a final aside, if you don't want godotenv munging your env you can just get a
```go
var myEnv map[string]string
myEnv, err = godotenv.Read()
myEnv, err := godotenv.Read()
s3Bucket := myEnv["S3_BUCKET"]
```
@@ -119,10 +118,6 @@ content := getRemoteFileContent()
myEnv, err := godotenv.Unmarshal(content)
```
### Variable name compatibility
Ruby's dotenv only allows `[A-Za-z0-9_.]` in key names, while Node's dotenv also permits `-`. godotenv has matched the Node charset since v1.4.0, so keys like `MY-VAR` parse cleanly here and in Node but will error under Ruby's dotenv.
### Precedence & Conventions
Existing envs take precedence of envs that are loaded later.
@@ -137,12 +132,12 @@ if "" == env {
env = "development"
}
godotenv.Load() // The Original .env
godotenv.Load(".env." + env)
godotenv.Load(".env." + env + ".local")
if "test" != env {
godotenv.Load(".env.local")
}
godotenv.Load(".env." + env + ".local")
godotenv.Load(".env." + env)
godotenv.Load() // The Original .env
```
If you need to, you can also use `godotenv.Overload()` to defy this convention
@@ -158,8 +153,6 @@ godotenv -f /some/path/to/.env some_command with some args
If you don't specify `-f` it will fall back on the default of loading `.env` in `PWD`
By default, it won't override existing environment variables; you can do that with the `-o` flag.
### Writing Env Files
Godotenv can also write a map representing the environment to a correctly-formatted and escaped file
+3 -4
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"log"
"strings"
"github.com/joho/godotenv"
@@ -14,15 +15,13 @@ func main() {
flag.BoolVar(&showHelp, "h", false, "show help")
var rawEnvFilenames string
flag.StringVar(&rawEnvFilenames, "f", "", "comma separated paths to .env files")
var overload bool
flag.BoolVar(&overload, "o", false, "override existing .env variables")
flag.Parse()
usage := `
Run a process with an env setup from a .env file
godotenv [-o] [-f ENV_FILE_PATHS] COMMAND_ARGS
godotenv [-f ENV_FILE_PATHS] COMMAND_ARGS
ENV_FILE_PATHS: comma separated paths to .env files
COMMAND_ARGS: command and args you want to run
@@ -48,7 +47,7 @@ example
cmd := args[0]
cmdArgs := args[1:]
err := godotenv.Exec(envFilenames, cmd, cmdArgs, overload)
err := godotenv.Exec(envFilenames, cmd, cmdArgs)
if err != nil {
log.Fatal(err)
}
-3
View File
@@ -1,7 +1,4 @@
# Full line comment
qux=thud # fred # other
thud=fred#qux # other
fred=qux#baz # other # more
foo=bar # baz
bar=foo#baz
baz="foo"#bar
+1
View File
@@ -1 +1,2 @@
export OPTION_A='postgres://localhost:5432/database?sslmode=disable'
-3
View File
@@ -1,3 +0,0 @@
OPTION_A=abc
OPTION-B=def
+1 -2
View File
@@ -4,5 +4,4 @@ OPTION_C= 3
OPTION_D =4
OPTION_E = 5
OPTION_F =
OPTION_G=
OPTION_H=1 2
OPTION_G=
-1
View File
@@ -3,4 +3,3 @@ OPTION_B=${OPTION_A}
OPTION_C=$OPTION_B
OPTION_D=${OPTION_A}${OPTION_B}
OPTION_E=${OPTION_NOT_DEFINED}
OPTION_F=${GLOBAL_OPTION}
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/joho/godotenv
go 1.13
go 1.12
+140 -38
View File
@@ -4,22 +4,25 @@
//
// The TL;DR is that you make a .env file that looks something like
//
// SOME_ENV_VAR=somevalue
// SOME_ENV_VAR=somevalue
//
// and then in your go code you can call
//
// godotenv.Load()
// godotenv.Load()
//
// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
package godotenv
import (
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -27,13 +30,12 @@ const doubleQuoteSpecialChars = "\\\n\r\"!$`"
// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
data, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
return UnmarshalBytes(buf.Bytes())
return UnmarshalBytes(data)
}
// Load will read your env file(s) and load them into ENV for this process.
@@ -44,7 +46,7 @@ func Parse(r io.Reader) (map[string]string, error) {
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Load("fileone", "filetwo")
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults.
func Load(filenames ...string) (err error) {
@@ -67,7 +69,7 @@ func Load(filenames ...string) (err error) {
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Overload("fileone", "filetwo")
// godotenv.Overload("fileone", "filetwo")
//
// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefully set all vars.
func Overload(filenames ...string) (err error) {
@@ -123,13 +125,9 @@ func UnmarshalBytes(src []byte) (map[string]string, error) {
// Simply hooks up os.Stdin/err/out to the command and calls Run().
//
// If you want more fine grained control over your command it's recommended
// that you use `Load()`, `Overload()` or `Read()` and the `os/exec` package yourself.
func Exec(filenames []string, cmd string, cmdArgs []string, overload bool) error {
op := Load
if overload {
op = Overload
}
if err := op(filenames...); err != nil {
// that you use `Load()` or `Read()` and the `os/exec` package yourself.
func Exec(filenames []string, cmd string, cmdArgs []string) error {
if err := Load(filenames...); err != nil {
return err
}
@@ -158,33 +156,13 @@ func Write(envMap map[string]string, filename string) error {
return file.Sync()
}
// isInt checks if the string may be serialized as a number value, leading
// "-" symbol is allowed for negative numbers, leading "+" sign is not. The
// length of the value is not limited.
func isInt(s string) bool {
s = strings.TrimPrefix(s, "-")
if len(s) == 0 {
return false
}
for _, r := range s {
if '0' <= r && r <= '9' {
continue
}
return false
}
return true
}
// Marshal outputs the given environment as a dotenv-formatted environment file.
// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped.
func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap))
for k, v := range envMap {
if isInt(v) {
lines = append(lines, fmt.Sprintf(`%s=%s`, k, v))
if d, err := strconv.Atoi(v); err == nil {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else {
lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v)))
}
@@ -232,6 +210,130 @@ func readFile(filename string) (envMap map[string]string, err error) {
return Parse(file)
}
var exportRegex = regexp.MustCompile(`^\s*(?:export\s+)?(.*?)\s*$`)
func parseLine(line string, envMap map[string]string) (key string, value string, err error) {
if len(line) == 0 {
err = errors.New("zero length string")
return
}
// ditch the comments (but keep quoted hashes)
if strings.Contains(line, "#") {
segmentsBetweenHashes := strings.Split(line, "#")
quotesAreOpen := false
var segmentsToKeep []string
for _, segment := range segmentsBetweenHashes {
if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 {
if quotesAreOpen {
quotesAreOpen = false
segmentsToKeep = append(segmentsToKeep, segment)
} else {
quotesAreOpen = true
}
}
if len(segmentsToKeep) == 0 || quotesAreOpen {
segmentsToKeep = append(segmentsToKeep, segment)
}
}
line = strings.Join(segmentsToKeep, "#")
}
firstEquals := strings.Index(line, "=")
firstColon := strings.Index(line, ":")
splitString := strings.SplitN(line, "=", 2)
if firstColon != -1 && (firstColon < firstEquals || firstEquals == -1) {
//this is a yaml-style line
splitString = strings.SplitN(line, ":", 2)
}
if len(splitString) != 2 {
err = errors.New("can't separate key from value")
return
}
// Parse the key
key = splitString[0]
key = strings.TrimPrefix(key, "export")
key = strings.TrimSpace(key)
key = exportRegex.ReplaceAllString(splitString[0], "$1")
// Parse the value
value = parseValue(splitString[1], envMap)
return
}
var (
singleQuotesRegex = regexp.MustCompile(`\A'(.*)'\z`)
doubleQuotesRegex = regexp.MustCompile(`\A"(.*)"\z`)
escapeRegex = regexp.MustCompile(`\\.`)
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
)
func parseValue(value string, envMap map[string]string) string {
// trim
value = strings.Trim(value, " ")
// check if we've got quoted values or possible escapes
if len(value) > 1 {
singleQuotes := singleQuotesRegex.FindStringSubmatch(value)
doubleQuotes := doubleQuotesRegex.FindStringSubmatch(value)
if singleQuotes != nil || doubleQuotes != nil {
// pull the quotes off the edges
value = value[1 : len(value)-1]
}
if doubleQuotes != nil {
// expand newlines
value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string {
c := strings.TrimPrefix(match, `\`)
switch c {
case "n":
return "\n"
case "r":
return "\r"
default:
return match
}
})
// unescape characters
value = unescapeCharsRegex.ReplaceAllString(value, "$1")
}
if singleQuotes == nil {
value = expandVariables(value, envMap)
}
}
return value
}
var expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
func expandVariables(v string, m map[string]string) string {
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
submatch := expandVarRegex.FindStringSubmatch(s)
if submatch == nil {
return s
}
if submatch[1] == "\\" || submatch[2] == "(" {
return submatch[0][1:]
} else if submatch[4] != "" {
return m[submatch[4]]
}
return s
})
}
func doubleQuoteEscape(line string) string {
for _, c := range doubleQuoteSpecialChars {
toReplace := "\\" + string(c)
@@ -241,7 +343,7 @@ func doubleQuoteEscape(line string) string {
if c == '\r' {
toReplace = `\r`
}
line = strings.ReplaceAll(line, string(c), toReplace)
line = strings.Replace(line, string(c), toReplace, -1)
}
return line
}
+12 -217
View File
@@ -2,7 +2,6 @@ package godotenv
import (
"bytes"
"errors"
"fmt"
"os"
"reflect"
@@ -13,14 +12,9 @@ import (
var noopPresets = make(map[string]string)
func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
result, err := Unmarshal(rawEnvLine)
if err != nil {
t.Errorf("Expected %q to parse as %q: %q, errored %q", rawEnvLine, expectedKey, expectedValue, err)
return
}
if result[expectedKey] != expectedValue {
t.Errorf("Expected '%v' to parse as '%v' => '%v', got %q instead", rawEnvLine, expectedKey, expectedValue, result)
key, value, _ := parseLine(rawEnvLine, noopPresets)
if key != expectedKey || value != expectedValue {
t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
}
}
@@ -86,7 +80,6 @@ func TestReadPlainEnv(t *testing.T) {
"OPTION_E": "5",
"OPTION_F": "",
"OPTION_G": "",
"OPTION_H": "1 2",
}
envMap, err := Read(envFileName)
@@ -160,7 +153,6 @@ func TestLoadPlainEnv(t *testing.T) {
"OPTION_C": "3",
"OPTION_D": "4",
"OPTION_E": "5",
"OPTION_H": "1 2",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
@@ -185,31 +177,6 @@ func TestLoadEqualsEnv(t *testing.T) {
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestLoadHyphenEnv(t *testing.T) {
envFileName := "fixtures/hyphen.env"
expectedValues := map[string]string{
"OPTION_A": "abc",
"OPTION-B": "def",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestKeyNameCharsetRejectsDisallowed(t *testing.T) {
// Locks in the variable-name charset to [A-Za-z0-9_.-]. If you widen
// this further, delete the relevant case here on purpose.
disallowed := []string{
"FOO+BAR=baz",
"FOO@BAR=baz",
"FOO/BAR=baz",
}
for _, input := range disallowed {
if _, err := Unmarshal(input); err == nil {
t.Errorf("expected error parsing %q, got nil", input)
}
}
}
func TestLoadQuotedEnv(t *testing.T) {
envFileName := "fixtures/quoted.env"
expectedValues := map[string]string{
@@ -233,21 +200,15 @@ func TestLoadQuotedEnv(t *testing.T) {
func TestSubstitutions(t *testing.T) {
envFileName := "fixtures/substitutions.env"
presets := map[string]string{
"GLOBAL_OPTION": "global",
}
expectedValues := map[string]string{
"OPTION_A": "1",
"OPTION_B": "1",
"OPTION_C": "1",
"OPTION_D": "11",
"OPTION_E": "",
"OPTION_F": "global",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets)
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestExpanding(t *testing.T) {
@@ -311,6 +272,7 @@ func TestExpanding(t *testing.T) {
}
})
}
}
func TestVariableStringValueSeparator(t *testing.T) {
@@ -368,9 +330,6 @@ func TestParsing(t *testing.T) {
// parses escaped double quotes
parseAndCompare(t, `FOO="escaped\"bar"`, "FOO", `escaped"bar`)
// a trailing escaped backslash does not escape the closing quote
parseAndCompare(t, `FOO="bar\\"`, "FOO", `bar\`)
// parses single quotes inside double quotes
parseAndCompare(t, `FOO="'d'"`, "FOO", `'d'`)
@@ -410,9 +369,6 @@ func TestParsing(t *testing.T) {
// expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar '
parseAndCompare(t, "FOO=bar ", "FOO", "bar")
// unquoted internal whitespace is preserved
parseAndCompare(t, `KEY=value value`, "KEY", "value value")
// it 'ignores inline comments' do
// expect(env("foo=bar # this is foo")).to eql('foo' => 'bar')
parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar")
@@ -435,8 +391,10 @@ func TestParsing(t *testing.T) {
parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz")
parseAndCompare(t, `="value"`, "", "value")
parseAndCompare(t, `KEY="`, "KEY", "\"")
parseAndCompare(t, `KEY="value`, "KEY", "\"value")
// unquoted whitespace around keys should be ignored
// leading whitespace should be ignored
parseAndCompare(t, " KEY =value", "KEY", "value")
parseAndCompare(t, " KEY=value", "KEY", "value")
parseAndCompare(t, "\tKEY=value", "KEY", "value")
@@ -444,7 +402,7 @@ func TestParsing(t *testing.T) {
// it 'throws an error if line format is incorrect' do
// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
badlyFormattedLine := "lol$wut"
_, err := Unmarshal(badlyFormattedLine)
_, _, err := parseLine(badlyFormattedLine, noopPresets)
if err == nil {
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
}
@@ -470,10 +428,6 @@ func TestLinesToIgnore(t *testing.T) {
"Indented comment": {
input: "\t # comment",
},
"Multiple consecutive comments": {
input: "# one\n# two\n# three\nFOO=bar",
want: "FOO=bar",
},
"non-ignored value": {
input: `export OPTION_B='\n'`,
want: `export OPTION_B='\n'`,
@@ -510,41 +464,14 @@ func TestErrorParsing(t *testing.T) {
func TestComments(t *testing.T) {
envFileName := "fixtures/comments.env"
expectedValues := map[string]string{
"qux": "thud",
"thud": "fred#qux",
"fred": "qux#baz",
"foo": "bar",
"bar": "foo#baz",
"baz": "foo",
"foo": "bar",
"bar": "foo#baz",
"baz": "foo",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestIsInt(t *testing.T) {
checkAndCompare := func(s string, expected bool) {
if isInt(s) != expected {
t.Fail()
}
}
// invalid values
checkAndCompare("", false)
checkAndCompare("+123", false)
checkAndCompare("+12a3", false)
checkAndCompare("12a3", false)
checkAndCompare("abc", false)
checkAndCompare("12 3", false)
checkAndCompare("-", false)
checkAndCompare(" ", false)
// valid values
checkAndCompare("-123", true)
checkAndCompare("123", true)
checkAndCompare("-922337203685477580868712", true)
checkAndCompare("922337203685477580837281", true)
}
func TestWrite(t *testing.T) {
writeAndCompare := func(env string, expected string) {
envMap, _ := Unmarshal(env)
@@ -568,10 +495,6 @@ func TestWrite(t *testing.T) {
writeAndCompare("foo=bar\nbaz=buzz", "baz=\"buzz\"\nfoo=\"bar\"")
// integers should not be quoted
writeAndCompare(`key="10"`, `key=10`)
// leading + is not numeric — must be quoted to preserve the sign
writeAndCompare(`key=+123`, `key="+123"`)
// leading zeros must be preserved (not collapsed to a smaller int)
writeAndCompare(`key=007`, `key=007`)
}
@@ -597,131 +520,3 @@ func TestRoundtrip(t *testing.T) {
}
}
func TestTrailingNewlines(t *testing.T) {
cases := map[string]struct {
input string
key string
value string
}{
"Simple value without trailing newline": {
input: "KEY=value",
key: "KEY",
value: "value",
},
"Value with internal whitespace without trailing newline": {
input: "KEY=value value",
key: "KEY",
value: "value value",
},
"Value with internal whitespace with trailing newline": {
input: "KEY=value value\n",
key: "KEY",
value: "value value",
},
"YAML style - value with internal whitespace without trailing newline": {
input: "KEY: value value",
key: "KEY",
value: "value value",
},
"YAML style - value with internal whitespace with trailing newline": {
input: "KEY: value value\n",
key: "KEY",
value: "value value",
},
}
for n, c := range cases {
t.Run(n, func(t *testing.T) {
result, err := Unmarshal(c.input)
if err != nil {
t.Errorf("Input: %q Unexpected error:\t%q", c.input, err)
}
if result[c.key] != c.value {
t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, result)
}
})
}
}
func TestWhitespace(t *testing.T) {
cases := map[string]struct {
input string
key string
value string
}{
"Leading whitespace": {
input: " A=a\n",
key: "A",
value: "a",
},
"Leading tab": {
input: "\tA=a\n",
key: "A",
value: "a",
},
"Leading mixed whitespace": {
input: " \t \t\n\t \t A=a\n",
key: "A",
value: "a",
},
"Leading whitespace before export": {
input: " \t\t export A=a\n",
key: "A",
value: "a",
},
"Trailing whitespace": {
input: "A=a \t \t\n",
key: "A",
value: "a",
},
"Trailing whitespace with export": {
input: "export A=a\t \t \n",
key: "A",
value: "a",
},
"No EOL": {
input: "A=a",
key: "A",
value: "a",
},
"Trailing whitespace with no EOL": {
input: "A=a ",
key: "A",
value: "a",
},
}
for n, c := range cases {
t.Run(n, func(t *testing.T) {
result, err := Unmarshal(c.input)
if err != nil {
t.Errorf("Input: %q Unexpected error:\t%q", c.input, err)
}
if result[c.key] != c.value {
t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, result)
}
})
}
}
func TestParserErrors(t *testing.T) {
cases := map[string]struct {
input string
err error
}{
"UnterminatedQuote": {
input: "foo=\"bar",
err: ErrUnterminatedQuote,
},
}
for n, c := range cases {
t.Run(n, func(t *testing.T) {
v, err := Unmarshal(c.input)
if !errors.Is(err, c.err) {
t.Errorf("Input: %q Expected:\t %q\nGot:\t %q Val: %v", c.input, c.err, err, v)
}
})
}
}
+31 -115
View File
@@ -4,8 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"os"
"regexp"
"strings"
"unicode"
)
@@ -18,14 +16,8 @@ const (
exportPrefix = "export"
)
var (
ErrZeroLengthString = errors.New("zero length string")
ErrUnexpectedChar = errors.New("unexpected character")
ErrUnterminatedQuote = errors.New("unterminated quoted value")
)
func parseBytes(src []byte, out map[string]string) error {
src = bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n"))
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
cutset := src
for {
cutset = getStatementStart(cutset)
@@ -55,37 +47,29 @@ func parseBytes(src []byte, out map[string]string) error {
//
// It skips any comment line or non-whitespace character.
func getStatementStart(src []byte) []byte {
for {
pos := indexOfNonSpaceChar(src)
if pos == -1 {
return nil
}
src = src[pos:]
if src[0] != charComment {
return src
}
// skip comment section
pos = bytes.IndexFunc(src, isCharFunc('\n'))
if pos == -1 {
return nil
}
src = src[pos:]
pos := indexOfNonSpaceChar(src)
if pos == -1 {
return nil
}
src = src[pos:]
if src[0] != charComment {
return src
}
// skip comment section
pos = bytes.IndexFunc(src, isCharFunc('\n'))
if pos == -1 {
return nil
}
return getStatementStart(src[pos:])
}
// locateKeyName locates and parses key name and returns rest of slice
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
// trim "export" and space at beginning
src = bytes.TrimLeftFunc(src, isSpace)
if bytes.HasPrefix(src, []byte(exportPrefix)) {
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
if bytes.IndexFunc(trimmed, isSpace) == 0 {
src = bytes.TrimLeftFunc(trimmed, isSpace)
}
}
src = bytes.TrimLeftFunc(bytes.TrimPrefix(src, []byte(exportPrefix)), isSpace)
// locate key name end and validate it in single loop
offset := 0
@@ -104,20 +88,19 @@ loop:
break loop
case '_':
default:
// variable name should match [A-Za-z0-9_.-]
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' || rchar == '-' {
// variable name should match [A-Za-z0-9_]
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) {
continue
}
return "", nil, fmt.Errorf(
`%w %q in variable name near %q`,
ErrUnexpectedChar,
`unexpected character %q in variable name near %q`,
string(char), string(src))
}
}
if len(src) == 0 {
return "", nil, ErrZeroLengthString
return "", nil, errors.New("zero length string")
}
// trim whitespace
@@ -130,41 +113,13 @@ loop:
func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) {
quote, hasPrefix := hasQuotePrefix(src)
if !hasPrefix {
// unquoted value - read until end of line
endOfLine := bytes.IndexFunc(src, isLineEnd)
// Hit EOF without a trailing newline
if endOfLine == -1 {
endOfLine = len(src)
if endOfLine == 0 {
return "", nil, nil
}
// unquoted value - read until whitespace
end := bytes.IndexFunc(src, unicode.IsSpace)
if end == -1 {
return expandVariables(string(src), vars), nil, nil
}
// Convert line to rune away to do accurate countback of runes
line := []rune(string(src[0:endOfLine]))
// Assume end of line is end of var
endOfVar := len(line)
if endOfVar == 0 {
return "", src[endOfLine:], nil
}
// Work backwards to check if the line ends in whitespace then
// a comment, ie: foo=bar # baz # other
for i := 0; i < endOfVar; i++ {
if line[i] == charComment && i < endOfVar {
if isSpace(line[i-1]) {
endOfVar = i
break
}
}
}
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
return expandVariables(trimmed, vars), src[endOfLine:], nil
return expandVariables(string(src[0:end]), vars), src[end:], nil
}
// lookup quoted string terminator
@@ -173,12 +128,8 @@ func extractVarValue(src []byte, vars map[string]string) (value string, rest []b
continue
}
// skip escaped quote symbol; a quote is escaped only when preceded by an odd number of backslashes
backslashes := 0
for j := i - 1; j >= 0 && src[j] == '\\'; j-- {
backslashes++
}
if backslashes%2 == 1 {
// skip escaped quote symbol (\" or \', depends on quote)
if prevChar := src[i-1]; prevChar == '\\' {
continue
}
@@ -200,7 +151,7 @@ func extractVarValue(src []byte, vars map[string]string) (value string, rest []b
valEndIndex = len(src)
}
return "", nil, fmt.Errorf("%w %s", ErrUnterminatedQuote, src[:valEndIndex])
return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
}
func expandEscapes(str string) string {
@@ -225,7 +176,7 @@ func indexOfNonSpaceChar(src []byte) int {
}
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
func hasQuotePrefix(src []byte) (prefix byte, isQuoted bool) {
func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) {
if len(src) == 0 {
return 0, false
}
@@ -254,38 +205,3 @@ func isSpace(r rune) bool {
}
return false
}
func isLineEnd(r rune) bool {
if r == '\n' || r == '\r' {
return true
}
return false
}
var (
escapeRegex = regexp.MustCompile(`\\.`)
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
)
func expandVariables(v string, m map[string]string) string {
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
submatch := expandVarRegex.FindStringSubmatch(s)
if submatch == nil {
return s
}
if submatch[1] == "\\" || submatch[2] == "(" {
return submatch[0][1:]
} else if submatch[4] != "" {
if val, ok := m[submatch[4]]; ok {
return val
}
if val, ok := os.LookupEnv(submatch[4]); ok {
return val
}
return m[submatch[4]]
}
return s
})
}