Compare commits

..

91 Commits

Author SHA1 Message Date
41e88df24e Add action to publish binaries on each release 2020-11-11 15:02:30 +11:00
65218afbaa Add a go.mod file (#123) 2020-11-11 14:54:56 +11:00
aa035a1808 Non amd tests (#122)
* Add non-amd64 tests

* Try shifting env around

* Update input to match action

* Include git

* Comment out IBM Z for now and quieten apt-get
2020-11-11 12:54:49 +11:00
23296b91aa Merge pull request #121 from joho/github-actions
Setup GitHub actions for CI
2020-11-11 11:33:11 +11:00
e57c08db27 Add CI badge to readme 2020-11-11 11:31:34 +11:00
573e9186b2 Matrix test across mac/win/linux 2020-11-11 11:24:40 +11:00
5451d82b77 Add Barebones github actions test 2020-11-11 11:19:52 +11:00
0da8ce72f0 Merge pull request #115 from adombeck/master
Fix typo
2020-11-08 12:01:29 +11:00
3e4069b9b2 Merge pull request #109 from mniak/pr
Write ints without quotes
2020-11-08 11:58:27 +11:00
a4d9cf1d6d Merge pull request #120 from santosh653/master
PR_AddingPowerSupport_golang-github-joho-godotenv
2020-10-16 06:51:22 +11:00
7fbe752d59 Update .travis.yml
adding power support.
2020-10-15 02:09:51 -04:00
46ee0dcae8 Update .travis.yml
Adding arch: amd64
2020-10-15 00:58:50 -04:00
bc7d5cd181 Fix typo 2020-09-04 23:35:59 +02:00
6e653f9adf add newline back 2020-06-26 15:39:50 -03:00
fccdfd265d Fix package name 2020-06-26 15:39:06 -03:00
f4e7418908 Remove go.mod 2020-06-26 15:36:35 -03:00
63ea8bf09b Change package name back to joho/godotenv 2020-06-26 15:35:47 -03:00
29b5be9cdc Rename and implement int-able without quotes 2020-06-26 15:22:04 -03:00
d6ee6871f2 Merge pull request #95 from orxobo/Write-adjustment
Fixed Write bugs
2020-03-02 07:46:15 +11:00
dbcf4b53b8 Fixed Write bugs
This should address a fix #93 and #94
This has not been addressed in any tests.
2020-02-11 17:05:34 +10:00
b09de681dc Merge pull request #90 from djherbis/master
#89 move regexp.MustCompile to globals
2019-10-21 07:25:22 +11:00
992ab0ec47 #89 move regexp.MustCompile to globals 2019-10-18 10:35:15 -04:00
5c0e6c6ab1 Merge pull request #69 from hairyhenderson/ignore-leading-whitespace
Fixing a couple whitespace bugs: ignoring leading whitespace, and supporting more kinds of empty lines
2019-02-04 15:41:09 +11:00
61baafa627 Merge branch 'master' into ignore-leading-whitespace 2019-02-04 15:28:23 +11:00
823f94bb9a Merge pull request #70 from hairyhenderson/support-keys-beginning-with-export-66
Support key names beginning with 'export'
2019-02-04 15:26:24 +11:00
263a1dda9d Support key names beginning with 'export'
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
2019-02-03 23:11:51 -05:00
79711eebaf Ignoring leading whitespace
Signed-off-by: Dave Henderson <dhenderson@gmail.com>
2019-02-03 22:39:38 -05:00
69ed1d913a Merge pull request #63 from mdanzinger/patch-1
Update cmd.go
2018-11-21 06:47:48 +11:00
2841430efc An even tinier grammar fix
I read `.env` aloud as dotenv so the A makes sense.
2018-11-21 06:41:23 +11:00
5917dd2291 Update cmd.go
Tiny little grammatical error.
2018-11-20 13:25:32 -05:00
c0b86d615e Merge pull request #60 from coolaj86/precedence
explicitly state env precedence and convention
2018-09-27 15:32:03 +10:00
3896766f7d explicitly state env precedence and convention 2018-09-24 21:35:43 -06:00
23d116af35 Merge pull request #58 from lucastetreault/master
feat(Expand Variables): Custom variable expansion instead of Go's os.Expand
2018-09-11 18:04:31 +10:00
2d8b3aab88 feat(Expand Variables): Custom variable expansion instead of Go's os.Expand
Copy over the tests from https://github.com/bkeepers/dotenv/blob/master/spec/dotenv/parser_spec.rb
related to expanding variables and implement the required changes. I also realized as part of this
that this implementation was not handling values in single quotes properly (e.g.: not the same was
as the ruby package mentionned) so that has been fixed as well along with the related tests.

Fixes: #52
2018-09-11 00:55:10 -06:00
1709ab122c Merge pull request #54 from egorse/master
The value expand fallback to actual ENV values
2018-04-05 15:36:34 +10:00
8ad714e304 The value expand fallback to actual ENV values 2018-03-31 23:18:36 +03:00
6bb0851667 Merge pull request #47 from sachaos/feature-expand-variables-on-value
Support variable substitution in dotenv files
2018-01-15 13:49:21 +11:00
06e67b5ef3 Update my homepage link 2017-12-23 10:35:39 +11:00
2707e9ff66 Fix test, $ should not be escaped 2017-11-20 23:41:46 +09:00
50c29652a0 Expand variables on parseValue 2017-11-20 23:20:38 +09:00
33977c2d8d Add test for substitutions 2017-11-20 23:20:19 +09:00
9be76b3741 Pass envMap to parseLine & parseValue 2017-11-20 22:21:39 +09:00
6d367c18ed Merge pull request #46 from joho/fix_quoting_parser_bug
WIP Parsing bug with nested quotes
2017-11-10 12:03:15 +11:00
05be8ccbf7 Try and replicate reported bug #45 2017-11-10 10:38:18 +11:00
0f92a24bb0 Merge pull request #44 from dvrkps/patch-1
travis: update go version
2017-11-02 13:11:18 +11:00
b7bbb3624e travis: update go version 2017-11-01 07:18:32 +01:00
a79fa1e548 Merge pull request #41 from alexquick/document-write-dotenv
Document Marshal, Unmarshal, and Write
2017-09-18 16:32:59 +10:00
144189c1ed Merge pull request #42 from alexquick/feature-sorted-output
Sort output of Marshal/Write
2017-09-18 16:32:10 +10:00
3dd2dbe832 sort output of Write/Marshal 2017-09-16 18:02:27 -04:00
9f04f40640 Be more careful with TestRoundtrip 2017-09-16 17:55:04 -04:00
e6264cf869 document Marshal, Unmarshal, and Write 2017-09-16 17:12:55 -04:00
9739509bea Merge pull request #35 from alexquick/feature-write-dotenv
support for writing envs out in dotenv format
2017-09-13 22:32:30 -07:00
b1bb9d9fc3 rename WriteString/ReadString to Marshal/Unmarshal 2017-09-14 00:24:22 -04:00
5d289f4405 escape some other bash-y special chars ($!) 2017-09-13 23:13:08 -04:00
88e7c8bd35 support for writing envs out in dotenv format 2017-09-13 23:13:08 -04:00
c9360df4d1 Merge pull request #34 from alexquick/fix-parsing-issues
Fix some small parsing bugs
2017-08-22 14:21:26 +10:00
59f20222da Merge pull request #33 from crash7/go-report-card
Add Go Report Card badge and fix spelling error
2017-08-22 14:17:51 +10:00
9d9ddadf44 Merge pull request #36 from pda/parse-from-reader
Parse(reader) as alternative to Read(filenames)
2017-08-06 18:28:30 +10:00
390de3704e README.md mentions Parse(io.Reader) 2017-08-06 17:34:10 +10:00
ebf1036af6 Parse(io.Reader) => map[string]string 2017-08-06 17:34:10 +10:00
a905e99577 fix panic with " as the value 2017-07-16 18:43:49 -04:00
6f30f0c011 support for equals in yaml-style lines 2017-07-16 17:25:28 -04:00
84bf91f40e rudimentry support for nested quotes 2017-07-16 17:24:36 -04:00
b9324c6f3c handle escaping more comprehensively 2017-07-16 17:15:29 -04:00
12b7e03247 Add Go Report Card badge and fix spelling error 2017-07-14 21:33:04 -03:00
3ddb2792f3 README housekeeping 2017-07-05 14:31:29 +10:00
325433c502 Merge pull request #29 from joho/respect_empty_external_env_vars
Respect preset empty external env vars
2017-03-29 07:01:54 +11:00
034acc2190 Change check of existing env to respect empty (but set) vars. 2017-03-28 11:54:56 +11:00
cd1272609d Add failing test for override of empty var 2017-03-28 11:39:40 +11:00
eaf676fc03 Merge pull request #27 from goenning/empty_var
allow usage of empty var on .env
2017-03-23 07:07:31 +11:00
a42a65518c allow empty_var 2017-03-22 13:05:44 +00:00
b01826f956 Merge pull request #25 from matiasanaya/master
Fix quoted values check
2017-03-21 20:56:48 +11:00
6a1233b2f6 Fix quoted values check 2017-03-21 19:04:19 +11:00
d10b3fbe00 Merge pull request #24 from joho/setup_travis
Move CI
2017-02-22 08:49:41 +11:00
0a959c8d8f Add a badge for the windows build too. 2017-02-22 08:43:40 +11:00
bcaccd4f68 Apparently this file is meant to be hidden? 2017-02-22 08:29:43 +11:00
22e45bfff4 Switch build badge over to travis 2017-02-22 08:27:57 +11:00
2fc79dff51 Replace wercker.yml with travis.yml 2017-02-22 08:23:25 +11:00
726cc8b906 Merge pull request #22 from mmilata/dont-swallow-errors
Improve error handling
2016-12-17 10:05:37 +11:00
861984c215 Don't hide line parsing errors 2016-12-12 14:43:30 +01:00
0ff0c0fc7a Propagate errors encountered when reading file 2016-12-12 14:41:36 +01:00
4ed13390c0 Merge pull request #13 from jmervine/master
Add Overload methods.
2015-09-07 11:02:28 +10:00
008304c688 adding Overload method 2015-09-05 08:59:08 -07:00
443e926da0 Merge pull request #11 from buddhamagnet/master
Remove unecessary assignment in autoloader
2015-06-10 07:30:23 +10:00
2ed25fcb28 remove unecessary assignment in autoloader 2015-06-09 18:24:15 +01:00
f6581828bb outdent else because golint said so. 2015-03-23 12:17:14 +11:00
d29c003c20 Still trying to please golint with package comments. 2015-03-23 12:15:55 +11:00
19b5c2bf30 Some golint feedback from http://goreportcard.com/report/joho/godotenv 2015-03-23 12:15:01 +11:00
e1c92610d7 run gofmt -w -s ./.. 2015-03-23 12:06:31 +11:00
ead2e75027 Merge pull request #8 from calavera/add_values_to_envmap
Fix issue reading file.
2015-01-02 15:46:44 +11:00
dc9cc93c4e Add values to the envMap when reading the file.
But do not override values in the global environment.
2014-12-23 17:57:02 -08:00
13 changed files with 690 additions and 109 deletions

62
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,62 @@
name: CI
on: [push]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
go: [ '1.15', '1.14' ]
os: [ ubuntu-latest, macOS-latest, windows-latest ]
name: ${{ matrix.os }} Go ${{ matrix.go }} Tests
steps:
- uses: actions/checkout@v2
- name: Setup go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- run: go test
test-non-amd64:
strategy:
fail-fast: false
matrix:
arch:
# For some reasons this is segfaulting on go env
# - name: IBM Z and LinuxONE
# architecture: "s390x"
- name: POWER8
architecture: "ppc64le"
runs-on: ubuntu-latest
name: Test on ${{ matrix.arch.name }}
steps:
- uses: actions/checkout@v2
- uses: uraimo/run-on-arch-action@master
with:
arch: ${{ matrix.arch.architecture }}
distro: ubuntu20.04
env: | # YAML pipe
GOARCH: ${{ matrix.arch.architecture }}
CGO_ENABLED: 0
run: |
apt-get update
apt-get install -q -y curl wget git
latestGo=$(curl "https://golang.org/VERSION?m=text")
wget "https://dl.google.com/go/${latestGo}.linux-${GOARCH}.tar.gz"
rm -f $(which go)
rm -rf /usr/local/go
tar -C /usr/local -xzf "${latestGo}.linux-${GOARCH}.tar.gz"
export PATH=/usr/local/go/bin:$PATH
printf "Using go at: $(which go)\n"
printf "Go version: $(go version)\n"
printf "\n\nGo environment:\n\n"
go env
printf "\n\nSystem environment:\n\n"
env
go get -insecure -v -t -d ./...
go test ./...
cd ./cmd/godotenv
go build -trimpath -ldflags="-w -s" -v

42
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,42 @@
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@v2
- name: Generate build files
uses: thatisuday/go-cross-build@v1
with:
platforms: 'linux/amd64, linux/ppc64le, darwin/amd64, windows/amd64'
package: 'cmd/godotenv'
name: 'godotenv'
compress: 'true'
dest: 'dist'
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: ${{ contains(github.ref, 'pre') }}
- name: Upload Release Assets
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
file: dist/*
overwrite: true
file_glob: true

View File

@ -1,4 +1,4 @@
# GoDotEnv [![wercker status](https://app.wercker.com/status/507594c2ec7e60f19403a568dfea0f78 "wercker status")](https://app.wercker.com/project/bykey/507594c2ec7e60f19403a568dfea0f78)
# 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)
A Go (golang) port of the Ruby dotenv project (which loads env vars from a .env file)
@ -96,6 +96,45 @@ myEnv, err := godotenv.Read()
s3Bucket := myEnv["S3_BUCKET"]
```
... or from an `io.Reader` instead of a local file
```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`
@ -106,6 +145,22 @@ 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`
### 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
Contributions are most welcome! The parser itself is pretty stupidly naive and I wouldn't be surprised if it breaks with edge cases.
@ -118,10 +173,16 @@ Contributions are most welcome! The parser itself is pretty stupidly naive and I
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request
## Releases
Releases should follow [Semver](http://semver.org/) though the first couple of releases are `v1` and `v1.1`.
Use [annotated tags for all releases](https://github.com/joho/godotenv/issues/30). Example `git tag -a v1.2.1`
## CI
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)
Linux: [![Build Status](https://travis-ci.org/joho/godotenv.svg?branch=master)](https://travis-ci.org/joho/godotenv) Windows: [![Build status](https://ci.appveyor.com/api/projects/status/9v40vnfvvgde64u4)](https://ci.appveyor.com/project/joho/godotenv)
## 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](http://whoisjohnbarton.com) 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](https://johnbarton.co/) based off the tests/fixtures in the original library.

View File

@ -11,5 +11,5 @@ package autoload
import "github.com/joho/godotenv"
func init() {
_ = godotenv.Load()
godotenv.Load()
}

View File

@ -19,7 +19,7 @@ func main() {
flag.Parse()
usage := `
Run a process with a env setup from a .env file
Run a process with an env setup from a .env file
godotenv [-f ENV_FILE_PATHS] COMMAND_ARGS
@ -45,7 +45,7 @@ example
// take rest of args and "exec" them
cmd := args[0]
cmdArgs := args[1:len(args)]
cmdArgs := args[1:]
err := godotenv.Exec(envFilenames, cmd, cmdArgs)
if err != nil {

2
fixtures/invalid1.env Normal file
View File

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

View File

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

View File

@ -6,3 +6,4 @@ OPTION_E="1"
OPTION_F="2"
OPTION_G=""
OPTION_H="\n"
OPTION_I = "echo 'asd'"

View File

@ -0,0 +1,5 @@
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 Normal file
View File

@ -0,0 +1,3 @@
module github.com/joho/godotenv
go 1.12

View File

@ -1,44 +1,49 @@
/*
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
The TL;DR is that you make a .env file that looks something like
SOME_ENV_VAR=somevalue
and then in your go code you can call
godotenv.Load()
and all the env vars declared in .env will be avaiable through os.Getenv("SOME_ENV_VAR")
*/
// Package godotenv is 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
//
// The TL;DR is that you make a .env file that looks something like
//
// SOME_ENV_VAR=somevalue
//
// and then in your go code you can call
//
// godotenv.Load()
//
// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
package godotenv
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
)
/*
Call this function as close as possible to the start of your program (ideally in main)
const doubleQuoteSpecialChars = "\\\n\r\"!$`"
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
*/
// Load will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
//
// 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) {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err = loadFile(filename)
err = loadFile(filename, false)
if err != nil {
return // return early on a spazout
}
@ -46,10 +51,31 @@ func Load(filenames ...string) (err error) {
return
}
/*
Read all env (with same file loading semantics as Load) but return values as
a map rather than automatically writing values into env
*/
// Overload will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main)
//
// If you call Overload 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.Overload("fileone", "filetwo")
//
// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefilly 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, err error) {
filenames = filenamesOrDefault(filenames)
envMap = make(map[string]string)
@ -70,15 +96,46 @@ func Read(filenames ...string) (envMap map[string]string, err error) {
return
}
/*
Loads env vars from the specified filenames (empty map falls back to default)
then executes the cmd specified.
// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (envMap map[string]string, err error) {
envMap = make(map[string]string)
Simply hooks up os.Stdin/err/out to the command and calls Run()
var lines []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
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.
*/
if err = scanner.Err(); err != nil {
return
}
for _, fullLine := range lines {
if !isIgnoredLine(fullLine) {
var key, value string
key, value, err = parseLine(fullLine, envMap)
if err != nil {
return
}
envMap[key] = value
}
}
return
}
//Unmarshal reads an env file from a string, returning a map of keys and values.
func Unmarshal(str string) (envMap map[string]string, err error) {
return Parse(strings.NewReader(str))
}
// Exec loads env vars from the specified filenames (empty map falls back to default)
// then executes the cmd specified.
//
// 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()` or `Read()` and the `os/exec` package yourself.
func Exec(filenames []string, cmd string, cmdArgs []string) error {
Load(filenames...)
@ -89,25 +146,67 @@ func Exec(filenames []string, cmd string, cmdArgs []string) error {
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)
if err != nil {
return err
}
file.Sync()
return err
}
// 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 {
if len(filenames) == 0 {
return []string{".env"}
} else {
return filenames
}
return filenames
}
func loadFile(filename string) (err error) {
func loadFile(filename string, overload bool) error {
envMap, err := readFile(filename)
if err != nil {
return
return err
}
currentEnv := map[string]bool{}
rawEnv := os.Environ()
for _, rawEnvLine := range rawEnv {
key := strings.Split(rawEnvLine, "=")[0]
currentEnv[key] = true
}
for key, value := range envMap {
os.Setenv(key, value)
if !currentEnv[key] || overload {
os.Setenv(key, value)
}
}
return
return nil
}
func readFile(filename string) (envMap map[string]string, err error) {
@ -117,27 +216,12 @@ func readFile(filename string) (envMap map[string]string, err error) {
}
defer file.Close()
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
return Parse(file)
}
func parseLine(line string) (key string, value string, err error) {
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
@ -147,7 +231,7 @@ func parseLine(line string) (key string, value string, err error) {
if strings.Contains(line, "#") {
segmentsBetweenHashes := strings.Split(line, "#")
quotesAreOpen := false
segmentsToKeep := make([]string, 0)
var segmentsToKeep []string
for _, segment := range segmentsBetweenHashes {
if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 {
if quotesAreOpen {
@ -166,11 +250,11 @@ func parseLine(line string) (key string, value string, err error) {
line = strings.Join(segmentsToKeep, "#")
}
// now split key from value
firstEquals := strings.Index(line, "=")
firstColon := strings.Index(line, ":")
splitString := strings.SplitN(line, "=", 2)
if len(splitString) != 2 {
// try yaml mode!
if firstColon != -1 && (firstColon < firstEquals || firstEquals == -1) {
//this is a yaml-style line
splitString = strings.SplitN(line, ":", 2)
}
@ -184,28 +268,96 @@ func parseLine(line string) (key string, value string, err error) {
if strings.HasPrefix(key, "export") {
key = strings.TrimPrefix(key, "export")
}
key = strings.Trim(key, " ")
key = strings.TrimSpace(key)
key = exportRegex.ReplaceAllString(splitString[0], "$1")
// 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)
}
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 isIgnoredLine(line string) bool {
trimmedLine := strings.Trim(line, " \n\t")
trimmedLine := strings.TrimSpace(line)
return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#")
}
func doubleQuoteEscape(line string) string {
for _, c := range doubleQuoteSpecialChars {
toReplace := "\\" + string(c)
if c == '\n' {
toReplace = `\n`
}
if c == '\r' {
toReplace = `\r`
}
line = strings.Replace(line, string(c), toReplace, -1)
}
return line
}

View File

@ -1,22 +1,32 @@
package godotenv
import (
"bytes"
"fmt"
"os"
"reflect"
"strings"
"testing"
)
var noopPresets = make(map[string]string)
func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) {
key, value, _ := parseLine(rawEnvLine)
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)
}
}
func loadEnvAndCompareValues(t *testing.T, envFileName string, expectedValues map[string]string) {
func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, envFileName string, expectedValues map[string]string, presets map[string]string) {
// first up, clear the env
os.Clearenv()
err := Load(envFileName)
for k, v := range presets {
os.Setenv(k, v)
}
err := loader(envFileName)
if err != nil {
t.Fatalf("Error loading %v", envFileName)
}
@ -38,6 +48,14 @@ 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) {
err := Load("somefilethatwillneverexistever.env")
if err == nil {
@ -45,6 +63,13 @@ 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) {
envFileName := "fixtures/plain.env"
expectedValues := map[string]string{
@ -53,6 +78,8 @@ func TestReadPlainEnv(t *testing.T) {
"OPTION_C": "3",
"OPTION_D": "4",
"OPTION_E": "5",
"OPTION_F": "",
"OPTION_G": "",
}
envMap, err := Read(envFileName)
@ -71,6 +98,53 @@ 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 TestOveroadDoesOverride(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) {
envFileName := "fixtures/plain.env"
expectedValues := map[string]string{
@ -81,17 +155,17 @@ func TestLoadPlainEnv(t *testing.T) {
"OPTION_E": "5",
}
loadEnvAndCompareValues(t, envFileName, expectedValues)
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestLoadExportedEnv(t *testing.T) {
envFileName := "fixtures/exported.env"
expectedValues := map[string]string{
"OPTION_A": "2",
"OPTION_B": "\n",
"OPTION_B": "\\n",
}
loadEnvAndCompareValues(t, envFileName, expectedValues)
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestLoadEqualsEnv(t *testing.T) {
@ -100,7 +174,7 @@ func TestLoadEqualsEnv(t *testing.T) {
"OPTION_A": "postgres://localhost:5432/database?sslmode=disable",
}
loadEnvAndCompareValues(t, envFileName, expectedValues)
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
func TestLoadQuotedEnv(t *testing.T) {
@ -109,14 +183,92 @@ func TestLoadQuotedEnv(t *testing.T) {
"OPTION_A": "1",
"OPTION_B": "2",
"OPTION_C": "",
"OPTION_D": "\n",
"OPTION_D": "\\n",
"OPTION_E": "1",
"OPTION_F": "2",
"OPTION_G": "",
"OPTION_H": "\n",
"OPTION_I": "echo 'asd'",
}
loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets)
}
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])
}
}
})
}
loadEnvAndCompareValues(t, envFileName, expectedValues)
}
func TestActualEnvVarsAreLeftAlone(t *testing.T) {
@ -138,24 +290,40 @@ func TestParsing(t *testing.T) {
parseAndCompare(t, "FOO= bar", "FOO", "bar")
// parses double quoted values
parseAndCompare(t, "FOO=\"bar\"", "FOO", "bar")
parseAndCompare(t, `FOO="bar"`, "FOO", "bar")
// parses single quoted values
parseAndCompare(t, "FOO='bar'", "FOO", "bar")
// 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
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
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
// 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 varibales with "." in the name' do
// expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar')
@ -175,20 +343,34 @@ func TestParsing(t *testing.T) {
// it 'allows # in quoted value' do
// 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#bang\" # comment", "FOO", "bar#baz#bang")
parseAndCompare(t, `FOO="bar#baz#bang" # comment`, "FOO", "bar#baz#bang")
// 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')
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")
parseAndCompare(t, `KEY="`, "KEY", "\"")
parseAndCompare(t, `KEY="value`, "KEY", "\"value")
// leading whitespace 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
// expect{env('lol$wut')}.to raise_error(Dotenv::FormatError)
badlyFormattedLine := "lol$wut"
_, _, err := parseLine(badlyFormattedLine)
_, _, err := parseLine(badlyFormattedLine, noopPresets)
if err == nil {
t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine)
}
@ -201,6 +383,10 @@ func TestLinesToIgnore(t *testing.T) {
t.Error("Line with nothing but line break wasn't ignored")
}
if !isIgnoredLine("\r\n") {
t.Error("Line with nothing but windows-style line break wasn't ignored")
}
if !isIgnoredLine("\t\t ") {
t.Error("Line full of whitespace wasn't ignored")
}
@ -216,7 +402,73 @@ func TestLinesToIgnore(t *testing.T) {
}
// make sure we're not getting false positives
if isIgnoredLine("export OPTION_B='\\n'") {
if isIgnoredLine(`export OPTION_B='\n'`) {
t.Error("ignoring a perfectly valid line to parse")
}
}
func TestErrorReadDirectory(t *testing.T) {
envFileName := "fixtures/"
envMap, err := Read(envFileName)
if err == nil {
t.Errorf("Expected error, got %v", envMap)
}
}
func TestErrorParsing(t *testing.T) {
envFileName := "fixtures/invalid1.env"
envMap, err := Read(envFileName)
if err == nil {
t.Errorf("Expected error, got %v", envMap)
}
}
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)
}
}
}

View File

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