@@ -0,0 +1,342 @@
|
||||
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()))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package envfile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetPreservesDocumentAndUpdatesLastDuplicate(t *testing.T) {
|
||||
doc, err := Parse([]byte("# title\nA=one\nA=two\nB=3\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := doc.Set("A", "hello world"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(doc.Render())
|
||||
want := "# title\nA=one\nA=hello world\nB=3\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportMissingCopiesComments(t *testing.T) {
|
||||
current, _ := Parse([]byte("A=1\n"))
|
||||
example, _ := Parse([]byte("# Alpha\nA=1\n\n# Beta help\nB=2\n"))
|
||||
missing, err := current.ImportMissing(example, "# imported")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "B" {
|
||||
t.Fatalf("unexpected missing: %#v", missing)
|
||||
}
|
||||
got := string(current.Render())
|
||||
if !strings.Contains(got, "# Beta help\nB=2") {
|
||||
t.Fatalf("comments/value not imported: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecode(t *testing.T) {
|
||||
values := []string{"plain", "contains # hash", "line1\nline2", " leading"}
|
||||
for _, value := range values {
|
||||
if got := DecodeValue(EncodeValue(value)); got != value {
|
||||
t.Fatalf("roundtrip %q -> %q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user