detached 1e7884d

parse.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close