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