Skip to content

Commit

Permalink
feat: add ParseFile function
Browse files Browse the repository at this point in the history
  • Loading branch information
SameerJadav committed Aug 3, 2024
1 parent 31c0fb5 commit 824600d
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 66 deletions.
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# GoEnvParse
# EnvParse

[![Go Reference](https://pkg.go.dev/badge/github.com/SameerJadav/go-envparse.svg)](https://pkg.go.dev/github.com/SameerJadav/go-envparse) [![CI](https://github.com/SameerJadav/go-envparse/actions/workflows/ci.yml/badge.svg)](https://github.com/SameerJadav/go-envparse/actions/workflows/ci.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/SameerJadav/envparse.svg)](https://pkg.go.dev/github.com/SameerJadav/envparse) [![CI](https://github.com/SameerJadav/envparse/actions/workflows/ci.yml/badge.svg)](https://github.com/SameerJadav/envparse/actions/workflows/ci.yml)

GoEnvParse is a Go package for parsing environment variables from `.env` files. It provides a simple and efficient way to load environment variables from `.env` file into your Go applications.
EnvParse is a Go package designed for efficiently parsing environment variables from `.env` files. It provides a straightforward and performant way to load environment variables into your Go applications.

## Features

- Parse environment files from any `io.Reader` source
- Parse environment files directly from a file
- Handling of quoted values (double quotes, single quotes, and backticks)
- Variable expansion in non-quoted and double-quoted values
- Error reporting with line numbers for invalid syntax
Expand All @@ -16,24 +17,28 @@ GoEnvParse is a Go package for parsing environment variables from `.env` files.
- Double-quoted values are unescaped, including unicode characters
- Single-quoted and backtick-quoted values are treated as literal strings
- Variable expansion is performed in non-quoted and double-quoted values
- Any empty keys, commented lines (lines prefixed with `#`), and invalid lines (lines that are not comments and do not have an `=` sign) will not be parsed
- Inline comments (e.g., `KEY=VALUE#inline comment`) are removed from the value; if a value must contain a `#`, the value must be quoted (e.g., `KEY="VALUE#with hash"`)
- Does not support multiline values

## Installation

```shell
go get github.com/SameerJadav/go-envparse
go get github.com/SameerJadav/envparse
```

## Usage

Parse from `io.Reader`

```go
package main

import (
"log"
"os"

"github.com/SameerJadav/go-envparse"
"github.com/SameerJadav/envparse"
)

func main() {
Expand All @@ -54,6 +59,30 @@ func main() {
}
```

Parse from File

```go
package main

import (
"log"
"os"

"github.com/SameerJadav/envparse"
)

func main() {
env, err := envparse.ParseFile(".env")
if err != nil {
log.Fatal(err)
}

for key, value := range env {
os.Setenv(key, value)
}
}
```

## Contributing

Contributions are welcome. Please open an issue or submit a pull request.
Expand Down
25 changes: 17 additions & 8 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,17 @@ package envparse

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
)

// Parse reads an env file from io.Reader, returning a map of keys-value pairs and an error.
//
// Only double-quoted values are escaped. Single-quoted and backquoted values
// are treated as literal strings. Variable expansion (${...} and $...)
// is performed in non-quoted and double-quoted values.
//
// Note: This function does not support multiline values.
// Each key-value pair must be on a single line.
// Parse reads an environment variables file from the provided io.Reader and
// returns a map of key-value pairs. The function also returns an error if
// any issues are encountered during parsing.
func Parse(r io.Reader) (map[string]string, error) {
result := make(map[string]string)
scanner := bufio.NewScanner(r)
Expand Down Expand Up @@ -78,6 +74,19 @@ func Parse(r io.Reader) (map[string]string, error) {
return result, nil
}

// ParseFile reads an environment variables file from the specified filename
// and returns a map of key-value pairs. The function also returns an error
// if any issues are encountered during file reading or parsing.
//
// This function uses the [Parse] function internally to process the file contents.
func ParseFile(filename string) (map[string]string, error) {
content, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read env file %s: %w", filename, err)
}
return Parse(bytes.NewReader(content))
}

func isQuoted(value string) (byte, int, int, bool) {
if len(value) < 2 {
return 0, -1, -1, false
Expand Down
146 changes: 93 additions & 53 deletions parser_test.go
Original file line number Diff line number Diff line change
@@ -1,73 +1,113 @@
package envparse

import (
"bytes"
"os"
"testing"
)

var expected = map[string]string{
"SIMPLE_VAR": "value",
"EMPTY": "",
"EMPTY_SINGLE_QUOTES": "",
"EMPTY_DOUBLE_QUOTES": "",
"EMPTY_BACKTICKS": "",
"QUOTED_VAR": "value",
"SINGLE_QUOTED_VAR": "value",
"BACKQUOTED_VAR": "value",
"DOUBLE_QUOTES_SPACED": " double quotes ",
"SINGLE_QUOTES_SPACED": " single quotes ",
"BACKQUOTE_SPACED": " back quotes ",
"UNQUOTED_WITH_SPACES": "this has spaces",
"NESTED_IN_DOUBLE": "This is a 'single quote' and a `backtick` inside double quotes",
"NESTED_IN_SINGLE": "This is a \"double quote\" and a `backtick` inside single quotes",
"NESTED_IN_BACKTICK": "This is a \"double quote\" and a 'single quote' inside backticks",
"ESCAPED_IN_DOUBLE": "This has \"escaped double quotes\"",
"ESCAPED_IN_SINGLE": "This has \\'escaped single quotes\\'",
"MIXED_QUOTES": "Double with 'single' and \"escaped double\" and `backtick`",
"UNQUOTED_NEWLINE": "This is a line\\nAnd this is another line",
"DOUBLE_QUOTED_NEWLINE": "This is in double quotes\nThis is a new line in double quotes",
"SINGLE_QUOTED_NEWLINE": "This is in single quotes\\nThis is a new line in single quotes",
"BACKTICK_QUOTED_NEWLINE": "This is in backticks\\nThis is a new line in backticks",
"VARIABLE_EXPANSION": "value/expansion",
"NESTED_EXPANSION": "value/expansion/nested",
"VARIABLE_EXPANSION_ALT": "value/expansion",
"NESTED_EXPANSION_ALT": "value/expansion/nested",
"DOUBLEQUOTED_VARIABLE_EXPANSION": "value/expansion",
"DOUBLEQUOTED_NESTED_EXPANSION": "value/expansion/nested",
"DOUBLEQUOTED_VARIABLE_EXPANSION_ALT": "value/expansion",
"DOUBLEQUOTED_NESTED_EXPANSION_ALT": "value/expansion/nested",
"SINGLEQUOTED_VARIABLE_EXPANSION": "${SIMPLE_VAR}/expansion",
"SINGLEQUOTED_NESTED_EXPANSION": "${VARIABLE_EXPANSION}/nested",
"SINGLEQUOTED_VARIABLE_EXPANSION_ALT": "$SIMPLE_VAR/expansion",
"SINGLEQUOTED_NESTED_EXPANSION_ALT": "$VARIABLE_EXPANSION/nested",
"BACKQUOTED_VARIABLE_EXPANSION": "${SIMPLE_VAR}/expansion",
"BACKQUOTED_NESTED_EXPANSION": "${VARIABLE_EXPANSION}/nested",
"BACKQUOTED_VARIABLE_EXPANSION_ALT": "$SIMPLE_VAR/expansion",
"BACKQUOTED_NESTED_EXPANSION_ALT": "$VARIABLE_EXPANSION/nested",
"UNMATCHED_DOUBLEQUOTE": "\"value",
"UNMATCHED_SINGLEQUOTE": "'value",
"UNMATCHED_BACKQUOTE": "`value",
"INLINE_COMMENTS": "value",
"INLINE_COMMENTS_DOUBLE_QUOTES": "inline comments outside of #doublequotes",
"INLINE_COMMENTS_SINGLE_QUOTES": "inline comments outside of #singlequotes",
"INLINE_COMMENTS_BACKQUOTES": "inline comments outside of #backticks",
"EXPORTED_VAR": "value",
"EQUAL_SIGNS": "equals==",
"SPACED_KEY": "value",
}

func TestParse(t *testing.T) {
expected := map[string]string{
"SIMPLE_VAR": "value",
"EMPTY": "",
"EMPTY_SINGLE_QUOTES": "",
"EMPTY_DOUBLE_QUOTES": "",
"EMPTY_BACKTICKS": "",
"QUOTED_VAR": "value",
"SINGLE_QUOTED_VAR": "value",
"BACKQUOTED_VAR": "value",
"DOUBLE_QUOTES_SPACED": " double quotes ",
"SINGLE_QUOTES_SPACED": " single quotes ",
"BACKQUOTE_SPACED": " back quotes ",
"UNQUOTED_WITH_SPACES": "this has spaces",
"NESTED_IN_DOUBLE": "This is a 'single quote' and a `backtick` inside double quotes",
"NESTED_IN_SINGLE": "This is a \"double quote\" and a `backtick` inside single quotes",
"NESTED_IN_BACKTICK": "This is a \"double quote\" and a 'single quote' inside backticks",
"ESCAPED_IN_DOUBLE": "This has \"escaped double quotes\"",
"ESCAPED_IN_SINGLE": "This has \\'escaped single quotes\\'",
"MIXED_QUOTES": "Double with 'single' and \"escaped double\" and `backtick`",
"UNQUOTED_NEWLINE": "This is a line\\nAnd this is another line",
"DOUBLE_QUOTED_NEWLINE": "This is in double quotes\nThis is a new line in double quotes",
"SINGLE_QUOTED_NEWLINE": "This is in single quotes\\nThis is a new line in single quotes",
"BACKTICK_QUOTED_NEWLINE": "This is in backticks\\nThis is a new line in backticks",
"VARIABLE_EXPANSION": "value/expansion",
"NESTED_EXPANSION": "value/expansion/nested",
"VARIABLE_EXPANSION_ALT": "value/expansion",
"NESTED_EXPANSION_ALT": "value/expansion/nested",
"DOUBLEQUOTED_VARIABLE_EXPANSION": "value/expansion",
"DOUBLEQUOTED_NESTED_EXPANSION": "value/expansion/nested",
"DOUBLEQUOTED_VARIABLE_EXPANSION_ALT": "value/expansion",
"DOUBLEQUOTED_NESTED_EXPANSION_ALT": "value/expansion/nested",
"SINGLEQUOTED_VARIABLE_EXPANSION": "${SIMPLE_VAR}/expansion",
"SINGLEQUOTED_NESTED_EXPANSION": "${VARIABLE_EXPANSION}/nested",
"SINGLEQUOTED_VARIABLE_EXPANSION_ALT": "$SIMPLE_VAR/expansion",
"SINGLEQUOTED_NESTED_EXPANSION_ALT": "$VARIABLE_EXPANSION/nested",
"BACKQUOTED_VARIABLE_EXPANSION": "${SIMPLE_VAR}/expansion",
"BACKQUOTED_NESTED_EXPANSION": "${VARIABLE_EXPANSION}/nested",
"BACKQUOTED_VARIABLE_EXPANSION_ALT": "$SIMPLE_VAR/expansion",
"BACKQUOTED_NESTED_EXPANSION_ALT": "$VARIABLE_EXPANSION/nested",
"UNMATCHED_DOUBLEQUOTE": "\"value",
"UNMATCHED_SINGLEQUOTE": "'value",
"UNMATCHED_BACKQUOTE": "`value",
"INLINE_COMMENTS": "value",
"INLINE_COMMENTS_DOUBLE_QUOTES": "inline comments outside of #doublequotes",
"INLINE_COMMENTS_SINGLE_QUOTES": "inline comments outside of #singlequotes",
"INLINE_COMMENTS_BACKQUOTES": "inline comments outside of #backticks",
"EXPORTED_VAR": "value",
"EQUAL_SIGNS": "equals==",
"SPACED_KEY": "value",
content, err := os.ReadFile("test.env")
if err != nil {
t.Fatal(err)
}

file, err := os.Open("test.env")
result, err := Parse(bytes.NewReader(content))
if err != nil {
t.Fatal(err)
t.Fatalf("Parse returned unexpected error: %v", err)
}
defer file.Close()

result, err := Parse(file)
compareMaps(t, result, expected)
}

func TestParseFile(t *testing.T) {
result, err := ParseFile("test.env")
if err != nil {
t.Fatalf("Parse returned unexpected error: %v", err)
}

compareMaps(t, result, expected)
}

func BenchmarkParse(b *testing.B) {
content, err := os.ReadFile("test.env")
if err != nil {
b.Fatal(err)
}
reader := bytes.NewReader(content)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err = Parse(reader)
if err != nil {
b.Fatal(err)
}
}
}

func BenchmarkParseFile(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ParseFile("test.env")
if err != nil {
b.Fatal(err)
}
}
}

func compareMaps(t *testing.T, result, expected map[string]string) {
t.Helper()

if len(result) != len(expected) {
t.Errorf("Map size mismatch: got %d entries, want %d entries", len(result), len(expected))
}
Expand Down

0 comments on commit 824600d

Please sign in to comment.