Implements conf.Load to populate tagged structs from a chain of Sources (env, .env, YAML/JSON/TOML, custom). Supports default values, slice separators, nested structs, pointer fields, and a Validator hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
2.3 KiB
Go
103 lines
2.3 KiB
Go
package conf
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"git.juancwu.dev/juancwu/errx"
|
|
)
|
|
|
|
// DotEnvFile loads KEY=VALUE pairs from path. Lines starting with # and blank
|
|
// lines are ignored. Values may be wrapped in single or double quotes; double
|
|
// quotes honor \n, \t, \r, \\, and \" escapes.
|
|
func DotEnvFile(path string) (Source, error) {
|
|
const op = "conf.DotEnvFile"
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, errx.Wrapf(op, err, "open %s", path)
|
|
}
|
|
defer f.Close()
|
|
return DotEnvReader(f)
|
|
}
|
|
|
|
// DotEnvReader is DotEnvFile for an arbitrary reader.
|
|
func DotEnvReader(r io.Reader) (Source, error) {
|
|
const op = "conf.DotEnvReader"
|
|
|
|
m := map[string]string{}
|
|
sc := bufio.NewScanner(r)
|
|
lineNo := 0
|
|
for sc.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
line = strings.TrimPrefix(line, "export ")
|
|
eq := strings.IndexByte(line, '=')
|
|
if eq < 0 {
|
|
return nil, errx.Newf(op, "line %d: missing '='", lineNo)
|
|
}
|
|
key := strings.TrimSpace(line[:eq])
|
|
val := strings.TrimSpace(line[eq+1:])
|
|
// strip trailing inline comment for unquoted values
|
|
if !isQuoted(val) {
|
|
if i := strings.Index(val, " #"); i >= 0 {
|
|
val = strings.TrimSpace(val[:i])
|
|
}
|
|
}
|
|
v, err := unquote(val)
|
|
if err != nil {
|
|
return nil, errx.Wrapf(op, err, "line %d", lineNo)
|
|
}
|
|
m[key] = v
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, errx.Wrap(op, err)
|
|
}
|
|
return MapSource(m), nil
|
|
}
|
|
|
|
func isQuoted(s string) bool {
|
|
if len(s) < 2 {
|
|
return false
|
|
}
|
|
return (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')
|
|
}
|
|
|
|
func unquote(s string) (string, error) {
|
|
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
|
return s[1 : len(s)-1], nil
|
|
}
|
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
|
inner := s[1 : len(s)-1]
|
|
var b strings.Builder
|
|
for i := 0; i < len(inner); i++ {
|
|
c := inner[i]
|
|
if c != '\\' || i+1 >= len(inner) {
|
|
b.WriteByte(c)
|
|
continue
|
|
}
|
|
i++
|
|
switch inner[i] {
|
|
case 'n':
|
|
b.WriteByte('\n')
|
|
case 't':
|
|
b.WriteByte('\t')
|
|
case 'r':
|
|
b.WriteByte('\r')
|
|
case '\\':
|
|
b.WriteByte('\\')
|
|
case '"':
|
|
b.WriteByte('"')
|
|
default:
|
|
b.WriteByte('\\')
|
|
b.WriteByte(inner[i])
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
return s, nil
|
|
}
|