├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── LICENCE ├── README.md ├── autoload └── autoload.go ├── cmd └── godotenv │ └── cmd.go ├── fixtures ├── comments.env ├── equals.env ├── exported.env ├── invalid1.env ├── plain.env ├── quoted.env └── substitutions.env ├── go.mod ├── godotenv.go ├── godotenv_test.go └── parser.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: / 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | go: [ '1.20', '1.19', '1.18', '1.17', '1.16' ] 12 | os: [ ubuntu-latest, macOS-latest, windows-latest ] 13 | name: ${{ matrix.os }} Go ${{ matrix.go }} Tests 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: ${{ matrix.go }} 20 | - run: go test 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '31 4 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # 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 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Upload Release Assets 7 | 8 | jobs: 9 | build: 10 | name: Upload Release Assets 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Generate build files 16 | uses: thatisuday/go-cross-build@v1.0.2 17 | with: 18 | platforms: 'linux/amd64, linux/ppc64le, darwin/amd64, darwin/arm64, windows/amd64' 19 | package: 'cmd/godotenv' 20 | name: 'godotenv' 21 | compress: 'true' 22 | dest: 'dist' 23 | - name: Publish Binaries 24 | uses: svenstaro/upload-release-action@v2 25 | with: 26 | repo_token: ${{ secrets.GITHUB_TOKEN }} 27 | release_name: Release ${{ github.ref }} 28 | tag: ${{ github.ref }} 29 | file: dist/* 30 | file_glob: true 31 | overwrite: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 John Barton 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 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) 2 | 3 | A Go (golang) port of the Ruby [dotenv](https://github.com/bkeepers/dotenv) project (which loads env vars from a .env file). 4 | 5 | From the original Library: 6 | 7 | > Storing configuration in the environment is one of the tenets of a twelve-factor app. Anything that is likely to change between deployment environments–such as resource handles for databases or credentials for external services–should be extracted from the code into environment variables. 8 | > 9 | > 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. 10 | 11 | It can be used as a library (for loading in env for your own daemons etc.) or as a bin command. 12 | 13 | There is test coverage and CI for both linuxish and Windows environments, but I make no guarantees about the bin version working on Windows. 14 | 15 | ## Installation 16 | 17 | As a library 18 | 19 | ```shell 20 | go get github.com/joho/godotenv 21 | ``` 22 | 23 | or if you want to use it as a bin command 24 | 25 | go >= 1.17 26 | ```shell 27 | go install github.com/joho/godotenv/cmd/godotenv@latest 28 | ``` 29 | 30 | go < 1.17 31 | ```shell 32 | go get github.com/joho/godotenv/cmd/godotenv 33 | ``` 34 | 35 | ## Usage 36 | 37 | Add your application configuration to your `.env` file in the root of your project: 38 | 39 | ```shell 40 | S3_BUCKET=YOURS3BUCKET 41 | SECRET_KEY=YOURSECRETKEYGOESHERE 42 | ``` 43 | 44 | Then in your Go app you can do something like 45 | 46 | ```go 47 | package main 48 | 49 | import ( 50 | "log" 51 | "os" 52 | 53 | "github.com/joho/godotenv" 54 | ) 55 | 56 | func main() { 57 | err := godotenv.Load() 58 | if err != nil { 59 | log.Fatal("Error loading .env file") 60 | } 61 | 62 | s3Bucket := os.Getenv("S3_BUCKET") 63 | secretKey := os.Getenv("SECRET_KEY") 64 | 65 | // now do something with s3 or whatever 66 | } 67 | ``` 68 | 69 | If you're even lazier than that, you can just take advantage of the autoload package which will read in `.env` on import 70 | 71 | ```go 72 | import _ "github.com/joho/godotenv/autoload" 73 | ``` 74 | 75 | While `.env` in the project root is the default, you don't have to be constrained, both examples below are 100% legit 76 | 77 | ```go 78 | godotenv.Load("somerandomfile") 79 | godotenv.Load("filenumberone.env", "filenumbertwo.env") 80 | ``` 81 | 82 | If you want to be really fancy with your env file you can do comments and exports (below is a valid env file) 83 | 84 | ```shell 85 | # I am a comment and that is OK 86 | SOME_VAR=someval 87 | FOO=BAR # comments at line end are OK too 88 | export BAR=BAZ 89 | ``` 90 | 91 | Or finally you can do YAML(ish) style 92 | 93 | ```yaml 94 | FOO: bar 95 | BAR: baz 96 | ``` 97 | 98 | as a final aside, if you don't want godotenv munging your env you can just get a map back instead 99 | 100 | ```go 101 | var myEnv map[string]string 102 | myEnv, err := godotenv.Read() 103 | 104 | s3Bucket := myEnv["S3_BUCKET"] 105 | ``` 106 | 107 | ... or from an `io.Reader` instead of a local file 108 | 109 | ```go 110 | reader := getRemoteFile() 111 | myEnv, err := godotenv.Parse(reader) 112 | ``` 113 | 114 | ... or from a `string` if you so desire 115 | 116 | ```go 117 | content := getRemoteFileContent() 118 | myEnv, err := godotenv.Unmarshal(content) 119 | ``` 120 | 121 | ### Precedence & Conventions 122 | 123 | Existing envs take precedence of envs that are loaded later. 124 | 125 | The [convention](https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use) 126 | for managing multiple environments (i.e. development, test, production) 127 | is to create an env named `{YOURAPP}_ENV` and load envs in this order: 128 | 129 | ```go 130 | env := os.Getenv("FOO_ENV") 131 | if "" == env { 132 | env = "development" 133 | } 134 | 135 | godotenv.Load(".env." + env + ".local") 136 | if "test" != env { 137 | godotenv.Load(".env.local") 138 | } 139 | godotenv.Load(".env." + env) 140 | godotenv.Load() // The Original .env 141 | ``` 142 | 143 | If you need to, you can also use `godotenv.Overload()` to defy this convention 144 | and overwrite existing envs instead of only supplanting them. Use with caution. 145 | 146 | ### Command Mode 147 | 148 | Assuming you've installed the command as above and you've got `$GOPATH/bin` in your `$PATH` 149 | 150 | ``` 151 | godotenv -f /some/path/to/.env some_command with some args 152 | ``` 153 | 154 | If you don't specify `-f` it will fall back on the default of loading `.env` in `PWD` 155 | 156 | By default, it won't override existing environment variables; you can do that with the `-o` flag. 157 | 158 | ### Writing Env Files 159 | 160 | Godotenv can also write a map representing the environment to a correctly-formatted and escaped file 161 | 162 | ```go 163 | env, err := godotenv.Unmarshal("KEY=value") 164 | err := godotenv.Write(env, "./.env") 165 | ``` 166 | 167 | ... or to a string 168 | 169 | ```go 170 | env, err := godotenv.Unmarshal("KEY=value") 171 | content, err := godotenv.Marshal(env) 172 | ``` 173 | 174 | ## Contributing 175 | 176 | Contributions are welcome, but with some caveats. 177 | 178 | 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. 179 | 180 | Contributions would be gladly accepted that: 181 | 182 | * 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) 183 | * keep the library up to date with the go ecosystem (ie CI bumps, documentation changes, changes in the core libraries) 184 | * bug fixes for use cases that pertain to the library's purpose of easing development of codebases deployed into twelve factor environments 185 | 186 | *code changes without tests and references to peer dotenv implementations will not be accepted* 187 | 188 | 1. Fork it 189 | 2. Create your feature branch (`git checkout -b my-new-feature`) 190 | 3. Commit your changes (`git commit -am 'Added some feature'`) 191 | 4. Push to the branch (`git push origin my-new-feature`) 192 | 5. Create new Pull Request 193 | 194 | ## Releases 195 | 196 | Releases should follow [Semver](http://semver.org/) though the first couple of releases are `v1` and `v1.1`. 197 | 198 | Use [annotated tags for all releases](https://github.com/joho/godotenv/issues/30). Example `git tag -a v1.2.1` 199 | 200 | ## Who? 201 | 202 | 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. 203 | -------------------------------------------------------------------------------- /autoload/autoload.go: -------------------------------------------------------------------------------- 1 | package autoload 2 | 3 | /* 4 | You can just read the .env file on import just by doing 5 | 6 | import _ "github.com/joho/godotenv/autoload" 7 | 8 | And bob's your mother's brother 9 | */ 10 | 11 | import "github.com/joho/godotenv" 12 | 13 | func init() { 14 | godotenv.Load() 15 | } 16 | -------------------------------------------------------------------------------- /cmd/godotenv/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/joho/godotenv" 10 | ) 11 | 12 | func main() { 13 | var showHelp bool 14 | flag.BoolVar(&showHelp, "h", false, "show help") 15 | var rawEnvFilenames string 16 | flag.StringVar(&rawEnvFilenames, "f", "", "comma separated paths to .env files") 17 | var overload bool 18 | flag.BoolVar(&overload, "o", false, "override existing .env variables") 19 | 20 | flag.Parse() 21 | 22 | usage := ` 23 | Run a process with an env setup from a .env file 24 | 25 | godotenv [-o] [-f ENV_FILE_PATHS] COMMAND_ARGS 26 | 27 | ENV_FILE_PATHS: comma separated paths to .env files 28 | COMMAND_ARGS: command and args you want to run 29 | 30 | example 31 | godotenv -f /path/to/something/.env,/another/path/.env fortune 32 | ` 33 | // if no args or -h flag 34 | // print usage and return 35 | args := flag.Args() 36 | if showHelp || len(args) == 0 { 37 | fmt.Println(usage) 38 | return 39 | } 40 | 41 | // load env 42 | var envFilenames []string 43 | if rawEnvFilenames != "" { 44 | envFilenames = strings.Split(rawEnvFilenames, ",") 45 | } 46 | 47 | // take rest of args and "exec" them 48 | cmd := args[0] 49 | cmdArgs := args[1:] 50 | 51 | err := godotenv.Exec(envFilenames, cmd, cmdArgs, overload) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /fixtures/comments.env: -------------------------------------------------------------------------------- 1 | # Full line comment 2 | qux=thud # fred # other 3 | thud=fred#qux # other 4 | fred=qux#baz # other # more 5 | foo=bar # baz 6 | bar=foo#baz 7 | baz="foo"#bar 8 | -------------------------------------------------------------------------------- /fixtures/equals.env: -------------------------------------------------------------------------------- 1 | export OPTION_A='postgres://localhost:5432/database?sslmode=disable' 2 | -------------------------------------------------------------------------------- /fixtures/exported.env: -------------------------------------------------------------------------------- 1 | export OPTION_A=2 2 | export OPTION_B='\n' 3 | -------------------------------------------------------------------------------- /fixtures/invalid1.env: -------------------------------------------------------------------------------- 1 | INVALID LINE 2 | foo=bar 3 | -------------------------------------------------------------------------------- /fixtures/plain.env: -------------------------------------------------------------------------------- 1 | OPTION_A=1 2 | OPTION_B=2 3 | OPTION_C= 3 4 | OPTION_D =4 5 | OPTION_E = 5 6 | OPTION_F = 7 | OPTION_G= 8 | OPTION_H=1 2 -------------------------------------------------------------------------------- /fixtures/quoted.env: -------------------------------------------------------------------------------- 1 | OPTION_A='1' 2 | OPTION_B='2' 3 | OPTION_C='' 4 | OPTION_D='\n' 5 | OPTION_E="1" 6 | OPTION_F="2" 7 | OPTION_G="" 8 | OPTION_H="\n" 9 | OPTION_I = "echo 'asd'" 10 | OPTION_J='line 1 11 | line 2' 12 | OPTION_K='line one 13 | this is \'quoted\' 14 | one more line' 15 | OPTION_L="line 1 16 | line 2" 17 | OPTION_M="line one 18 | this is \"quoted\" 19 | one more line" 20 | -------------------------------------------------------------------------------- /fixtures/substitutions.env: -------------------------------------------------------------------------------- 1 | OPTION_A=1 2 | OPTION_B=${OPTION_A} 3 | OPTION_C=$OPTION_B 4 | OPTION_D=${OPTION_A}${OPTION_B} 5 | OPTION_E=${OPTION_NOT_DEFINED} 6 | OPTION_F=${GLOBAL_OPTION} 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joho/godotenv 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /godotenv.go: -------------------------------------------------------------------------------- 1 | // Package godotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv) 2 | // 3 | // Examples/readme can be found on the GitHub page at https://github.com/joho/godotenv 4 | // 5 | // The TL;DR is that you make a .env file that looks something like 6 | // 7 | // SOME_ENV_VAR=somevalue 8 | // 9 | // and then in your go code you can call 10 | // 11 | // godotenv.Load() 12 | // 13 | // and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR") 14 | package godotenv 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "io" 20 | "os" 21 | "os/exec" 22 | "sort" 23 | "strconv" 24 | "strings" 25 | ) 26 | 27 | const doubleQuoteSpecialChars = "\\\n\r\"!$`" 28 | 29 | // Parse reads an env file from io.Reader, returning a map of keys and values. 30 | func Parse(r io.Reader) (map[string]string, error) { 31 | var buf bytes.Buffer 32 | _, err := io.Copy(&buf, r) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return UnmarshalBytes(buf.Bytes()) 38 | } 39 | 40 | // Load will read your env file(s) and load them into ENV for this process. 41 | // 42 | // Call this function as close as possible to the start of your program (ideally in main). 43 | // 44 | // If you call Load without any args it will default to loading .env in the current path. 45 | // 46 | // You can otherwise tell it which files to load (there can be more than one) like: 47 | // 48 | // godotenv.Load("fileone", "filetwo") 49 | // 50 | // 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. 51 | func Load(filenames ...string) (err error) { 52 | filenames = filenamesOrDefault(filenames) 53 | 54 | for _, filename := range filenames { 55 | err = loadFile(filename, false) 56 | if err != nil { 57 | return // return early on a spazout 58 | } 59 | } 60 | return 61 | } 62 | 63 | // Overload will read your env file(s) and load them into ENV for this process. 64 | // 65 | // Call this function as close as possible to the start of your program (ideally in main). 66 | // 67 | // If you call Overload without any args it will default to loading .env in the current path. 68 | // 69 | // You can otherwise tell it which files to load (there can be more than one) like: 70 | // 71 | // godotenv.Overload("fileone", "filetwo") 72 | // 73 | // It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefully set all vars. 74 | func Overload(filenames ...string) (err error) { 75 | filenames = filenamesOrDefault(filenames) 76 | 77 | for _, filename := range filenames { 78 | err = loadFile(filename, true) 79 | if err != nil { 80 | return // return early on a spazout 81 | } 82 | } 83 | return 84 | } 85 | 86 | // Read all env (with same file loading semantics as Load) but return values as 87 | // a map rather than automatically writing values into env 88 | func Read(filenames ...string) (envMap map[string]string, err error) { 89 | filenames = filenamesOrDefault(filenames) 90 | envMap = make(map[string]string) 91 | 92 | for _, filename := range filenames { 93 | individualEnvMap, individualErr := readFile(filename) 94 | 95 | if individualErr != nil { 96 | err = individualErr 97 | return // return early on a spazout 98 | } 99 | 100 | for key, value := range individualEnvMap { 101 | envMap[key] = value 102 | } 103 | } 104 | 105 | return 106 | } 107 | 108 | // Unmarshal reads an env file from a string, returning a map of keys and values. 109 | func Unmarshal(str string) (envMap map[string]string, err error) { 110 | return UnmarshalBytes([]byte(str)) 111 | } 112 | 113 | // UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values. 114 | func UnmarshalBytes(src []byte) (map[string]string, error) { 115 | out := make(map[string]string) 116 | err := parseBytes(src, out) 117 | 118 | return out, err 119 | } 120 | 121 | // Exec loads env vars from the specified filenames (empty map falls back to default) 122 | // then executes the cmd specified. 123 | // 124 | // Simply hooks up os.Stdin/err/out to the command and calls Run(). 125 | // 126 | // If you want more fine grained control over your command it's recommended 127 | // that you use `Load()`, `Overload()` or `Read()` and the `os/exec` package yourself. 128 | func Exec(filenames []string, cmd string, cmdArgs []string, overload bool) error { 129 | op := Load 130 | if overload { 131 | op = Overload 132 | } 133 | if err := op(filenames...); err != nil { 134 | return err 135 | } 136 | 137 | command := exec.Command(cmd, cmdArgs...) 138 | command.Stdin = os.Stdin 139 | command.Stdout = os.Stdout 140 | command.Stderr = os.Stderr 141 | return command.Run() 142 | } 143 | 144 | // Write serializes the given environment and writes it to a file. 145 | func Write(envMap map[string]string, filename string) error { 146 | content, err := Marshal(envMap) 147 | if err != nil { 148 | return err 149 | } 150 | file, err := os.Create(filename) 151 | if err != nil { 152 | return err 153 | } 154 | defer file.Close() 155 | _, err = file.WriteString(content + "\n") 156 | if err != nil { 157 | return err 158 | } 159 | return file.Sync() 160 | } 161 | 162 | // Marshal outputs the given environment as a dotenv-formatted environment file. 163 | // Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped. 164 | func Marshal(envMap map[string]string) (string, error) { 165 | lines := make([]string, 0, len(envMap)) 166 | for k, v := range envMap { 167 | if d, err := strconv.Atoi(v); err == nil { 168 | lines = append(lines, fmt.Sprintf(`%s=%d`, k, d)) 169 | } else { 170 | lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v))) 171 | } 172 | } 173 | sort.Strings(lines) 174 | return strings.Join(lines, "\n"), nil 175 | } 176 | 177 | func filenamesOrDefault(filenames []string) []string { 178 | if len(filenames) == 0 { 179 | return []string{".env"} 180 | } 181 | return filenames 182 | } 183 | 184 | func loadFile(filename string, overload bool) error { 185 | envMap, err := readFile(filename) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | currentEnv := map[string]bool{} 191 | rawEnv := os.Environ() 192 | for _, rawEnvLine := range rawEnv { 193 | key := strings.Split(rawEnvLine, "=")[0] 194 | currentEnv[key] = true 195 | } 196 | 197 | for key, value := range envMap { 198 | if !currentEnv[key] || overload { 199 | _ = os.Setenv(key, value) 200 | } 201 | } 202 | 203 | return nil 204 | } 205 | 206 | func readFile(filename string) (envMap map[string]string, err error) { 207 | file, err := os.Open(filename) 208 | if err != nil { 209 | return 210 | } 211 | defer file.Close() 212 | 213 | return Parse(file) 214 | } 215 | 216 | func doubleQuoteEscape(line string) string { 217 | for _, c := range doubleQuoteSpecialChars { 218 | toReplace := "\\" + string(c) 219 | if c == '\n' { 220 | toReplace = `\n` 221 | } 222 | if c == '\r' { 223 | toReplace = `\r` 224 | } 225 | line = strings.Replace(line, string(c), toReplace, -1) 226 | } 227 | return line 228 | } 229 | -------------------------------------------------------------------------------- /godotenv_test.go: -------------------------------------------------------------------------------- 1 | package godotenv 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | var noopPresets = make(map[string]string) 13 | 14 | func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) { 15 | result, err := Unmarshal(rawEnvLine) 16 | 17 | if err != nil { 18 | t.Errorf("Expected %q to parse as %q: %q, errored %q", rawEnvLine, expectedKey, expectedValue, err) 19 | return 20 | } 21 | if result[expectedKey] != expectedValue { 22 | t.Errorf("Expected '%v' to parse as '%v' => '%v', got %q instead", rawEnvLine, expectedKey, expectedValue, result) 23 | } 24 | } 25 | 26 | func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, envFileName string, expectedValues map[string]string, presets map[string]string) { 27 | // first up, clear the env 28 | os.Clearenv() 29 | 30 | for k, v := range presets { 31 | os.Setenv(k, v) 32 | } 33 | 34 | err := loader(envFileName) 35 | if err != nil { 36 | t.Fatalf("Error loading %v", envFileName) 37 | } 38 | 39 | for k := range expectedValues { 40 | envValue := os.Getenv(k) 41 | v := expectedValues[k] 42 | if envValue != v { 43 | t.Errorf("Mismatch for key '%v': expected '%#v' got '%#v'", k, v, envValue) 44 | } 45 | } 46 | } 47 | 48 | func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) { 49 | err := Load() 50 | pathError := err.(*os.PathError) 51 | if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { 52 | t.Errorf("Didn't try and open .env by default") 53 | } 54 | } 55 | 56 | func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) { 57 | err := Overload() 58 | pathError := err.(*os.PathError) 59 | if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { 60 | t.Errorf("Didn't try and open .env by default") 61 | } 62 | } 63 | 64 | func TestLoadFileNotFound(t *testing.T) { 65 | err := Load("somefilethatwillneverexistever.env") 66 | if err == nil { 67 | t.Error("File wasn't found but Load didn't return an error") 68 | } 69 | } 70 | 71 | func TestOverloadFileNotFound(t *testing.T) { 72 | err := Overload("somefilethatwillneverexistever.env") 73 | if err == nil { 74 | t.Error("File wasn't found but Overload didn't return an error") 75 | } 76 | } 77 | 78 | func TestReadPlainEnv(t *testing.T) { 79 | envFileName := "fixtures/plain.env" 80 | expectedValues := map[string]string{ 81 | "OPTION_A": "1", 82 | "OPTION_B": "2", 83 | "OPTION_C": "3", 84 | "OPTION_D": "4", 85 | "OPTION_E": "5", 86 | "OPTION_F": "", 87 | "OPTION_G": "", 88 | "OPTION_H": "1 2", 89 | } 90 | 91 | envMap, err := Read(envFileName) 92 | if err != nil { 93 | t.Error("Error reading file") 94 | } 95 | 96 | if len(envMap) != len(expectedValues) { 97 | t.Error("Didn't get the right size map back") 98 | } 99 | 100 | for key, value := range expectedValues { 101 | if envMap[key] != value { 102 | t.Error("Read got one of the keys wrong") 103 | } 104 | } 105 | } 106 | 107 | func TestParse(t *testing.T) { 108 | envMap, err := Parse(bytes.NewReader([]byte("ONE=1\nTWO='2'\nTHREE = \"3\""))) 109 | expectedValues := map[string]string{ 110 | "ONE": "1", 111 | "TWO": "2", 112 | "THREE": "3", 113 | } 114 | if err != nil { 115 | t.Fatalf("error parsing env: %v", err) 116 | } 117 | for key, value := range expectedValues { 118 | if envMap[key] != value { 119 | t.Errorf("expected %s to be %s, got %s", key, value, envMap[key]) 120 | } 121 | } 122 | } 123 | 124 | func TestLoadDoesNotOverride(t *testing.T) { 125 | envFileName := "fixtures/plain.env" 126 | 127 | // ensure NO overload 128 | presets := map[string]string{ 129 | "OPTION_A": "do_not_override", 130 | "OPTION_B": "", 131 | } 132 | 133 | expectedValues := map[string]string{ 134 | "OPTION_A": "do_not_override", 135 | "OPTION_B": "", 136 | } 137 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets) 138 | } 139 | 140 | func TestOverloadDoesOverride(t *testing.T) { 141 | envFileName := "fixtures/plain.env" 142 | 143 | // ensure NO overload 144 | presets := map[string]string{ 145 | "OPTION_A": "do_not_override", 146 | } 147 | 148 | expectedValues := map[string]string{ 149 | "OPTION_A": "1", 150 | } 151 | loadEnvAndCompareValues(t, Overload, envFileName, expectedValues, presets) 152 | } 153 | 154 | func TestLoadPlainEnv(t *testing.T) { 155 | envFileName := "fixtures/plain.env" 156 | expectedValues := map[string]string{ 157 | "OPTION_A": "1", 158 | "OPTION_B": "2", 159 | "OPTION_C": "3", 160 | "OPTION_D": "4", 161 | "OPTION_E": "5", 162 | "OPTION_H": "1 2", 163 | } 164 | 165 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) 166 | } 167 | 168 | func TestLoadExportedEnv(t *testing.T) { 169 | envFileName := "fixtures/exported.env" 170 | expectedValues := map[string]string{ 171 | "OPTION_A": "2", 172 | "OPTION_B": "\\n", 173 | } 174 | 175 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) 176 | } 177 | 178 | func TestLoadEqualsEnv(t *testing.T) { 179 | envFileName := "fixtures/equals.env" 180 | expectedValues := map[string]string{ 181 | "OPTION_A": "postgres://localhost:5432/database?sslmode=disable", 182 | } 183 | 184 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) 185 | } 186 | 187 | func TestLoadQuotedEnv(t *testing.T) { 188 | envFileName := "fixtures/quoted.env" 189 | expectedValues := map[string]string{ 190 | "OPTION_A": "1", 191 | "OPTION_B": "2", 192 | "OPTION_C": "", 193 | "OPTION_D": "\\n", 194 | "OPTION_E": "1", 195 | "OPTION_F": "2", 196 | "OPTION_G": "", 197 | "OPTION_H": "\n", 198 | "OPTION_I": "echo 'asd'", 199 | "OPTION_J": "line 1\nline 2", 200 | "OPTION_K": "line one\nthis is \\'quoted\\'\none more line", 201 | "OPTION_L": "line 1\nline 2", 202 | "OPTION_M": "line one\nthis is \"quoted\"\none more line", 203 | } 204 | 205 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) 206 | } 207 | 208 | func TestSubstitutions(t *testing.T) { 209 | envFileName := "fixtures/substitutions.env" 210 | 211 | presets := map[string]string{ 212 | "GLOBAL_OPTION": "global", 213 | } 214 | 215 | expectedValues := map[string]string{ 216 | "OPTION_A": "1", 217 | "OPTION_B": "1", 218 | "OPTION_C": "1", 219 | "OPTION_D": "11", 220 | "OPTION_E": "", 221 | "OPTION_F": "global", 222 | } 223 | 224 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets) 225 | } 226 | 227 | func TestExpanding(t *testing.T) { 228 | tests := []struct { 229 | name string 230 | input string 231 | expected map[string]string 232 | }{ 233 | { 234 | "expands variables found in values", 235 | "FOO=test\nBAR=$FOO", 236 | map[string]string{"FOO": "test", "BAR": "test"}, 237 | }, 238 | { 239 | "parses variables wrapped in brackets", 240 | "FOO=test\nBAR=${FOO}bar", 241 | map[string]string{"FOO": "test", "BAR": "testbar"}, 242 | }, 243 | { 244 | "expands undefined variables to an empty string", 245 | "BAR=$FOO", 246 | map[string]string{"BAR": ""}, 247 | }, 248 | { 249 | "expands variables in double quoted strings", 250 | "FOO=test\nBAR=\"quote $FOO\"", 251 | map[string]string{"FOO": "test", "BAR": "quote test"}, 252 | }, 253 | { 254 | "does not expand variables in single quoted strings", 255 | "BAR='quote $FOO'", 256 | map[string]string{"BAR": "quote $FOO"}, 257 | }, 258 | { 259 | "does not expand escaped variables", 260 | `FOO="foo\$BAR"`, 261 | map[string]string{"FOO": "foo$BAR"}, 262 | }, 263 | { 264 | "does not expand escaped variables", 265 | `FOO="foo\${BAR}"`, 266 | map[string]string{"FOO": "foo${BAR}"}, 267 | }, 268 | { 269 | "does not expand escaped variables", 270 | "FOO=test\nBAR=\"foo\\${FOO} ${FOO}\"", 271 | map[string]string{"FOO": "test", "BAR": "foo${FOO} test"}, 272 | }, 273 | } 274 | 275 | for _, tt := range tests { 276 | t.Run(tt.name, func(t *testing.T) { 277 | env, err := Parse(strings.NewReader(tt.input)) 278 | if err != nil { 279 | t.Errorf("Error: %s", err.Error()) 280 | } 281 | for k, v := range tt.expected { 282 | if strings.Compare(env[k], v) != 0 { 283 | t.Errorf("Expected: %s, Actual: %s", v, env[k]) 284 | } 285 | } 286 | }) 287 | } 288 | } 289 | 290 | func TestVariableStringValueSeparator(t *testing.T) { 291 | input := "TEST_URLS=\"stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443\"" 292 | want := map[string]string{ 293 | "TEST_URLS": "stratum+tcp://stratum.antpool.com:3333\nstratum+tcp://stratum.antpool.com:443", 294 | } 295 | got, err := Parse(strings.NewReader(input)) 296 | if err != nil { 297 | t.Error(err) 298 | } 299 | 300 | if len(got) != len(want) { 301 | t.Fatalf( 302 | "unexpected value:\nwant:\n\t%#v\n\ngot:\n\t%#v", want, got) 303 | } 304 | 305 | for k, wantVal := range want { 306 | gotVal, ok := got[k] 307 | if !ok { 308 | t.Fatalf("key %q doesn't present in result", k) 309 | } 310 | if wantVal != gotVal { 311 | t.Fatalf( 312 | "mismatch in %q value:\nwant:\n\t%s\n\ngot:\n\t%s", k, 313 | wantVal, gotVal) 314 | } 315 | } 316 | } 317 | 318 | func TestActualEnvVarsAreLeftAlone(t *testing.T) { 319 | os.Clearenv() 320 | os.Setenv("OPTION_A", "actualenv") 321 | _ = Load("fixtures/plain.env") 322 | 323 | if os.Getenv("OPTION_A") != "actualenv" { 324 | t.Error("An ENV var set earlier was overwritten") 325 | } 326 | } 327 | 328 | func TestParsing(t *testing.T) { 329 | // unquoted values 330 | parseAndCompare(t, "FOO=bar", "FOO", "bar") 331 | 332 | // parses values with spaces around equal sign 333 | parseAndCompare(t, "FOO =bar", "FOO", "bar") 334 | parseAndCompare(t, "FOO= bar", "FOO", "bar") 335 | 336 | // parses double quoted values 337 | parseAndCompare(t, `FOO="bar"`, "FOO", "bar") 338 | 339 | // parses single quoted values 340 | parseAndCompare(t, "FOO='bar'", "FOO", "bar") 341 | 342 | // parses escaped double quotes 343 | parseAndCompare(t, `FOO="escaped\"bar"`, "FOO", `escaped"bar`) 344 | 345 | // parses single quotes inside double quotes 346 | parseAndCompare(t, `FOO="'d'"`, "FOO", `'d'`) 347 | 348 | // parses yaml style options 349 | parseAndCompare(t, "OPTION_A: 1", "OPTION_A", "1") 350 | 351 | //parses yaml values with equal signs 352 | parseAndCompare(t, "OPTION_A: Foo=bar", "OPTION_A", "Foo=bar") 353 | 354 | // parses non-yaml options with colons 355 | parseAndCompare(t, "OPTION_A=1:B", "OPTION_A", "1:B") 356 | 357 | // parses export keyword 358 | parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2") 359 | parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\\n") 360 | parseAndCompare(t, "export exportFoo=2", "exportFoo", "2") 361 | parseAndCompare(t, "exportFOO=2", "exportFOO", "2") 362 | parseAndCompare(t, "export_FOO =2", "export_FOO", "2") 363 | parseAndCompare(t, "export.FOO= 2", "export.FOO", "2") 364 | parseAndCompare(t, "export\tOPTION_A=2", "OPTION_A", "2") 365 | parseAndCompare(t, " export OPTION_A=2", "OPTION_A", "2") 366 | parseAndCompare(t, "\texport OPTION_A=2", "OPTION_A", "2") 367 | 368 | // it 'expands newlines in quoted strings' do 369 | // expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz") 370 | parseAndCompare(t, `FOO="bar\nbaz"`, "FOO", "bar\nbaz") 371 | 372 | // it 'parses variables with "." in the name' do 373 | // expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar') 374 | parseAndCompare(t, "FOO.BAR=foobar", "FOO.BAR", "foobar") 375 | 376 | // it 'parses variables with several "=" in the value' do 377 | // expect(env('FOO=foobar=')).to eql('FOO' => 'foobar=') 378 | parseAndCompare(t, "FOO=foobar=", "FOO", "foobar=") 379 | 380 | // it 'strips unquoted values' do 381 | // expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar ' 382 | parseAndCompare(t, "FOO=bar ", "FOO", "bar") 383 | 384 | // unquoted internal whitespace is preserved 385 | parseAndCompare(t, `KEY=value value`, "KEY", "value value") 386 | 387 | // it 'ignores inline comments' do 388 | // expect(env("foo=bar # this is foo")).to eql('foo' => 'bar') 389 | parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar") 390 | 391 | // it 'allows # in quoted value' do 392 | // expect(env('foo="bar#baz" # comment')).to eql('foo' => 'bar#baz') 393 | parseAndCompare(t, `FOO="bar#baz" # comment`, "FOO", "bar#baz") 394 | parseAndCompare(t, "FOO='bar#baz' # comment", "FOO", "bar#baz") 395 | parseAndCompare(t, `FOO="bar#baz#bang" # comment`, "FOO", "bar#baz#bang") 396 | 397 | // it 'parses # in quoted values' do 398 | // expect(env('foo="ba#r"')).to eql('foo' => 'ba#r') 399 | // expect(env("foo='ba#r'")).to eql('foo' => 'ba#r') 400 | parseAndCompare(t, `FOO="ba#r"`, "FOO", "ba#r") 401 | parseAndCompare(t, "FOO='ba#r'", "FOO", "ba#r") 402 | 403 | //newlines and backslashes should be escaped 404 | parseAndCompare(t, `FOO="bar\n\ b\az"`, "FOO", "bar\n baz") 405 | parseAndCompare(t, `FOO="bar\\\n\ b\az"`, "FOO", "bar\\\n baz") 406 | parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz") 407 | 408 | parseAndCompare(t, `="value"`, "", "value") 409 | 410 | // unquoted whitespace around keys should be ignored 411 | parseAndCompare(t, " KEY =value", "KEY", "value") 412 | parseAndCompare(t, " KEY=value", "KEY", "value") 413 | parseAndCompare(t, "\tKEY=value", "KEY", "value") 414 | 415 | // it 'throws an error if line format is incorrect' do 416 | // expect{env('lol$wut')}.to raise_error(Dotenv::FormatError) 417 | badlyFormattedLine := "lol$wut" 418 | _, err := Unmarshal(badlyFormattedLine) 419 | if err == nil { 420 | t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine) 421 | } 422 | } 423 | 424 | func TestLinesToIgnore(t *testing.T) { 425 | cases := map[string]struct { 426 | input string 427 | want string 428 | }{ 429 | "Line with nothing but line break": { 430 | input: "\n", 431 | }, 432 | "Line with nothing but windows-style line break": { 433 | input: "\r\n", 434 | }, 435 | "Line full of whitespace": { 436 | input: "\t\t ", 437 | }, 438 | "Comment": { 439 | input: "# Comment", 440 | }, 441 | "Indented comment": { 442 | input: "\t # comment", 443 | }, 444 | "non-ignored value": { 445 | input: `export OPTION_B='\n'`, 446 | want: `export OPTION_B='\n'`, 447 | }, 448 | } 449 | 450 | for n, c := range cases { 451 | t.Run(n, func(t *testing.T) { 452 | got := string(getStatementStart([]byte(c.input))) 453 | if got != c.want { 454 | t.Errorf("Expected:\t %q\nGot:\t %q", c.want, got) 455 | } 456 | }) 457 | } 458 | } 459 | 460 | func TestErrorReadDirectory(t *testing.T) { 461 | envFileName := "fixtures/" 462 | envMap, err := Read(envFileName) 463 | 464 | if err == nil { 465 | t.Errorf("Expected error, got %v", envMap) 466 | } 467 | } 468 | 469 | func TestErrorParsing(t *testing.T) { 470 | envFileName := "fixtures/invalid1.env" 471 | envMap, err := Read(envFileName) 472 | if err == nil { 473 | t.Errorf("Expected error, got %v", envMap) 474 | } 475 | } 476 | 477 | func TestComments(t *testing.T) { 478 | envFileName := "fixtures/comments.env" 479 | expectedValues := map[string]string{ 480 | "qux": "thud", 481 | "thud": "fred#qux", 482 | "fred": "qux#baz", 483 | "foo": "bar", 484 | "bar": "foo#baz", 485 | "baz": "foo", 486 | } 487 | 488 | loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) 489 | } 490 | 491 | func TestWrite(t *testing.T) { 492 | writeAndCompare := func(env string, expected string) { 493 | envMap, _ := Unmarshal(env) 494 | actual, _ := Marshal(envMap) 495 | if expected != actual { 496 | t.Errorf("Expected '%v' (%v) to write as '%v', got '%v' instead.", env, envMap, expected, actual) 497 | } 498 | } 499 | //just test some single lines to show the general idea 500 | //TestRoundtrip makes most of the good assertions 501 | 502 | //values are always double-quoted 503 | writeAndCompare(`key=value`, `key="value"`) 504 | //double-quotes are escaped 505 | writeAndCompare(`key=va"lu"e`, `key="va\"lu\"e"`) 506 | //but single quotes are left alone 507 | writeAndCompare(`key=va'lu'e`, `key="va'lu'e"`) 508 | // newlines, backslashes, and some other special chars are escaped 509 | writeAndCompare(`foo="\n\r\\r!"`, `foo="\n\r\\r\!"`) 510 | // lines should be sorted 511 | writeAndCompare("foo=bar\nbaz=buzz", "baz=\"buzz\"\nfoo=\"bar\"") 512 | // integers should not be quoted 513 | writeAndCompare(`key="10"`, `key=10`) 514 | 515 | } 516 | 517 | func TestRoundtrip(t *testing.T) { 518 | fixtures := []string{"equals.env", "exported.env", "plain.env", "quoted.env"} 519 | for _, fixture := range fixtures { 520 | fixtureFilename := fmt.Sprintf("fixtures/%s", fixture) 521 | env, err := readFile(fixtureFilename) 522 | if err != nil { 523 | t.Errorf("Expected '%s' to read without error (%v)", fixtureFilename, err) 524 | } 525 | rep, err := Marshal(env) 526 | if err != nil { 527 | t.Errorf("Expected '%s' to Marshal (%v)", fixtureFilename, err) 528 | } 529 | roundtripped, err := Unmarshal(rep) 530 | if err != nil { 531 | t.Errorf("Expected '%s' to Mashal and Unmarshal (%v)", fixtureFilename, err) 532 | } 533 | if !reflect.DeepEqual(env, roundtripped) { 534 | t.Errorf("Expected '%s' to roundtrip as '%v', got '%v' instead", fixtureFilename, env, roundtripped) 535 | } 536 | 537 | } 538 | } 539 | 540 | func TestTrailingNewlines(t *testing.T) { 541 | cases := map[string]struct { 542 | input string 543 | key string 544 | value string 545 | }{ 546 | "Simple value without trailing newline": { 547 | input: "KEY=value", 548 | key: "KEY", 549 | value: "value", 550 | }, 551 | "Value with internal whitespace without trailing newline": { 552 | input: "KEY=value value", 553 | key: "KEY", 554 | value: "value value", 555 | }, 556 | "Value with internal whitespace with trailing newline": { 557 | input: "KEY=value value\n", 558 | key: "KEY", 559 | value: "value value", 560 | }, 561 | "YAML style - value with internal whitespace without trailing newline": { 562 | input: "KEY: value value", 563 | key: "KEY", 564 | value: "value value", 565 | }, 566 | "YAML style - value with internal whitespace with trailing newline": { 567 | input: "KEY: value value\n", 568 | key: "KEY", 569 | value: "value value", 570 | }, 571 | } 572 | 573 | for n, c := range cases { 574 | t.Run(n, func(t *testing.T) { 575 | result, err := Unmarshal(c.input) 576 | if err != nil { 577 | t.Errorf("Input: %q Unexpected error:\t%q", c.input, err) 578 | } 579 | if result[c.key] != c.value { 580 | t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, result) 581 | } 582 | }) 583 | } 584 | } 585 | 586 | func TestWhitespace(t *testing.T) { 587 | cases := map[string]struct { 588 | input string 589 | key string 590 | value string 591 | }{ 592 | "Leading whitespace": { 593 | input: " A=a\n", 594 | key: "A", 595 | value: "a", 596 | }, 597 | "Leading tab": { 598 | input: "\tA=a\n", 599 | key: "A", 600 | value: "a", 601 | }, 602 | "Leading mixed whitespace": { 603 | input: " \t \t\n\t \t A=a\n", 604 | key: "A", 605 | value: "a", 606 | }, 607 | "Leading whitespace before export": { 608 | input: " \t\t export A=a\n", 609 | key: "A", 610 | value: "a", 611 | }, 612 | "Trailing whitespace": { 613 | input: "A=a \t \t\n", 614 | key: "A", 615 | value: "a", 616 | }, 617 | "Trailing whitespace with export": { 618 | input: "export A=a\t \t \n", 619 | key: "A", 620 | value: "a", 621 | }, 622 | "No EOL": { 623 | input: "A=a", 624 | key: "A", 625 | value: "a", 626 | }, 627 | "Trailing whitespace with no EOL": { 628 | input: "A=a ", 629 | key: "A", 630 | value: "a", 631 | }, 632 | } 633 | 634 | for n, c := range cases { 635 | t.Run(n, func(t *testing.T) { 636 | result, err := Unmarshal(c.input) 637 | if err != nil { 638 | t.Errorf("Input: %q Unexpected error:\t%q", c.input, err) 639 | } 640 | if result[c.key] != c.value { 641 | t.Errorf("Input %q Expected:\t %q/%q\nGot:\t %q", c.input, c.key, c.value, result) 642 | } 643 | }) 644 | } 645 | } 646 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package godotenv 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "regexp" 9 | "strings" 10 | "unicode" 11 | ) 12 | 13 | const ( 14 | charComment = '#' 15 | prefixSingleQuote = '\'' 16 | prefixDoubleQuote = '"' 17 | 18 | exportPrefix = "export" 19 | ) 20 | 21 | func parseBytes(src []byte, out map[string]string) error { 22 | src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1) 23 | cutset := src 24 | for { 25 | cutset = getStatementStart(cutset) 26 | if cutset == nil { 27 | // reached end of file 28 | break 29 | } 30 | 31 | key, left, err := locateKeyName(cutset) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | value, left, err := extractVarValue(left, out) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | out[key] = value 42 | cutset = left 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // getStatementPosition returns position of statement begin. 49 | // 50 | // It skips any comment line or non-whitespace character. 51 | func getStatementStart(src []byte) []byte { 52 | pos := indexOfNonSpaceChar(src) 53 | if pos == -1 { 54 | return nil 55 | } 56 | 57 | src = src[pos:] 58 | if src[0] != charComment { 59 | return src 60 | } 61 | 62 | // skip comment section 63 | pos = bytes.IndexFunc(src, isCharFunc('\n')) 64 | if pos == -1 { 65 | return nil 66 | } 67 | 68 | return getStatementStart(src[pos:]) 69 | } 70 | 71 | // locateKeyName locates and parses key name and returns rest of slice 72 | func locateKeyName(src []byte) (key string, cutset []byte, err error) { 73 | // trim "export" and space at beginning 74 | src = bytes.TrimLeftFunc(src, isSpace) 75 | if bytes.HasPrefix(src, []byte(exportPrefix)) { 76 | trimmed := bytes.TrimPrefix(src, []byte(exportPrefix)) 77 | if bytes.IndexFunc(trimmed, isSpace) == 0 { 78 | src = bytes.TrimLeftFunc(trimmed, isSpace) 79 | } 80 | } 81 | 82 | // locate key name end and validate it in single loop 83 | offset := 0 84 | loop: 85 | for i, char := range src { 86 | rchar := rune(char) 87 | if isSpace(rchar) { 88 | continue 89 | } 90 | 91 | switch char { 92 | case '=', ':': 93 | // library also supports yaml-style value declaration 94 | key = string(src[0:i]) 95 | offset = i + 1 96 | break loop 97 | case '_': 98 | default: 99 | // variable name should match [A-Za-z0-9_.] 100 | if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' { 101 | continue 102 | } 103 | 104 | return "", nil, fmt.Errorf( 105 | `unexpected character %q in variable name near %q`, 106 | string(char), string(src)) 107 | } 108 | } 109 | 110 | if len(src) == 0 { 111 | return "", nil, errors.New("zero length string") 112 | } 113 | 114 | // trim whitespace 115 | key = strings.TrimRightFunc(key, unicode.IsSpace) 116 | cutset = bytes.TrimLeftFunc(src[offset:], isSpace) 117 | return key, cutset, nil 118 | } 119 | 120 | // extractVarValue extracts variable value and returns rest of slice 121 | func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) { 122 | quote, hasPrefix := hasQuotePrefix(src) 123 | if !hasPrefix { 124 | // unquoted value - read until end of line 125 | endOfLine := bytes.IndexFunc(src, isLineEnd) 126 | 127 | // Hit EOF without a trailing newline 128 | if endOfLine == -1 { 129 | endOfLine = len(src) 130 | 131 | if endOfLine == 0 { 132 | return "", nil, nil 133 | } 134 | } 135 | 136 | // Convert line to rune away to do accurate countback of runes 137 | line := []rune(string(src[0:endOfLine])) 138 | 139 | // Assume end of line is end of var 140 | endOfVar := len(line) 141 | if endOfVar == 0 { 142 | return "", src[endOfLine:], nil 143 | } 144 | 145 | // Work backwards to check if the line ends in whitespace then 146 | // a comment, ie: foo=bar # baz # other 147 | for i := 0; i < endOfVar; i++ { 148 | if line[i] == charComment && i < endOfVar { 149 | if isSpace(line[i-1]) { 150 | endOfVar = i 151 | break 152 | } 153 | } 154 | } 155 | 156 | trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace) 157 | 158 | return expandVariables(trimmed, vars), src[endOfLine:], nil 159 | } 160 | 161 | // lookup quoted string terminator 162 | for i := 1; i < len(src); i++ { 163 | if char := src[i]; char != quote { 164 | continue 165 | } 166 | 167 | // skip escaped quote symbol (\" or \', depends on quote) 168 | if prevChar := src[i-1]; prevChar == '\\' { 169 | continue 170 | } 171 | 172 | // trim quotes 173 | trimFunc := isCharFunc(rune(quote)) 174 | value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc)) 175 | if quote == prefixDoubleQuote { 176 | // unescape newlines for double quote (this is compat feature) 177 | // and expand environment variables 178 | value = expandVariables(expandEscapes(value), vars) 179 | } 180 | 181 | return value, src[i+1:], nil 182 | } 183 | 184 | // return formatted error if quoted string is not terminated 185 | valEndIndex := bytes.IndexFunc(src, isCharFunc('\n')) 186 | if valEndIndex == -1 { 187 | valEndIndex = len(src) 188 | } 189 | 190 | return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex]) 191 | } 192 | 193 | func expandEscapes(str string) string { 194 | out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string { 195 | c := strings.TrimPrefix(match, `\`) 196 | switch c { 197 | case "n": 198 | return "\n" 199 | case "r": 200 | return "\r" 201 | default: 202 | return match 203 | } 204 | }) 205 | return unescapeCharsRegex.ReplaceAllString(out, "$1") 206 | } 207 | 208 | func indexOfNonSpaceChar(src []byte) int { 209 | return bytes.IndexFunc(src, func(r rune) bool { 210 | return !unicode.IsSpace(r) 211 | }) 212 | } 213 | 214 | // hasQuotePrefix reports whether charset starts with single or double quote and returns quote character 215 | func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) { 216 | if len(src) == 0 { 217 | return 0, false 218 | } 219 | 220 | switch prefix := src[0]; prefix { 221 | case prefixDoubleQuote, prefixSingleQuote: 222 | return prefix, true 223 | default: 224 | return 0, false 225 | } 226 | } 227 | 228 | func isCharFunc(char rune) func(rune) bool { 229 | return func(v rune) bool { 230 | return v == char 231 | } 232 | } 233 | 234 | // isSpace reports whether the rune is a space character but not line break character 235 | // 236 | // this differs from unicode.IsSpace, which also applies line break as space 237 | func isSpace(r rune) bool { 238 | switch r { 239 | case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0: 240 | return true 241 | } 242 | return false 243 | } 244 | 245 | func isLineEnd(r rune) bool { 246 | if r == '\n' || r == '\r' { 247 | return true 248 | } 249 | return false 250 | } 251 | 252 | var ( 253 | escapeRegex = regexp.MustCompile(`\\.`) 254 | expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`) 255 | unescapeCharsRegex = regexp.MustCompile(`\\([^$])`) 256 | ) 257 | 258 | func expandVariables(v string, m map[string]string) string { 259 | return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string { 260 | submatch := expandVarRegex.FindStringSubmatch(s) 261 | 262 | if submatch == nil { 263 | return s 264 | } 265 | if submatch[1] == "\\" || submatch[2] == "(" { 266 | return submatch[0][1:] 267 | } else if submatch[4] != "" { 268 | if val, ok := m[submatch[4]]; ok { 269 | return val 270 | } 271 | if val, ok := os.LookupEnv(submatch[4]); ok { 272 | return val 273 | } 274 | return m[submatch[4]] 275 | } 276 | return s 277 | }) 278 | } 279 | --------------------------------------------------------------------------------