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) }