v0.1.0 3b1b6ef

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	"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}

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