Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Read env variables from file #5

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions dotenv/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package envconf

import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
)

type ErrIncorrectValue struct {
Value string
Symbol string
}

func (e ErrIncorrectValue) Error() string {
return fmt.Sprintf("%s contains invalid symbol %s\n", e.Value, e.Symbol)
}

var (
ErrInvalidPair = errors.New("invalid pair for env variable")
ErrQuotesInQuotes = errors.New("quotes in quotes")
ErrNotPairQuotes = errors.New("not pair quotes")
)

type EnvConfig struct {
Envs map[string]string
IsTrimSpace bool
Quote string
}

func NewEnvConf() *EnvConfig {
return &EnvConfig{Envs: map[string]string{}}
}

func (e *EnvConfig) TrimSpace(t bool) *EnvConfig {
e.IsTrimSpace = t
return e
}

func (e *EnvConfig) Parse(data io.Reader) error {
lines, err := e.readLine(data)
if err != nil {
return err
}
err = e.parseEnvLines(lines)
if err != nil {
return err
}
return nil
}

func (e *EnvConfig) parseEnvLines(lines []string) error {
for _, line := range lines {
if strings.Contains(line, "#") {
line = strings.TrimSpace(line)
line = trimComment(line)
// continue if block comment
if len(line) == 0 {
continue
}
}
line = strings.TrimLeft(line, "export ")
i := strings.Index(line, "=")
if i == -1 {
return ErrInvalidPair
}
key := line[:i]
value := line[i+1:]
// trim space symbols near key and value
if e.IsTrimSpace {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
} else if strings.HasSuffix(key, " ") || strings.HasPrefix(value, " ") {
return ErrIncorrectValue{Value: key, Symbol: " "}
}
var err error
key, err = e.trimQuotes(key)
if err != nil {
return err
}
if strings.Contains(key, " ") {
return ErrInvalidPair
}
value, err = e.trimQuotes(value)
if err != nil {
return err
}
value = e.trimCharacterEscaping(value)
e.Envs[key] = value
}
return nil
}

func (e *EnvConfig) readLine(data io.Reader) ([]string, error) {
b := bufio.NewReader(data)
lines := []string{}
for {
l, err := b.ReadString('\n')
if err != nil {
if err != io.EOF {
return nil, err
}
if len(l) == 0 {
return lines, nil
}
}
lines = append(lines, l)
}
}

func (e *EnvConfig) Set() error {
for k, v := range e.Envs {
if err := os.Setenv(k, v); err != nil {
return err
}
}
return nil
}

func (e *EnvConfig) trimQuotes(s string) (string, error) {
s = strings.TrimSpace(s)
// string contains only space symbols
if len(s) == 0 {
return "", nil
}
// valide pair quotes
if s[0] == '"' || s[0] == '\'' {
e.Quote = string(s[0])
if s[0] != s[len(s)-1] {
return "", ErrNotPairQuotes
}
s = s[1 : len(s)-1]
}
return s, nil
}

func (e *EnvConfig) trimCharacterEscaping(s string) string {
if e.Quote == "'" {
// remove escaping with '
return escaping(s, "'")
} else if e.Quote == "\"" {
// remove escaping with "
return escaping(s, "\"")
}
return s
}

func escaping(s, q string) string {
for i, j := 0, 1; j < len(s); i, j = i+1, j+1 {
if string(s[i]) == `\` && string(s[j]) == q {
s = s[:i] + s[i+1:]
}
}
return s
}

func trimComment(s string) string {
segmentsBetweenHashes := strings.Split(s, "#")
quotesAreOpen := false
var segmentsToKeep []string
for _, segment := range segmentsBetweenHashes {
if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 {
if quotesAreOpen {
quotesAreOpen = false
segmentsToKeep = append(segmentsToKeep, segment)
} else {
quotesAreOpen = true
}
}
if len(segmentsToKeep) == 0 || quotesAreOpen {
segmentsToKeep = append(segmentsToKeep, segment)
}
}
return strings.Join(segmentsToKeep, "#")
}
146 changes: 146 additions & 0 deletions dotenv/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package envconf

import (
"os"
"testing"
)

func TestEnvConfigStatusOK(t *testing.T) {
type testCase struct {
filePath string
expectedValues map[string]string
isTrimSpace bool
}
testcases := []testCase{
{
filePath: "./fixtures/basic.env",
expectedValues: map[string]string{
"OPTION_A": "foo",
"OPTION_B": "foo_bar",
"OPTION_C": "3.14",
"OPTION_D": "42",
"_OPTION_E": "foo",
"____OPTION_F": "bar",
"OPTION_G": "1",
"OPTION_H": "2",
},
},
{
filePath: "./fixtures/exported.env",
expectedValues: map[string]string{
"OPTION_A": "2",
"OPTION_B": `\n`,
},
},
{
filePath: "./fixtures/space.env",
expectedValues: map[string]string{
"OPTION_A": "1",
"OPTION_B": "2",
"OPTION_C": "3",
"OPTION_D": "4",
"OPTION_E": "5",
"OPTION_F": "",
"OPTION_G": "",
},
isTrimSpace: true,
},
{
filePath: "./fixtures/quoted.env",
expectedValues: map[string]string{
"OPTION_A": "1",
"OPTION_B": "2",
"OPTION_C": "",
"OPTION_D": `\n`,
"OPTION_E": "1",
"OPTION_F": "2",
"OPTION_G": "",
"OPTION_H": `\n`,
"OPTION_I": `foo 'bar'`,
"OPTION_J": `foo"bar"`,
"OPTION_K": `"foo`,
"OPTION_L": `foo "bar"`,
"OPTION_M": `foo \bar\`,
"OPTION_N": `\\foo`,
"OPTION_O": `foo \"bar\"`,
"OPTION_P": "`foo bar`",
},
},
{
filePath: "./fixtures/comment.env",
expectedValues: map[string]string{
"OPTION_A": "1",
"OPTION_B": "2",
},
},
}
for _, tc := range testcases {
envsFile, err := os.Open(tc.filePath)
if err != nil {
t.Error("cannot open file via path:", tc.filePath)
}
envf := NewEnvConf().TrimSpace(tc.isTrimSpace)
if err := envf.Parse(envsFile); err != nil {
t.Errorf("cannot read file %s error: %s", tc.filePath, err)
}
// comparing result envs with expected
if len(tc.expectedValues) != len(envf.Envs) {
t.Errorf("he expected value is not equal to the value from the file: in the test case=%v != in the env file=%v", len(tc.expectedValues), len(envf.Envs))
}
for k, v := range envf.Envs {
values, ok := tc.expectedValues[k]
if !ok {
t.Error("expected values not contains key:", k)
}
if values != v {
t.Errorf("the expected value is not equal to the value from the file: in the test case=%v !=in the file=%v. Test key: %s", values, v, k)
}
}
}
}

func TestEnvConfigParseIncorrectFileStatusError(t *testing.T) {
type testCase struct {
filePath string
isTrimSpace bool
}
testcases := []testCase{
{
filePath: "./fixtures/space.env",
},
}
for _, tc := range testcases {
envsFile, err := os.Open(tc.filePath)
if err != nil {
t.Error("cannot open file via path:", tc.filePath)
}
envf := NewEnvConf().TrimSpace(tc.isTrimSpace)
err = envf.Parse(envsFile)
err, ok := err.(ErrIncorrectValue)
if !ok {
t.Errorf("incorrect error type: %T", err)
}
}
}

func TestEnvConfigParseIncorrectKeyStatusError(t *testing.T) {
type testCase struct {
filePath string
}
testcases := []testCase{
{
filePath: "./fixtures/nagative_export.env",
},
}
for _, tc := range testcases {
envsFile, err := os.Open(tc.filePath)
if err != nil {
t.Error("cannot open file via path:", tc.filePath)
}
envf := NewEnvConf()
err = envf.Parse(envsFile)
if err != ErrInvalidPair {
t.Errorf("incorrect error type: %T", err)
}
}
}
8 changes: 8 additions & 0 deletions dotenv/fixtures/basic.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
OPTION_A=foo
OPTION_B=foo_bar
OPTION_C=3.14
OPTION_D=42
_OPTION_E=foo
____OPTION_F=bar
OPTION_G=1
OPTION_H=2
6 changes: 6 additions & 0 deletions dotenv/fixtures/comment.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Block comment
OPTION_A=1
OPTION_B=2 # InlineComment
# OPTION_C=3
# OPTION_D=4
# OPTION_E=5
2 changes: 2 additions & 0 deletions dotenv/fixtures/exported.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export OPTION_A=2
export OPTION_B='\n'
1 change: 1 addition & 0 deletions dotenv/fixtures/nagative_export.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exp_ OPTION_A=1
16 changes: 16 additions & 0 deletions dotenv/fixtures/quoted.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
OPTION_A='1'
OPTION_B='2'
OPTION_C=''
OPTION_D='\n'
OPTION_E="1"
OPTION_F="2"
OPTION_G=""
OPTION_H="\n"
OPTION_I="foo 'bar'"
OPTION_J="foo\"bar\""
OPTION_K="\"foo"
OPTION_L='foo "bar"'
OPTION_M='foo \bar\'
OPTION_N='\\foo'
OPTION_O='foo \"bar\"'
OPTION_P='`foo bar`'
7 changes: 7 additions & 0 deletions dotenv/fixtures/space.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
OPTION_A=1
OPTION_B=2
OPTION_C= 3
OPTION_D =4
OPTION_E = 5
OPTION_F =
OPTION_G=
9 changes: 5 additions & 4 deletions value.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,16 @@ func (v *value) define() error {
owner = owner.parent
}
value, exists = v.owner.external.Get(values...)
if exists {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changes is not related to this commit

return nil
} else if value != nil {
exists = true
}
case DefaultPriority:
value, exists = v.defaultV.value()
}
if exists {
debugLogger.Printf("envconf: set variable name=%s value=%v source=%s", v.fullname(), value, p)
if p == ExternalPriority {
// value setted in external source
return nil
}
break
}
}
Expand Down