246 lines
5.6 KiB
Go
246 lines
5.6 KiB
Go
package envexpand
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var placeholderRE = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)
|
|
|
|
const maxIterations = 32
|
|
|
|
// ExpandString replaces {{NAME}} placeholders using vars.
|
|
func ExpandString(s string, vars map[string]string) string {
|
|
return placeholderRE.ReplaceAllStringFunc(s, func(match string) string {
|
|
name := match[2 : len(match)-2]
|
|
if v, ok := vars[name]; ok {
|
|
return v
|
|
}
|
|
return match
|
|
})
|
|
}
|
|
|
|
// ExpandMap resolves {{VAR}} references until stable or maxIterations is reached.
|
|
func ExpandMap(vars map[string]string) (map[string]string, error) {
|
|
out := make(map[string]string, len(vars))
|
|
for k, v := range vars {
|
|
out[k] = v
|
|
}
|
|
|
|
for i := 0; i < maxIterations; i++ {
|
|
changed := false
|
|
for k, v := range out {
|
|
next := ExpandString(v, out)
|
|
if next != v {
|
|
changed = true
|
|
out[k] = next
|
|
}
|
|
}
|
|
if !changed {
|
|
if unresolved := findUnresolved(out); len(unresolved) > 0 {
|
|
return nil, fmt.Errorf("envexpand: unresolved placeholders: %s", strings.Join(unresolved, ", "))
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("envexpand: expansion did not converge after %d iterations", maxIterations)
|
|
}
|
|
|
|
func findUnresolved(vars map[string]string) []string {
|
|
seen := make(map[string]bool)
|
|
for _, v := range vars {
|
|
for _, name := range placeholderRE.FindAllStringSubmatch(v, -1) {
|
|
seen[name[1]] = true
|
|
}
|
|
}
|
|
names := make([]string, 0, len(seen))
|
|
for n := range seen {
|
|
names = append(names, "{{"+n+"}}")
|
|
}
|
|
for i := 0; i < len(names); i++ {
|
|
for j := i + 1; j < len(names); j++ {
|
|
if names[j] < names[i] {
|
|
names[i], names[j] = names[j], names[i]
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ParseLines reads KEY=VALUE pairs from dotenv-style content (no expansion).
|
|
func ParseLines(lines []string) (map[string]string, error) {
|
|
vars := make(map[string]string)
|
|
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
return nil, fmt.Errorf("envexpand: invalid line %d: missing '='", i+1)
|
|
}
|
|
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return nil, fmt.Errorf("envexpand: invalid line %d: empty key", i+1)
|
|
}
|
|
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
|
|
vars[key] = value
|
|
}
|
|
|
|
return vars, nil
|
|
}
|
|
|
|
// LoadFile parses a .env file without expanding placeholders.
|
|
func LoadFile(path string) (map[string]string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
|
return ParseLines(lines)
|
|
}
|
|
|
|
// LoadExpandFile parses and expands a .env file.
|
|
func LoadExpandFile(path string) (map[string]string, error) {
|
|
vars, err := LoadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ExpandMap(vars)
|
|
}
|
|
|
|
// WriteFile writes KEY=VALUE lines (no quoting; values are used as-is).
|
|
func WriteFile(path string, vars map[string]string) error {
|
|
var keys []string
|
|
for k := range vars {
|
|
keys = append(keys, k)
|
|
}
|
|
// Stable output: sort keys
|
|
for i := 0; i < len(keys); i++ {
|
|
for j := i + 1; j < len(keys); j++ {
|
|
if keys[j] < keys[i] {
|
|
keys[i], keys[j] = keys[j], keys[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
for _, k := range keys {
|
|
b.WriteString(k)
|
|
b.WriteByte('=')
|
|
b.WriteString(vars[k])
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
return os.WriteFile(path, []byte(b.String()), 0o600)
|
|
}
|
|
|
|
// ApplyFile loads and expands a .env file, then sets variables in the process
|
|
// environment. Existing environment variables are not overwritten.
|
|
func ApplyFile(path string) error {
|
|
vars, err := LoadExpandFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ApplyMap(vars, false)
|
|
return nil
|
|
}
|
|
|
|
// ApplyMap sets variables in the process environment.
|
|
// When override is false, existing environment variables are kept.
|
|
func ApplyMap(vars map[string]string, override bool) {
|
|
for k, v := range vars {
|
|
if !override {
|
|
if _, exists := os.LookupEnv(k); exists {
|
|
continue
|
|
}
|
|
}
|
|
_ = os.Setenv(k, v)
|
|
}
|
|
}
|
|
|
|
// Render reads an input .env, expands placeholders, and writes the result.
|
|
func Render(inPath, outPath string) error {
|
|
vars, err := LoadExpandFile(inPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WriteFile(outPath, vars)
|
|
}
|
|
|
|
// RenderToWriter expands and writes dotenv format to w.
|
|
func RenderToWriter(inPath string, w interface{ Write([]byte) (int, error) }) error {
|
|
vars, err := LoadExpandFile(inPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var keys []string
|
|
for k := range vars {
|
|
keys = append(keys, k)
|
|
}
|
|
for i := 0; i < len(keys); i++ {
|
|
for j := i + 1; j < len(keys); j++ {
|
|
if keys[j] < keys[i] {
|
|
keys[i], keys[j] = keys[j], keys[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, k := range keys {
|
|
if _, err := fmt.Fprintf(w, "%s=%s\n", k, vars[k]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseReader is a helper for tests and stdin.
|
|
func ParseReader(r interface {
|
|
ReadString(byte) (string, error)
|
|
}) (map[string]string, error) {
|
|
var lines []string
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
lines = append(lines, strings.TrimSuffix(line, "\n"))
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return ParseLines(lines)
|
|
}
|
|
|
|
// ParseFileLines loads via bufio for large files (alias to LoadFile).
|
|
func ParseFile(path string) (map[string]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var lines []string
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
lines = append(lines, sc.Text())
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseLines(lines)
|
|
}
|