1// Package kvconf reads, writes and live-reloads flat `key = value` config
2// files with the standard library only.
3//
4// # a comment
5// lock.after = 5m
6// border.color = #ff8800 # a value may start with #
7// title = "quoted # kept" # quotes are removed
8// bind.cmd+return = spawn foot
9//
10// [Parse] turns a file into entries, [Decode] maps entries onto a struct
11// with `kv` tags, [Watch] reports file changes with the native OS API
12// (inotify, kqueue, ReadDirectoryChangesW, or polling), and [Live] ties them
13// together into a config that reloads itself.
14package kvconf
15
16import (
17 "errors"
18 "hash/maphash"
19 "io"
20 "slices"
21 "strconv"
22 "strings"
23 "unicode/utf8"
24)
25
26// Entry is one `key = value` assignment.
27type Entry struct {
28 Key string
29 Value string // trimmed, comment removed, unquoted unless Options.RawValues
30 Line int // 1-based
31}
32
33// Warning is a problem found in a file. It never contains a value, so it is
34// safe to log even when the file holds secrets. In strict mode the first
35// warning is returned as the error.
36type Warning struct {
37 Line int // 0 when the problem is not tied to a line
38 Key string // empty when there is no key
39 Msg string
40}
41
42func (w Warning) Error() string {
43 var b strings.Builder
44 if w.Line > 0 {
45 b.WriteString("line ")
46 b.WriteString(strconv.Itoa(w.Line))
47 b.WriteString(": ")
48 }
49 if w.Key != "" {
50 b.WriteString(w.Key)
51 b.WriteString(": ")
52 }
53 b.WriteString(w.Msg)
54 return b.String()
55}
56
57// CommentStyle selects which '#' start a comment. A line whose first
58// non-blank character is '#' is always a comment, and a '#' inside a quoted
59// value never starts one.
60type CommentStyle uint8
61
62const (
63 // CommentSpaced: a '#' preceded by a space or a tab ends the value, so
64 // a value may start with '#' (colors) or contain one (a#b).
65 CommentSpaced CommentStyle = iota
66 // CommentLine: only whole-line comments; a value may contain any '#'.
67 CommentLine
68 // CommentAny: any '#' ends the value.
69 CommentAny
70)
71
72// Default limits, used when the matching Options field is 0.
73const (
74 DefaultMaxBytes = 1 << 20
75 DefaultMaxLine = 64 << 10
76)
77
78// Options controls parsing and decoding. The zero value is lenient: a
79// problem becomes a warning and the rest of the file still applies.
80type Options struct {
81 // Strict rejects the whole file on the first problem.
82 Strict bool
83 // Comments selects which '#' start a comment.
84 Comments CommentStyle
85 // AllowDuplicates lets a repeated key override the earlier one
86 // silently. Otherwise the last one still wins, with a warning.
87 AllowDuplicates bool
88 // AllowUnknown ignores keys the target struct does not declare.
89 AllowUnknown bool
90 // RawValues keeps the quotes of "..." and '...' values.
91 RawValues bool
92 // RequireFile makes a missing file an error for Load and Live. By
93 // default a missing file gives the defaults.
94 RequireFile bool
95 // MaxBytes bounds the file size: 0 means DefaultMaxBytes, a negative
96 // value means no limit. A larger file is an error, even when lenient.
97 MaxBytes int64
98 // MaxLine bounds the length of a line: 0 means DefaultMaxLine, a
99 // negative value means no limit.
100 MaxLine int
101}
102
103func (o *Options) maxBytes() int64 {
104 if o.MaxBytes == 0 {
105 return DefaultMaxBytes
106 }
107 return o.MaxBytes
108}
109
110func (o *Options) maxLine() int {
111 if o.MaxLine == 0 {
112 return DefaultMaxLine
113 }
114 return o.MaxLine
115}
116
117var (
118 // ErrTooLarge is returned when the input exceeds Options.MaxBytes.
119 ErrTooLarge = errors.New("kvconf: file too large")
120 // ErrNotUTF8 is returned when the input is not valid UTF-8.
121 ErrNotUTF8 = errors.New("kvconf: file is not valid UTF-8")
122)
123
124// problems collects warnings, or stops at the first one in strict mode.
125type problems struct {
126 strict bool
127 list []Warning
128}
129
130// add records a warning and reports whether parsing may go on.
131func (p *problems) add(line int, key, msg string) bool {
132 p.list = append(p.list, Warning{Line: line, Key: key, Msg: msg})
133 return !p.strict
134}
135
136var dupSeed = maphash.MakeSeed()
137
138// duplicates reports each assignment of a key already assigned on an
139// earlier line, with the line it overrides. An open-addressing table of
140// entry indexes, on the stack for a config-sized file, is cheaper than a
141// map[string]int.
142func (p *problems) duplicates(entries []Entry) {
143 if len(entries) < 2 {
144 return
145 }
146 size := 1
147 for size < 2*len(entries) {
148 size <<= 1
149 }
150 var buf [512]int32 // 0: empty, else entry index + 1
151 var table []int32
152 if size <= len(buf) {
153 table = buf[:size]
154 } else {
155 table = make([]int32, size)
156 }
157 mask := uint32(size - 1)
158 syntax := len(p.list)
159 for i := range entries {
160 key := entries[i].Key
161 // Seeded, so a crafted file cannot make every key collide.
162 h := uint32(maphash.String(dupSeed, key))
163 for slot := h & mask; ; slot = (slot + 1) & mask {
164 k := table[slot]
165 if k == 0 {
166 table[slot] = int32(i + 1)
167 break
168 }
169 if prev := &entries[k-1]; prev.Key == key {
170 p.list = append(p.list, Warning{Line: entries[i].Line, Key: key, Msg: "overrides line " + strconv.Itoa(prev.Line)})
171 table[slot] = int32(i + 1)
172 break
173 }
174 }
175 }
176 if len(p.list) == syntax {
177 return
178 }
179 // Warnings are reported in line order, merged with the syntax ones.
180 slices.SortStableFunc(p.list, func(a, b Warning) int { return a.Line - b.Line })
181 if p.strict {
182 p.list = p.list[:1]
183 }
184}
185
186func (p *problems) err() error {
187 if p.strict && len(p.list) > 0 {
188 return p.list[0]
189 }
190 return nil
191}
192
193// Parse reads r and returns its entries in file order: a repeated key
194// appears once per assignment. A line that is not `key = value` is skipped
195// with a warning, or fails the parse in strict mode.
196func Parse(r io.Reader, opts Options) ([]Entry, []Warning, error) {
197 data, err := readLimited(r, opts.maxBytes())
198 if err != nil {
199 return nil, nil, err
200 }
201 return ParseBytes(data, opts)
202}
203
204func readLimited(r io.Reader, limit int64) ([]byte, error) {
205 if limit < 0 {
206 return io.ReadAll(r)
207 }
208 data, err := io.ReadAll(io.LimitReader(r, limit+1))
209 if err != nil {
210 return nil, err
211 }
212 if int64(len(data)) > limit {
213 return nil, ErrTooLarge
214 }
215 return data, nil
216}
217
218// ParseBytes is Parse for data already in memory. The entries do not keep
219// a reference to data.
220func ParseBytes(data []byte, opts Options) ([]Entry, []Warning, error) {
221 if m := opts.maxBytes(); m >= 0 && int64(len(data)) > m {
222 return nil, nil, ErrTooLarge
223 }
224 if !utf8.Valid(data) {
225 return nil, nil, ErrNotUTF8
226 }
227 // One copy: every key and value is a substring of it.
228 s := strings.TrimPrefix(string(data), "\ufeff")
229 entries := make([]Entry, 0, strings.Count(s, "="))
230 p := problems{strict: opts.Strict}
231 maxLine := opts.maxLine()
232 for n := 1; s != ""; n++ {
233 raw := s
234 if i := strings.IndexByte(s, '\n'); i >= 0 {
235 raw, s = s[:i], s[i+1:]
236 } else {
237 s = ""
238 }
239 if maxLine >= 0 && len(raw) > maxLine {
240 if !p.add(n, "", "line longer than "+strconv.Itoa(maxLine)+" bytes") {
241 break
242 }
243 continue
244 }
245 line := strings.TrimSpace(raw)
246 if line == "" || line[0] == '#' {
247 continue
248 }
249 eq := strings.IndexByte(line, '=')
250 if eq < 0 {
251 if !p.add(n, "", "expected key = value") {
252 break
253 }
254 continue
255 }
256 // A key may contain spaces: output names such as "LG Electronics 27GR95UM".
257 key := strings.TrimSpace(line[:eq])
258 if key == "" {
259 if !p.add(n, "", "missing key") {
260 break
261 }
262 continue
263 }
264 value := stripComment(strings.TrimSpace(line[eq+1:]), opts.Comments)
265 if !opts.RawValues {
266 value = unquote(value)
267 }
268 entries = append(entries, Entry{Key: key, Value: value, Line: n})
269 }
270 if !opts.AllowDuplicates {
271 // entries stops at a strict failure: its duplicates come first.
272 p.duplicates(entries)
273 }
274 if err := p.err(); err != nil {
275 return nil, p.list, err
276 }
277 return entries, p.list, nil
278}
279
280// stripComment removes a trailing comment from a trimmed value, ignoring a
281// '#' inside quotes.
282func stripComment(v string, style CommentStyle) string {
283 if style == CommentLine || strings.IndexByte(v, '#') < 0 {
284 return v
285 }
286 var quote byte
287 for i := 0; i < len(v); i++ {
288 switch c := v[i]; {
289 case quote != 0:
290 if c == '\\' && quote == '"' {
291 i++
292 } else if c == quote {
293 quote = 0
294 }
295 case (c == '"' || c == '\'') && (i == 0 || v[i-1] == ' ' || v[i-1] == '\t'):
296 // Only a quote that starts a word opens a string: the quote in
297 // "it's" stays text.
298 quote = c
299 case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')):
300 return strings.TrimSpace(v[:i])
301 }
302 }
303 return v
304}
305
306// unquote removes the quotes of a value that is exactly one quoted string:
307// "..." with Go escapes, or '...' taken literally. Anything else, such as a
308// command line with quoted arguments, is kept as written.
309func unquote(v string) string {
310 if len(v) < 2 || v[0] != v[len(v)-1] {
311 return v
312 }
313 switch v[0] {
314 case '"':
315 inner := v[1 : len(v)-1]
316 if strings.IndexByte(inner, '\\') < 0 && strings.IndexByte(inner, '"') < 0 {
317 return inner // the common case, without allocating
318 }
319 // An escape such as \x80 could make invalid UTF-8: keep it as written.
320 if s, err := strconv.Unquote(v); err == nil && utf8.ValidString(s) {
321 return s
322 }
323 case '\'':
324 if inner := v[1 : len(v)-1]; strings.IndexByte(inner, '\'') < 0 {
325 return inner
326 }
327 }
328 return v
329}
330
331// Values returns the effective value of each key: the last one wins.
332func Values(entries []Entry) map[string]string {
333 m := make(map[string]string, len(entries))
334 for _, e := range entries {
335 m[e.Key] = e.Value
336 }
337 return m
338}
339
340// Changed lists, sorted, the keys added, removed or changed between two
341// Values maps.
342func Changed(old, cur map[string]string) []string {
343 var keys []string
344 for k, v := range cur {
345 if o, ok := old[k]; !ok || o != v {
346 keys = append(keys, k)
347 }
348 }
349 for k := range old {
350 if _, ok := cur[k]; !ok {
351 keys = append(keys, k)
352 }
353 }
354 slices.Sort(keys)
355 return keys
356}