Compare commits

..

No commits in common. "main" and "add_bin_command" have entirely different histories.

18 changed files with 201 additions and 1259 deletions

View File

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: /
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

View File

@ -1,20 +0,0 @@
name: CI
on: [push]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
go: [ '1.20', '1.19', '1.18', '1.17', '1.16' ]
os: [ ubuntu-latest, macOS-latest, windows-latest ]
name: ${{ matrix.os }} Go ${{ matrix.go }} Tests
steps:
- uses: actions/checkout@v3
- name: Setup go
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
- run: go test

View File

@ -1,72 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '31 4 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
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.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# 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@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
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@ -1,31 +0,0 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Upload Release Assets
jobs:
build:
name: Upload Release Assets
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Generate build files
uses: thatisuday/go-cross-build@v1.0.2
with:
platforms: 'linux/amd64, linux/ppc64le, darwin/amd64, darwin/arm64, windows/amd64'
package: 'cmd/godotenv'
name: 'godotenv'
compress: 'true'
dest: 'dist'
- name: Publish Binaries
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
release_name: Release ${{ github.ref }}
tag: ${{ github.ref }}
file: dist/*
file_glob: true
overwrite: true

116
README.md
View File

@ -1,6 +1,6 @@
# GoDotEnv ![CI](https://github.com/joho/godotenv/workflows/CI/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/joho/godotenv)](https://goreportcard.com/report/github.com/joho/godotenv) # GoDotEnv [![wercker status](https://app.wercker.com/status/507594c2ec7e60f19403a568dfea0f78 "wercker status")](https://app.wercker.com/project/bykey/507594c2ec7e60f19403a568dfea0f78)
A Go (golang) port of the Ruby [dotenv](https://github.com/bkeepers/dotenv) project (which loads env vars from a .env file). A Go (golang) port of the Ruby dotenv project (which loads env vars from a .env file)
From the original Library: From the original Library:
@ -8,30 +8,12 @@ From the original Library:
> >
> But it is not always practical to set environment variables on development machines or continuous integration servers where multiple projects are run. Dotenv load variables from a .env file into ENV when the environment is bootstrapped. > But it is not always practical to set environment variables on development machines or continuous integration servers where multiple projects are run. Dotenv load variables from a .env file into ENV when the environment is bootstrapped.
It can be used as a library (for loading in env for your own daemons etc.) or as a bin command.
There is test coverage and CI for both linuxish and Windows environments, but I make no guarantees about the bin version working on Windows.
## Installation ## Installation
As a library
```shell ```shell
go get github.com/joho/godotenv go get github.com/joho/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 ## Usage
Add your application configuration to your `.env` file in the root of your project: Add your application configuration to your `.env` file in the root of your project:
@ -47,10 +29,9 @@ Then in your Go app you can do something like
package main package main
import ( import (
"github.com/joho/godotenv"
"log" "log"
"os" "os"
"github.com/joho/godotenv"
) )
func main() { func main() {
@ -75,8 +56,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 While `.env` in the project root is the default, you don't have to be constrained, both examples below are 100% legit
```go ```go
godotenv.Load("somerandomfile") _ = godotenv.Load("somerandomfile")
godotenv.Load("filenumberone.env", "filenumbertwo.env") _ = 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) If you want to be really fancy with your env file you can do comments and exports (below is a valid env file)
@ -104,86 +85,13 @@ myEnv, err := godotenv.Read()
s3Bucket := myEnv["S3_BUCKET"] s3Bucket := myEnv["S3_BUCKET"]
``` ```
... or from an `io.Reader` instead of a local file end
```go
reader := getRemoteFile()
myEnv, err := godotenv.Parse(reader)
```
... or from a `string` if you so desire
```go
content := getRemoteFileContent()
myEnv, err := godotenv.Unmarshal(content)
```
### Precedence & Conventions
Existing envs take precedence of envs that are loaded later.
The [convention](https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use)
for managing multiple environments (i.e. development, test, production)
is to create an env named `{YOURAPP}_ENV` and load envs in this order:
```go
env := os.Getenv("FOO_ENV")
if "" == env {
env = "development"
}
godotenv.Load(".env." + env + ".local")
if "test" != env {
godotenv.Load(".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
and overwrite existing envs instead of only supplanting them. Use with caution.
### Command Mode
Assuming you've installed the command as above and you've got `$GOPATH/bin` in your `$PATH`
```
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
```go
env, err := godotenv.Unmarshal("KEY=value")
err := godotenv.Write(env, "./.env")
```
... or to a string
```go
env, err := godotenv.Unmarshal("KEY=value")
content, err := godotenv.Marshal(env)
```
## Contributing ## Contributing
Contributions are welcome, but with some caveats. Contributions are most welcome! The parser itself is pretty stupidly naive and I wouldn't be surprised if it breaks with edge cases.
This library has been declared feature complete (see [#182](https://github.com/joho/godotenv/issues/182) for background) and will not be accepting issues or pull requests adding new functionality or breaking the library API. *code changes without tests will not be accepted*
Contributions would be gladly accepted that:
* bring this library's parsing into closer compatibility with the mainline dotenv implementations, in particular [Ruby's dotenv](https://github.com/bkeepers/dotenv) and [Node.js' dotenv](https://github.com/motdotla/dotenv)
* keep the library up to date with the go ecosystem (ie CI bumps, documentation changes, changes in the core libraries)
* bug fixes for use cases that pertain to the library's purpose of easing development of codebases deployed into twelve factor environments
*code changes without tests and references to peer dotenv implementations will not be accepted*
1. Fork it 1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`) 2. Create your feature branch (`git checkout -b my-new-feature`)
@ -191,12 +99,10 @@ Contributions would be gladly accepted that:
4. Push to the branch (`git push origin my-new-feature`) 4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request 5. Create new Pull Request
## Releases ## CI
Releases should follow [Semver](http://semver.org/) though the first couple of releases are `v1` and `v1.1`. Linux: [![wercker status](https://app.wercker.com/status/507594c2ec7e60f19403a568dfea0f78/m "wercker status")](https://app.wercker.com/project/bykey/507594c2ec7e60f19403a568dfea0f78) Windows: [![Build status](https://ci.appveyor.com/api/projects/status/9v40vnfvvgde64u4)](https://ci.appveyor.com/project/joho/godotenv)
Use [annotated tags for all releases](https://github.com/joho/godotenv/issues/30). Example `git tag -a v1.2.1`
## Who? ## Who?
The original library [dotenv](https://github.com/bkeepers/dotenv) was written by [Brandon Keepers](http://opensoul.org/), and this port was done by [John Barton](https://johnbarton.co/) based off the tests/fixtures in the original library. The original library [dotenv](https://github.com/bkeepers/dotenv) was written by [Brandon Keepers](http://opensoul.org/), and this port was done by [John Barton](http://whoisjohnbarton.com) based off the tests/fixtures in the original library.

View File

@ -8,8 +8,8 @@ package autoload
And bob's your mother's brother And bob's your mother's brother
*/ */
import "git.coopcloud.tech/toolshed/godotenv" import "github.com/joho/godotenv"
func init() { func init() {
godotenv.Load() _ = godotenv.Load()
} }

View File

@ -7,7 +7,7 @@ import (
"strings" "strings"
"git.coopcloud.tech/toolshed/godotenv" "github.com/joho/godotenv"
) )
func main() { func main() {
@ -15,15 +15,13 @@ func main() {
flag.BoolVar(&showHelp, "h", false, "show help") flag.BoolVar(&showHelp, "h", false, "show help")
var rawEnvFilenames string var rawEnvFilenames string
flag.StringVar(&rawEnvFilenames, "f", "", "comma separated paths to .env files") flag.StringVar(&rawEnvFilenames, "f", "", "comma separated paths to .env files")
var overload bool
flag.BoolVar(&overload, "o", false, "override existing .env variables")
flag.Parse() flag.Parse()
usage := ` usage := `
Run a process with an env setup from a .env file Run a process with a 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 ENV_FILE_PATHS: comma separated paths to .env files
COMMAND_ARGS: command and args you want to run COMMAND_ARGS: command and args you want to run
@ -47,9 +45,9 @@ example
// take rest of args and "exec" them // take rest of args and "exec" them
cmd := args[0] cmd := args[0]
cmdArgs := args[1:] cmdArgs := args[1:len(args)]
err := godotenv.Exec(envFilenames, cmd, cmdArgs, overload) err := godotenv.Exec(envFilenames, cmd, cmdArgs)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -1,4 +0,0 @@
# Full line comment
foo=bar # baz
bar=foo#baz
baz="foo"#bar

View File

@ -1 +1,2 @@
export OPTION_A='postgres://localhost:5432/database?sslmode=disable' export OPTION_A='postgres://localhost:5432/database?sslmode=disable'

View File

@ -1,2 +0,0 @@
INVALID LINE
foo=bar

View File

@ -3,6 +3,3 @@ OPTION_B=2
OPTION_C= 3 OPTION_C= 3
OPTION_D =4 OPTION_D =4
OPTION_E = 5 OPTION_E = 5
OPTION_F =
OPTION_G=
OPTION_H=1 2

View File

@ -6,14 +6,3 @@ OPTION_E="1"
OPTION_F="2" OPTION_F="2"
OPTION_G="" OPTION_G=""
OPTION_H="\n" OPTION_H="\n"
OPTION_I = "echo 'asd'"
OPTION_J='line 1
line 2'
OPTION_K='line one
this is \'quoted\'
one more line'
OPTION_L="line 1
line 2"
OPTION_M="line one
this is \"quoted\"
one more line"

View File

@ -1,5 +0,0 @@
OPTION_A=1
OPTION_B=${OPTION_A}
OPTION_C=$OPTION_B
OPTION_D=${OPTION_A}${OPTION_B}
OPTION_E=${OPTION_NOT_DEFINED}

3
go.mod
View File

@ -1,3 +0,0 @@
module git.coopcloud.tech/toolshed/godotenv
go 1.12

View File

@ -1,58 +1,44 @@
// Package godotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv) /*
// A go port of the ruby dotenv library (https://github.com/bkeepers/dotenv)
// Examples/readme can be found on the GitHub page at https://github.com/joho/godotenv
// Examples/readme can be found on the github page at https://github.com/joho/godotenv
// The TL;DR is that you make a .env file that looks something like
// 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
// 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")
and all the env vars declared in .env will be avaiable through os.Getenv("SOME_ENV_VAR")
*/
package godotenv package godotenv
import ( import (
"bytes" "bufio"
"fmt" "errors"
"io"
"os" "os"
"os/exec" "os/exec"
"sort"
"strconv"
"strings" "strings"
) )
const doubleQuoteSpecialChars = "\\\n\r\"!$`" /*
Call this function as close as possible to the start of your program (ideally in main)
// Parse reads an env file from io.Reader, returning a map of keys and values. If you call Load without any args it will default to loading .env in the current path
func Parse(r io.Reader) (map[string]string, map[string]map[string]string, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
return nil, nil, err
}
return UnmarshalBytes(buf.Bytes()) You can otherwise tell it which files to load (there can be more than one) like
}
// Load will read your env file(s) and load them into ENV for this process. godotenv.Load("fileone", "filetwo")
//
// Call this function as close as possible to the start of your program (ideally in main). 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
// */
// If you call Load without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// 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) { func Load(filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames) filenames = filenamesOrDefault(filenames)
for _, filename := range filenames { for _, filename := range filenames {
err = loadFile(filename, false) err = loadFile(filename)
if err != nil { if err != nil {
return // return early on a spazout return // return early on a spazout
} }
@ -60,38 +46,16 @@ func Load(filenames ...string) (err error) {
return return
} }
// Overload will read your env file(s) and load them into ENV for this process. /*
// Read all env (with same file loading semantics as Load) but return values as
// Call this function as close as possible to the start of your program (ideally in main). a map rather than automatically writing values into env
// */
// If you call Overload without any args it will default to loading .env in the current path. func Read(filenames ...string) (envMap map[string]string, err error) {
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// 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) {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err = loadFile(filename, true)
if err != nil {
return // return early on a spazout
}
}
return
}
// Read all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func Read(filenames ...string) (envMap map[string]string, modMap map[string]map[string]string, err error) {
filenames = filenamesOrDefault(filenames) filenames = filenamesOrDefault(filenames)
envMap = make(map[string]string) envMap = make(map[string]string)
modMap = make(map[string]map[string]string)
for _, filename := range filenames { for _, filename := range filenames {
individualEnvMap, individualModMap, individualErr := readFile(filename) individualEnvMap, individualErr := readFile(filename)
if individualErr != nil { if individualErr != nil {
err = individualErr err = individualErr
@ -101,44 +65,22 @@ func Read(filenames ...string) (envMap map[string]string, modMap map[string]map[
for key, value := range individualEnvMap { for key, value := range individualEnvMap {
envMap[key] = value envMap[key] = value
} }
for key, value := range individualModMap {
modMap[key] = value
}
} }
return return
} }
// Unmarshal reads an env file from a string, returning a map of keys and values. /*
func Unmarshal(str string) (envMap map[string]string, modifierMap map[string]map[string]string, err error) { Loads env vars from the specified filenames (empty map falls back to default)
return UnmarshalBytes([]byte(str)) then executes the cmd specified.
}
// UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values. Simply hooks up os.Stdin/err/out to the command and calls Run()
func UnmarshalBytes(src []byte) (map[string]string, map[string]map[string]string, error) {
vars := make(map[string]string)
modifiers := make(map[string]map[string]string)
err := parseBytes(src, vars, modifiers)
return vars, modifiers, err If you want more fine grained control over your command it's recommended
} that you use `Load()` or `Read()` and the `os/exec` package yourself.
*/
// Exec loads env vars from the specified filenames (empty map falls back to default) func Exec(filenames []string, cmd string, cmdArgs []string) error {
// then executes the cmd specified. Load(filenames...)
//
// 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 {
return err
}
command := exec.Command(cmd, cmdArgs...) command := exec.Command(cmd, cmdArgs...)
command.Stdin = os.Stdin command.Stdin = os.Stdin
@ -147,88 +89,123 @@ func Exec(filenames []string, cmd string, cmdArgs []string, overload bool) error
return command.Run() return command.Run()
} }
// Write serializes the given environment and writes it to a file.
func Write(envMap map[string]string, filename string) error {
content, err := Marshal(envMap)
if err != nil {
return err
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content + "\n")
if err != nil {
return err
}
return file.Sync()
}
// 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 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)))
}
}
sort.Strings(lines)
return strings.Join(lines, "\n"), nil
}
func filenamesOrDefault(filenames []string) []string { func filenamesOrDefault(filenames []string) []string {
if len(filenames) == 0 { if len(filenames) == 0 {
return []string{".env"} return []string{".env"}
} else {
return filenames
} }
return filenames
} }
func loadFile(filename string, overload bool) error { func loadFile(filename string) (err error) {
envMap, _, err := readFile(filename) envMap, err := readFile(filename)
if err != nil { if err != nil {
return err return
}
currentEnv := map[string]bool{}
rawEnv := os.Environ()
for _, rawEnvLine := range rawEnv {
key := strings.Split(rawEnvLine, "=")[0]
currentEnv[key] = true
} }
for key, value := range envMap { for key, value := range envMap {
if !currentEnv[key] || overload { os.Setenv(key, value)
_ = os.Setenv(key, value)
}
} }
return nil return
} }
func readFile(filename string) (envMap map[string]string, modMap map[string]map[string]string, err error) { func readFile(filename string) (envMap map[string]string, err error) {
file, err := os.Open(filename) file, err := os.Open(filename)
if err != nil { if err != nil {
return return
} }
defer file.Close() defer file.Close()
return Parse(file) envMap = make(map[string]string)
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
for _, fullLine := range lines {
if !isIgnoredLine(fullLine) {
key, value, err := parseLine(fullLine)
if err == nil && os.Getenv(key) == "" {
envMap[key] = value
}
}
}
return
} }
func doubleQuoteEscape(line string) string { func parseLine(line string) (key string, value string, err error) {
for _, c := range doubleQuoteSpecialChars { if len(line) == 0 {
toReplace := "\\" + string(c) err = errors.New("zero length string")
if c == '\n' { return
toReplace = `\n`
}
if c == '\r' {
toReplace = `\r`
}
line = strings.Replace(line, string(c), toReplace, -1)
} }
return line
// ditch the comments (but keep quoted hashes)
if strings.Contains(line, "#") {
segmentsBetweenHashes := strings.Split(line, "#")
quotesAreOpen := false
segmentsToKeep := make([]string, 0)
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, "#")
}
// now split key from value
splitString := strings.SplitN(line, "=", 2)
if len(splitString) != 2 {
// try yaml mode!
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]
if strings.HasPrefix(key, "export") {
key = strings.TrimPrefix(key, "export")
}
key = strings.Trim(key, " ")
// Parse the value
value = splitString[1]
// trim
value = strings.Trim(value, " ")
// check if we've got quoted values
if strings.Count(value, "\"") == 2 || strings.Count(value, "'") == 2 {
// pull the quotes off the edges
value = strings.Trim(value, "\"'")
// expand quotes
value = strings.Replace(value, "\\\"", "\"", -1)
// expand newlines
value = strings.Replace(value, "\\n", "\n", -1)
}
return
}
func isIgnoredLine(line string) bool {
trimmedLine := strings.Trim(line, " \n\t")
return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#")
} }

View File

@ -1,36 +1,22 @@
package godotenv package godotenv
import ( import (
"bytes"
"fmt"
"os" "os"
"reflect"
"strings"
"testing" "testing"
) )
var noopPresets = make(map[string]string)
func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) { func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
result, _, err := Unmarshal(rawEnvLine) key, value, _ := parseLine(rawEnvLine)
if err != nil { if key != expectedKey || value != expectedValue {
t.Errorf("Expected %q to parse as %q: %q, errored %q", rawEnvLine, expectedKey, expectedValue, err) t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value)
return
}
if result[expectedKey] != expectedValue {
t.Errorf("Expected '%v' to parse as '%v' => '%v', got %q instead", rawEnvLine, expectedKey, expectedValue, result)
} }
} }
func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, envFileName string, expectedValues map[string]string, presets map[string]string) { func loadEnvAndCompareValues(t *testing.T, envFileName string, expectedValues map[string]string) {
// first up, clear the env // first up, clear the env
os.Clearenv() os.Clearenv()
for k, v := range presets { err := Load(envFileName)
os.Setenv(k, v)
}
err := loader(envFileName)
if err != nil { if err != nil {
t.Fatalf("Error loading %v", envFileName) t.Fatalf("Error loading %v", envFileName)
} }
@ -39,7 +25,7 @@ func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, e
envValue := os.Getenv(k) envValue := os.Getenv(k)
v := expectedValues[k] v := expectedValues[k]
if envValue != v { if envValue != v {
t.Errorf("Mismatch for key '%v': expected '%#v' got '%#v'", k, v, envValue) t.Errorf("Mismatch for key '%v': expected '%v' got '%v'", k, v, envValue)
} }
} }
} }
@ -52,14 +38,6 @@ func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) {
} }
} }
func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) {
err := Overload()
pathError := err.(*os.PathError)
if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" {
t.Errorf("Didn't try and open .env by default")
}
}
func TestLoadFileNotFound(t *testing.T) { func TestLoadFileNotFound(t *testing.T) {
err := Load("somefilethatwillneverexistever.env") err := Load("somefilethatwillneverexistever.env")
if err == nil { if err == nil {
@ -67,13 +45,6 @@ func TestLoadFileNotFound(t *testing.T) {
} }
} }
func TestOverloadFileNotFound(t *testing.T) {
err := Overload("somefilethatwillneverexistever.env")
if err == nil {
t.Error("File wasn't found but Overload didn't return an error")
}
}
func TestReadPlainEnv(t *testing.T) { func TestReadPlainEnv(t *testing.T) {
envFileName := "fixtures/plain.env" envFileName := "fixtures/plain.env"
expectedValues := map[string]string{ expectedValues := map[string]string{
@ -82,12 +53,9 @@ func TestReadPlainEnv(t *testing.T) {
"OPTION_C": "3", "OPTION_C": "3",
"OPTION_D": "4", "OPTION_D": "4",
"OPTION_E": "5", "OPTION_E": "5",
"OPTION_F": "",
"OPTION_G": "",
"OPTION_H": "1 2",
} }
envMap, _, err := Read(envFileName) envMap, err := Read(envFileName)
if err != nil { if err != nil {
t.Error("Error reading file") t.Error("Error reading file")
} }
@ -103,53 +71,6 @@ func TestReadPlainEnv(t *testing.T) {
} }
} }
func TestParse(t *testing.T) {
envMap, _, err := Parse(bytes.NewReader([]byte("ONE=1\nTWO='2'\nTHREE = \"3\"")))
expectedValues := map[string]string{
"ONE": "1",
"TWO": "2",
"THREE": "3",
}
if err != nil {
t.Fatalf("error parsing env: %v", err)
}
for key, value := range expectedValues {
if envMap[key] != value {
t.Errorf("expected %s to be %s, got %s", key, value, envMap[key])
}
}
}
func TestLoadDoesNotOverride(t *testing.T) {
envFileName := "fixtures/plain.env"
// ensure NO overload
presets := map[string]string{
"OPTION_A": "do_not_override",
"OPTION_B": "",
}
expectedValues := map[string]string{
"OPTION_A": "do_not_override",
"OPTION_B": "",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets)
}
func TestOverloadDoesOverride(t *testing.T) {
envFileName := "fixtures/plain.env"
// ensure NO overload
presets := map[string]string{
"OPTION_A": "do_not_override",
}
expectedValues := map[string]string{
"OPTION_A": "1",
}
loadEnvAndCompareValues(t, Overload, envFileName, expectedValues, presets)
}
func TestLoadPlainEnv(t *testing.T) { func TestLoadPlainEnv(t *testing.T) {
envFileName := "fixtures/plain.env" envFileName := "fixtures/plain.env"
expectedValues := map[string]string{ expectedValues := map[string]string{
@ -158,20 +79,19 @@ func TestLoadPlainEnv(t *testing.T) {
"OPTION_C": "3", "OPTION_C": "3",
"OPTION_D": "4", "OPTION_D": "4",
"OPTION_E": "5", "OPTION_E": "5",
"OPTION_H": "1 2",
} }
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, envFileName, expectedValues)
} }
func TestLoadExportedEnv(t *testing.T) { func TestLoadExportedEnv(t *testing.T) {
envFileName := "fixtures/exported.env" envFileName := "fixtures/exported.env"
expectedValues := map[string]string{ expectedValues := map[string]string{
"OPTION_A": "2", "OPTION_A": "2",
"OPTION_B": "\\n", "OPTION_B": "\n",
} }
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, envFileName, expectedValues)
} }
func TestLoadEqualsEnv(t *testing.T) { func TestLoadEqualsEnv(t *testing.T) {
@ -180,7 +100,7 @@ func TestLoadEqualsEnv(t *testing.T) {
"OPTION_A": "postgres://localhost:5432/database?sslmode=disable", "OPTION_A": "postgres://localhost:5432/database?sslmode=disable",
} }
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, envFileName, expectedValues)
} }
func TestLoadQuotedEnv(t *testing.T) { func TestLoadQuotedEnv(t *testing.T) {
@ -189,123 +109,14 @@ func TestLoadQuotedEnv(t *testing.T) {
"OPTION_A": "1", "OPTION_A": "1",
"OPTION_B": "2", "OPTION_B": "2",
"OPTION_C": "", "OPTION_C": "",
"OPTION_D": "\\n", "OPTION_D": "\n",
"OPTION_E": "1", "OPTION_E": "1",
"OPTION_F": "2", "OPTION_F": "2",
"OPTION_G": "", "OPTION_G": "",
"OPTION_H": "\n", "OPTION_H": "\n",
"OPTION_I": "echo 'asd'",
"OPTION_J": "line 1\nline 2",
"OPTION_K": "line one\nthis is \\'quoted\\'\none more line",
"OPTION_L": "line 1\nline 2",
"OPTION_M": "line one\nthis is \"quoted\"\none more line",
} }
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) loadEnvAndCompareValues(t, envFileName, expectedValues)
}
func TestSubstitutions(t *testing.T) {
envFileName := "fixtures/substitutions.env"
expectedValues := map[string]string{
"OPTION_A": "1",
"OPTION_B": "1",
"OPTION_C": "1",
"OPTION_D": "11",
"OPTION_E": "",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestExpanding(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
}{
{
"expands variables found in values",
"FOO=test\nBAR=$FOO",
map[string]string{"FOO": "test", "BAR": "test"},
},
{
"parses variables wrapped in brackets",
"FOO=test\nBAR=${FOO}bar",
map[string]string{"FOO": "test", "BAR": "testbar"},
},
{
"expands undefined variables to an empty string",
"BAR=$FOO",
map[string]string{"BAR": ""},
},
{
"expands variables in double quoted strings",
"FOO=test\nBAR=\"quote $FOO\"",
map[string]string{"FOO": "test", "BAR": "quote test"},
},
{
"does not expand variables in single quoted strings",
"BAR='quote $FOO'",
map[string]string{"BAR": "quote $FOO"},
},
{
"does not expand escaped variables",
`FOO="foo\$BAR"`,
map[string]string{"FOO": "foo$BAR"},
},
{
"does not expand escaped variables",
`FOO="foo\${BAR}"`,
map[string]string{"FOO": "foo${BAR}"},
},
{
"does not expand escaped variables",
"FOO=test\nBAR=\"foo\\${FOO} ${FOO}\"",
map[string]string{"FOO": "test", "BAR": "foo${FOO} test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, _, err := Parse(strings.NewReader(tt.input))
if err != nil {
t.Errorf("Error: %s", err.Error())
}
for k, v := range tt.expected {
if strings.Compare(env[k], v) != 0 {
t.Errorf("Expected: %s, Actual: %s", v, env[k])
}
}
})
}
}
func TestVariableStringValueSeparator(t *testing.T) {
input := "TEST_URLS=\"stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443\""
want := map[string]string{
"TEST_URLS": "stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443",
}
got, _, err := Parse(strings.NewReader(input))
if err != nil {
t.Error(err)
}
if len(got) != len(want) {
t.Fatalf(
"unexpected value:\nwant:\n\t%#v\n\ngot:\n\t%#v", want, got)
}
for k, wantVal := range want {
gotVal, ok := got[k]
if !ok {
t.Fatalf("key %q doesn't present in result", k)
}
if wantVal != gotVal {
t.Fatalf(
"mismatch in %q value:\nwant:\n\t%s\n\ngot:\n\t%s", k,
wantVal, gotVal)
}
}
} }
func TestActualEnvVarsAreLeftAlone(t *testing.T) { func TestActualEnvVarsAreLeftAlone(t *testing.T) {
@ -327,46 +138,30 @@ func TestParsing(t *testing.T) {
parseAndCompare(t, "FOO= bar", "FOO", "bar") parseAndCompare(t, "FOO= bar", "FOO", "bar")
// parses double quoted values // parses double quoted values
parseAndCompare(t, `FOO="bar"`, "FOO", "bar") parseAndCompare(t, "FOO=\"bar\"", "FOO", "bar")
// parses single quoted values // parses single quoted values
parseAndCompare(t, "FOO='bar'", "FOO", "bar") parseAndCompare(t, "FOO='bar'", "FOO", "bar")
// parses escaped double quotes // parses escaped double quotes
parseAndCompare(t, `FOO="escaped\"bar"`, "FOO", `escaped"bar`) parseAndCompare(t, "FOO=escaped\\\"bar\"", "FOO", "escaped\"bar")
// parses single quotes inside double quotes
parseAndCompare(t, `FOO="'d'"`, "FOO", `'d'`)
// parses yaml style options // parses yaml style options
parseAndCompare(t, "OPTION_A: 1", "OPTION_A", "1") parseAndCompare(t, "OPTION_A: 1", "OPTION_A", "1")
// parses yaml values with equal signs
parseAndCompare(t, "OPTION_A: Foo=bar", "OPTION_A", "Foo=bar")
// parses non-yaml options with colons
parseAndCompare(t, "OPTION_A=1:B", "OPTION_A", "1:B")
// parses export keyword // parses export keyword
parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2") parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2")
parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\\n") parseAndCompare(t, "export OPTION_B='\\n'", "OPTION_B", "\n")
parseAndCompare(t, "export exportFoo=2", "exportFoo", "2")
parseAndCompare(t, "exportFOO=2", "exportFOO", "2")
parseAndCompare(t, "export_FOO =2", "export_FOO", "2")
parseAndCompare(t, "export.FOO= 2", "export.FOO", "2")
parseAndCompare(t, "export\tOPTION_A=2", "OPTION_A", "2")
parseAndCompare(t, " export OPTION_A=2", "OPTION_A", "2")
parseAndCompare(t, "\texport OPTION_A=2", "OPTION_A", "2")
// it 'expands newlines in quoted strings' do // it 'expands newlines in quoted strings' do
// expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz") // expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz")
parseAndCompare(t, `FOO="bar\nbaz"`, "FOO", "bar\nbaz") parseAndCompare(t, "FOO=\"bar\\nbaz\"", "FOO", "bar\nbaz")
// it 'parses variables with "." in the name' do // it 'parses varibales with "." in the name' do
// expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar') // expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar')
parseAndCompare(t, "FOO.BAR=foobar", "FOO.BAR", "foobar") parseAndCompare(t, "FOO.BAR=foobar", "FOO.BAR", "foobar")
// it 'parses variables with several "=" in the value' do // it 'parses varibales with several "=" in the value' do
// expect(env('FOO=foobar=')).to eql('FOO' => 'foobar=') // expect(env('FOO=foobar=')).to eql('FOO' => 'foobar=')
parseAndCompare(t, "FOO=foobar=", "FOO", "foobar=") parseAndCompare(t, "FOO=foobar=", "FOO", "foobar=")
@ -374,336 +169,54 @@ func TestParsing(t *testing.T) {
// expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar ' // expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar '
parseAndCompare(t, "FOO=bar ", "FOO", "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 // it 'ignores inline comments' do
// expect(env("foo=bar # this is foo")).to eql('foo' => 'bar') // expect(env("foo=bar # this is foo")).to eql('foo' => 'bar')
parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar") parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar")
// it 'allows # in quoted value' do // it 'allows # in quoted value' do
// expect(env('foo="bar#baz" # comment')).to eql('foo' => 'bar#baz') // expect(env('foo="bar#baz" # comment')).to eql('foo' => 'bar#baz')
parseAndCompare(t, `FOO="bar#baz" # comment`, "FOO", "bar#baz") parseAndCompare(t, "FOO=\"bar#baz\" # comment", "FOO", "bar#baz")
parseAndCompare(t, "FOO='bar#baz' # comment", "FOO", "bar#baz") parseAndCompare(t, "FOO='bar#baz' # comment", "FOO", "bar#baz")
parseAndCompare(t, `FOO="bar#baz#bang" # comment`, "FOO", "bar#baz#bang") parseAndCompare(t, "FOO=\"bar#baz#bang\" # comment", "FOO", "bar#baz#bang")
// it 'parses # in quoted values' do // it 'parses # in quoted values' do
// expect(env('foo="ba#r"')).to eql('foo' => 'ba#r') // expect(env('foo="ba#r"')).to eql('foo' => 'ba#r')
// expect(env("foo='ba#r'")).to eql('foo' => 'ba#r') // expect(env("foo='ba#r'")).to eql('foo' => 'ba#r')
parseAndCompare(t, `FOO="ba#r"`, "FOO", "ba#r") parseAndCompare(t, "FOO=\"ba#r\"", "FOO", "ba#r")
parseAndCompare(t, "FOO='ba#r'", "FOO", "ba#r") parseAndCompare(t, "FOO='ba#r'", "FOO", "ba#r")
// newlines and backslashes should be escaped
parseAndCompare(t, `FOO="bar\n\ b\az"`, "FOO", "bar\n baz")
parseAndCompare(t, `FOO="bar\\\n\ b\az"`, "FOO", "bar\\\n baz")
parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz")
parseAndCompare(t, `="value"`, "", "value")
// unquoted whitespace around keys should be ignored
parseAndCompare(t, " KEY =value", "KEY", "value")
parseAndCompare(t, " KEY=value", "KEY", "value")
parseAndCompare(t, "\tKEY=value", "KEY", "value")
// it 'throws an error if line format is incorrect' do // it 'throws an error if line format is incorrect' do
// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError) // expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
badlyFormattedLine := "lol$wut" badlyFormattedLine := "lol$wut"
_, _, err := Unmarshal(badlyFormattedLine) _, _, err := parseLine(badlyFormattedLine)
if err == nil { if err == nil {
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine) t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
} }
} }
func TestLinesToIgnore(t *testing.T) { func TestLinesToIgnore(t *testing.T) {
cases := map[string]struct { // it 'ignores empty lines' do
input string // expect(env("\n \t \nfoo=bar\n \nfizz=buzz")).to eql('foo' => 'bar', 'fizz' => 'buzz')
want string if !isIgnoredLine("\n") {
}{ t.Error("Line with nothing but line break wasn't ignored")
"Line with nothing but line break": {
input: "\n",
},
"Line with nothing but windows-style line break": {
input: "\r\n",
},
"Line full of whitespace": {
input: "\t\t ",
},
"Comment": {
input: "# Comment",
},
"Indented comment": {
input: "\t # comment",
},
"non-ignored value": {
input: `export OPTION_B='\n'`,
want: `export OPTION_B='\n'`,
},
} }
for n, c := range cases { if !isIgnoredLine("\t\t ") {
t.Run(n, func(t *testing.T) { t.Error("Line full of whitespace wasn't ignored")
got := string(getStatementStart([]byte(c.input))) }
if got != c.want {
t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got) // it 'ignores comment lines' do
} // expect(env("\n\n\n # HERE GOES FOO \nfoo=bar")).to eql('foo' => 'bar')
}) if !isIgnoredLine("# comment") {
} t.Error("Comment wasn't ignored")
} }
func TestErrorReadDirectory(t *testing.T) { if !isIgnoredLine("\t#comment") {
envFileName := "fixtures/" t.Error("Indented comment wasn't ignored")
envMap, _, err := Read(envFileName) }
if err == nil { // make sure we're not getting false positives
t.Errorf("Expected error, got %v", envMap) if isIgnoredLine("export OPTION_B='\\n'") {
} t.Error("ignoring a perfectly valid line to parse")
}
func TestErrorParsing(t *testing.T) {
envFileName := "fixtures/invalid1.env"
envMap, _, err := Read(envFileName)
if err == nil {
t.Errorf("Expected error, got %v", envMap)
}
}
func TestComments(t *testing.T) {
envFileName := "fixtures/comments.env"
expectedValues := map[string]string{
"foo": "bar",
"bar": "foo#baz",
"baz": "foo",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestWrite(t *testing.T) {
writeAndCompare := func(env string, expected string) {
envMap, _, _ := Unmarshal(env)
actual, _ := Marshal(envMap)
if expected != actual {
t.Errorf("Expected '%v' (%v) to write as '%v', got '%v' instead.", env, envMap, expected, actual)
}
}
// just test some single lines to show the general idea
// TestRoundtrip makes most of the good assertions
// values are always double-quoted
writeAndCompare(`key=value`, `key="value"`)
// double-quotes are escaped
writeAndCompare(`key=va"lu"e`, `key="va\"lu\"e"`)
// but single quotes are left alone
writeAndCompare(`key=va'lu'e`, `key="va'lu'e"`)
// newlines, backslashes, and some other special chars are escaped
writeAndCompare(`foo="\n\r\\r!"`, `foo="\n\r\\r\!"`)
// lines should be sorted
writeAndCompare("foo=bar\nbaz=buzz", "baz=\"buzz\"\nfoo=\"bar\"")
// integers should not be quoted
writeAndCompare(`key="10"`, `key=10`)
}
func TestRoundtrip(t *testing.T) {
fixtures := []string{"equals.env", "exported.env", "plain.env", "quoted.env"}
for _, fixture := range fixtures {
fixtureFilename := fmt.Sprintf("fixtures/%s", fixture)
env, _, err := readFile(fixtureFilename)
if err != nil {
t.Errorf("Expected '%s' to read without error (%v)", fixtureFilename, err)
}
rep, err := Marshal(env)
if err != nil {
t.Errorf("Expected '%s' to Marshal (%v)", fixtureFilename, err)
}
roundtripped, _, err := Unmarshal(rep)
if err != nil {
t.Errorf("Expected '%s' to Mashal and Unmarshal (%v)", fixtureFilename, err)
}
if !reflect.DeepEqual(env, roundtripped) {
t.Errorf("Expected '%s' to roundtrip as '%v', got '%v' instead", fixtureFilename, env, roundtripped)
}
}
}
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 TestModfiers(t *testing.T) {
cases := map[string]struct {
input string
key string
value string
modifiers map[string]string
}{
"No Modifier": {
input: "A=a",
key: "A",
value: "a",
},
"With comment": {
input: "A=a # my comment",
key: "A",
value: "a",
},
"With single modifier": {
input: "A=a # foo=bar",
key: "A",
value: "a",
modifiers: map[string]string{
"foo": "bar",
},
},
"With multiple modifiers": {
input: "A=a # foo=bar length=10",
key: "A",
value: "a",
modifiers: map[string]string{
"foo": "bar",
"length": "10",
},
},
"With quoted var": {
input: "A='a' # foo=bar",
key: "A",
value: "a",
modifiers: map[string]string{
"foo": "bar",
},
},
"With quoted var 2": {
input: "A='a' # foo=bar\nB=b",
key: "A",
value: "a",
modifiers: map[string]string{
"foo": "bar",
},
},
}
for n, c := range cases {
t.Run(n, func(t *testing.T) {
values, modifiers, err := Unmarshal(c.input)
if err != nil {
t.Errorf("Input: %q Unexpected error:\t%q", c.input, err)
}
if values[c.key] != c.value {
t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, values)
}
if modifiers[c.key] == nil && c.modifiers != nil {
t.Errorf("Input %q Expected modifiers\n Got: none", c.input)
} else {
for k, v := range c.modifiers {
if modifiers[c.key][k] != v {
t.Errorf("Input %q Expected modifier %s=%s\n Got: %s=%s", c.input, k, v, k, modifiers[c.key][k])
}
}
}
})
} }
} }

293
parser.go
View File

@ -1,293 +0,0 @@
package godotenv
import (
"bytes"
"errors"
"fmt"
"regexp"
"strings"
"unicode"
)
const (
charComment = '#'
prefixSingleQuote = '\''
prefixDoubleQuote = '"'
exportPrefix = "export"
)
func parseBytes(src []byte, vars map[string]string, modifiers map[string]map[string]string) error {
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
cutset := src
for {
cutset = getStatementStart(cutset)
if cutset == nil {
// reached end of file
break
}
key, left, err := locateKeyName(cutset)
if err != nil {
return err
}
value, mods, left, err := extractVarValue(left, vars)
if err != nil {
return err
}
vars[key] = value
modifiers[key] = mods
cutset = left
}
return nil
}
// getStatementPosition returns position of statement begin.
//
// It skips any comment line or non-whitespace character.
func getStatementStart(src []byte) []byte {
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)
}
}
// locate key name end and validate it in single loop
offset := 0
loop:
for i, char := range src {
rchar := rune(char)
if isSpace(rchar) {
continue
}
switch char {
case '=', ':':
// library also supports yaml-style value declaration
key = string(src[0:i])
offset = i + 1
break loop
case '_':
default:
// variable name should match [A-Za-z0-9_.]
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
continue
}
return "", nil, fmt.Errorf(
`unexpected character %q in variable name near %q`,
string(char), string(src))
}
}
if len(src) == 0 {
return "", nil, errors.New("zero length string")
}
// trim whitespace
key = strings.TrimRightFunc(key, unicode.IsSpace)
cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
return key, cutset, nil
}
// extractVarValue extracts variable value and returns rest of slice
func extractVarValue(src []byte, vars map[string]string) (value string, modifiers map[string]string, rest []byte, err error) {
quote, hasPrefix := hasQuotePrefix(src)
// 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, nil
}
}
if !hasPrefix {
// 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 "", nil, src[endOfLine:], nil
}
comment := ""
// Work backwards to check if the line ends in whitespace then
// a comment (ie asdasd # some comment)
for i := endOfVar - 1; i >= 0; i-- {
if line[i] == charComment && i > 0 {
comment = string(line[i+1:])
if isSpace(line[i-1]) {
endOfVar = i
break
}
}
}
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
return expandVariables(trimmed, vars), extractModifiers(comment), src[endOfLine:], nil
}
// lookup quoted string terminator
for i := 1; i < len(src); i++ {
if char := src[i]; char != quote {
continue
}
// skip escaped quote symbol (\" or \', depends on quote)
if prevChar := src[i-1]; prevChar == '\\' {
continue
}
// trim quotes
trimFunc := isCharFunc(rune(quote))
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
if quote == prefixDoubleQuote {
// unescape newlines for double quote (this is compat feature)
// and expand environment variables
value = expandVariables(expandEscapes(value), vars)
}
var mods map[string]string
if endOfLine > i {
mods = extractModifiers(string(src[i+1 : endOfLine]))
}
return value, mods, src[i+1:], nil
}
// return formatted error if quoted string is not terminated
valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
if valEndIndex == -1 {
valEndIndex = len(src)
}
return "", nil, nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
}
func extractModifiers(comment string) map[string]string {
if comment == "" {
return nil
}
comment = strings.TrimSpace(comment)
kvpairs := strings.Split(comment, " ")
mods := make(map[string]string)
for _, kv := range kvpairs {
kvsplit := strings.Split(kv, "=")
if len(kvsplit) != 2 {
continue
}
mods[kvsplit[0]] = kvsplit[1]
}
return mods
}
func expandEscapes(str string) string {
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
c := strings.TrimPrefix(match, `\`)
switch c {
case "n":
return "\n"
case "r":
return "\r"
default:
return match
}
})
return unescapeCharsRegex.ReplaceAllString(out, "$1")
}
func indexOfNonSpaceChar(src []byte) int {
return bytes.IndexFunc(src, func(r rune) bool {
return !unicode.IsSpace(r)
})
}
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) {
if len(src) == 0 {
return 0, false
}
switch prefix := src[0]; prefix {
case prefixDoubleQuote, prefixSingleQuote:
return prefix, true
default:
return 0, false
}
}
func isCharFunc(char rune) func(rune) bool {
return func(v rune) bool {
return v == char
}
}
// isSpace reports whether the rune is a space character but not line break character
//
// this differs from unicode.IsSpace, which also applies line break as space
func isSpace(r rune) bool {
switch r {
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
return true
}
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] != "" {
return m[submatch[4]]
}
return s
})
}

1
wercker.yml Normal file
View File

@ -0,0 +1 @@
box: pjvds/golang