package envfile import ( "bufio" "errors" "fmt" "strconv" "strings" "unicode" ) type LineKind int const ( LineOther LineKind = iota LineAssignment ) type Line struct { Raw string Kind LineKind Key string Prefix string RawValue string Comment string LineIndex int } type Entry struct { Key string Value string RawValue string Description string Secret bool Missing bool Extra bool Duplicate bool } type Document struct { Lines []Line Newline string TrailingNL bool } func Parse(data []byte) (*Document, error) { text := string(data) newline := "\n" if strings.Contains(text, "\r\n") { newline = "\r\n" } trailing := strings.HasSuffix(text, "\n") text = strings.ReplaceAll(text, "\r\n", "\n") parts := strings.Split(text, "\n") if trailing && len(parts) > 0 { parts = parts[:len(parts)-1] } doc := &Document{Newline: newline, TrailingNL: trailing} for i, raw := range parts { line := Line{Raw: raw, Kind: LineOther, LineIndex: i} key, prefix, rawValue, ok := parseAssignment(raw) if ok { line.Kind = LineAssignment line.Key = key line.Prefix = prefix line.RawValue = rawValue } doc.Lines = append(doc.Lines, line) } return doc, nil } func parseAssignment(raw string) (key, prefix, value string, ok bool) { trimmedLeft := strings.TrimLeftFunc(raw, unicode.IsSpace) if trimmedLeft == "" || strings.HasPrefix(trimmedLeft, "#") { return "", "", "", false } pos := 0 if strings.HasPrefix(trimmedLeft, "export ") { pos = len("export ") } rest := trimmedLeft[pos:] eq := strings.IndexByte(rest, '=') if eq <= 0 { return "", "", "", false } candidate := strings.TrimSpace(rest[:eq]) if !validKey(candidate) { return "", "", "", false } absoluteEq := len(raw) - len(trimmedLeft) + pos + eq return candidate, raw[:absoluteEq+1], raw[absoluteEq+1:], true } func validKey(key string) bool { if key == "" { return false } for i, r := range key { if i == 0 { if !(r == '_' || unicode.IsLetter(r)) { return false } continue } if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) { return false } } return true } func (d *Document) Effective() map[string]string { out := make(map[string]string) for _, line := range d.Lines { if line.Kind == LineAssignment { out[line.Key] = DecodeValue(line.RawValue) } } return out } func (d *Document) Occurrences() map[string]int { out := make(map[string]int) for _, line := range d.Lines { if line.Kind == LineAssignment { out[line.Key]++ } } return out } func (d *Document) Set(key, value string) error { if !validKey(key) { return fmt.Errorf("invalid environment key %q", key) } encoded := EncodeValue(value) last := -1 for i := range d.Lines { if d.Lines[i].Kind == LineAssignment && d.Lines[i].Key == key { last = i } } if last >= 0 { line := &d.Lines[last] line.RawValue = encoded line.Raw = line.Prefix + encoded return nil } if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" { d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther}) } d.Lines = append(d.Lines, Line{Raw: key + "=" + encoded, Kind: LineAssignment, Key: key, Prefix: key + "=", RawValue: encoded}) return nil } func (d *Document) Render() []byte { lines := make([]string, 0, len(d.Lines)) for _, line := range d.Lines { lines = append(lines, line.Raw) } text := strings.Join(lines, d.Newline) if d.TrailingNL || len(lines) > 0 { text += d.Newline } return []byte(text) } func DecodeValue(raw string) string { raw = strings.TrimSpace(raw) if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' { return raw[1 : len(raw)-1] } if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { if v, err := strconv.Unquote(raw); err == nil { return v } } return raw } func EncodeValue(value string) string { if value == "" { return "" } needsQuote := strings.ContainsAny(value, "\n\r\t#\"'") || strings.TrimSpace(value) != value if !needsQuote { return value } return strconv.Quote(value) } func (d *Document) ImportMissing(example *Document, marker string) ([]string, error) { if example == nil { return nil, errors.New("example document is nil") } existing := d.Effective() var missing []string for _, line := range example.Lines { if line.Kind == LineAssignment { if _, ok := existing[line.Key]; !ok { missing = append(missing, line.Key) } } } if len(missing) == 0 { return nil, nil } if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" { d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther}) } if marker != "" { d.Lines = append(d.Lines, Line{Raw: marker, Kind: LineOther}) } missingSet := make(map[string]struct{}, len(missing)) for _, key := range missing { missingSet[key] = struct{}{} } pendingComments := []string{} added := make(map[string]struct{}) for _, line := range example.Lines { trimmed := strings.TrimSpace(line.Raw) if line.Kind != LineAssignment { if trimmed == "" || strings.HasPrefix(trimmed, "#") { pendingComments = append(pendingComments, line.Raw) } else { pendingComments = nil } continue } if _, ok := missingSet[line.Key]; !ok { pendingComments = nil continue } if _, done := added[line.Key]; done { pendingComments = nil continue } for _, comment := range trimBoundaryBlanks(pendingComments) { d.Lines = append(d.Lines, Line{Raw: comment, Kind: LineOther}) } copyLine := line copyLine.LineIndex = len(d.Lines) d.Lines = append(d.Lines, copyLine) added[line.Key] = struct{}{} pendingComments = nil } return missing, nil } func trimBoundaryBlanks(lines []string) []string { start, end := 0, len(lines) for start < end && strings.TrimSpace(lines[start]) == "" { start++ } for end > start && strings.TrimSpace(lines[end-1]) == "" { end-- } return lines[start:end] } func Descriptions(example *Document) map[string]string { out := make(map[string]string) var comments []string for _, line := range example.Lines { trimmed := strings.TrimSpace(line.Raw) if line.Kind == LineAssignment { var cleaned []string for _, c := range comments { c = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c), "#")) if c != "" && !allRune(c, '#') { cleaned = append(cleaned, c) } } if len(cleaned) > 0 { out[line.Key] = strings.Join(cleaned, " ") } comments = nil continue } if strings.HasPrefix(trimmed, "#") || trimmed == "" { comments = append(comments, line.Raw) } else { comments = nil } } return out } func allRune(s string, r rune) bool { if s == "" { return false } for _, got := range s { if got != r { return false } } return true } func Compare(current, example *Document) (missing, extra []string) { cur := current.Effective() ex := example.Effective() for key := range ex { if _, ok := cur[key]; !ok { missing = append(missing, key) } } for key := range cur { if _, ok := ex[key]; !ok { extra = append(extra, key) } } sortStrings(missing) sortStrings(extra) return missing, extra } func sortStrings(values []string) { for i := 1; i < len(values); i++ { for j := i; j > 0 && values[j] < values[j-1]; j-- { values[j], values[j-1] = values[j-1], values[j] } } } func ReadScanner(scanner *bufio.Scanner) (*Document, error) { var b strings.Builder first := true for scanner.Scan() { if !first { b.WriteByte('\n') } first = false b.WriteString(scanner.Text()) } if err := scanner.Err(); err != nil { return nil, err } return Parse([]byte(b.String())) }