main b4521f0

README.md RawHistoryFolder

kvconf

Flat key = value config files for Go, with live reload. Standard library only, no dependencies.

# ~/.config/myapp/config
theme      = dark
lock.after = 5m
border     = #ff8800          # a value may start with #
title      = "keeps # inside"
bind.cmd+q = close-window
output.DP-1.scale = 1.5
  • Typed decoding into a struct with kv tags: strings, bools (on/off), numbers with bounds, durations, enums, lists, paths with ~/, maps for dynamic keys (bind.*, output.<name>.<field>).
  • Live reload with each OS's own notifications: inotify (Linux), kqueue (macOS, BSD), ReadDirectoryChangesW (Windows), or polling.
  • Safe for real configs: atomic saves, symlinked dotfiles, editors that save by rename, missing directories, broken files that keep the last good config, and warnings that never print values (they may be secrets).
  • Fast: about 1 GB/s to parse, 2 allocations per reported change (see Performance).
go get github.com/bnema/kvconf

Quick start

type Config struct {
	Theme  string            `kv:"theme" oneof:"dark|light"`
	Lock   time.Duration     `kv:"lock.after,off" max:"1h"`
	Shots  kvconf.Path       `kv:"screenshot.dir"`
	Binds  map[string]string `kv:"bind.*"`
}

func (c *Config) SetDefaults() { c.Theme, c.Lock = "dark", 5*time.Minute }

func main() {
	path, _ := kvconf.DefaultPath("myapp", "config")
	live, warnings, err := kvconf.Open(path, kvconf.LiveOptions[Config]{
		OnChange: func(c kvconf.Change[Config]) { log.Println("reloaded", c.Keys) },
		OnError:  func(err error) { log.Println("config:", err) },
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, w := range warnings {
		log.Println("config:", w) // "line 3: theme: must be dark or light"
	}
	go live.Run(ctx)

	cfg := live.Get() // safe from any goroutine; never modified after
}

Without live reload: cfg, warnings, err := kvconf.Load[Config](path, kvconf.Options{}).

File format

  • One key = value per line. Spaces around the key and value are trimmed.
  • # starts a comment at the start of a line, or after a space or a tab. #ff8800 and a#b are values. See Options.Comments for other styles.
  • A value that is exactly "..." (Go escapes) or '...' (literal) is unquoted. spawn "a b" stays as written.
  • UTF-8, with an optional BOM and \r\n line ends.
  • A repeated key overrides the earlier one, with a warning.

Tags

Tag Meaning
kv:"name" The key. A nested struct prefixes its keys with name.
kv:",inline" A nested struct whose keys are not prefixed
kv:"name,off" off sets the zero value
kv:"name,nonempty" An empty value is rejected
kv:"bind.*" A map: bind.<name> = v. A map of structs takes bind.<name>.<field>; names may contain dots
kv:"*" []kvconf.Entry or map[string]string collecting unknown keys
min:"1" max:"10" Bounds of a number or duration (max:"1h")
oneof:"a|b" Allowed values
sep:"|" List separator (default ,)

Any type implementing encoding.TextUnmarshaler works as a field. Validate() error on the config checks rules spanning several keys. SetDefaults() fills defaults, also for each new element of a map of structs.

Errors and warnings

By default a bad line or value becomes a Warning with its line and key, and the rest of the file still applies. Options.Strict rejects the whole file instead. Options.AllowUnknown, AllowDuplicates, RequireFile, MaxBytes (1 MiB) and MaxLine (64 KiB) tune the rest.

Live keeps the current config when the file is broken or removed, and skips saves that change no value.

Lower-level API

  • Parse, ParseBytes: file to []Entry without decoding.
  • Decode, Unmarshal, Marshal: entries or bytes to and from a struct.
  • Watch(ctx, path, WatchOptions{...}, out): change notifications for one file, or every file of a directory with Dir: true.
  • Set, SetFile: change keys in place, keeping comments and layout.
  • WriteFile: atomic replace, written through symlinks.
  • ReadFile: bounded read that rejects FIFOs and devices without blocking.

Performance

Measured at v0.1.2 (1e7884d, 2026-10-10) with Go 1.27.1 on linux/amd64 with an AMD Ryzen 9 7900X3D. Values are medians of 10 runs. The input is a realistic ~2.9 KB config of 100 lines with comments, 60 binds and 8 outputs.

Benchmark Time/op Throughput Bytes/op Allocs/op
Parse (file to entries) 2.9 µs 992 MB/s 7.0 KiB 2
Unmarshal (file to struct, maps included) 8.3 µs 351 MB/s 13.4 KiB 16
Decode (5 scalar entries) 173 ns 112 B 1
Marshal (struct to file) 8.6 µs 6.8 KiB 10
Set (edit one key in place) 2.6 µs 10.5 KiB 5
Watch (one write to one report, inotify) 5.3 µs 256 B 2

Reproduce with:

go test -run '^$' -bench . -benchmem -count 10 .

License

MIT

source
1# kvconf
2
3Flat `key = value` config files for Go, with live reload. Standard library
4only, no dependencies.
5
6```ini
7# ~/.config/myapp/config
8theme      = dark
9lock.after = 5m
10border     = #ff8800          # a value may start with #
11title      = "keeps # inside"
12bind.cmd+q = close-window
13output.DP-1.scale = 1.5
14```
15
16- **Typed decoding** into a struct with `kv` tags: strings, bools (`on/off`),
17  numbers with bounds, durations, enums, lists, paths with `~/`, maps for
18  dynamic keys (`bind.*`, `output.<name>.<field>`).
19- **Live reload** with each OS's own notifications: inotify (Linux), kqueue
20  (macOS, BSD), ReadDirectoryChangesW (Windows), or polling.
21- **Safe for real configs**: atomic saves, symlinked dotfiles, editors that
22  save by rename, missing directories, broken files that keep the last good
23  config, and warnings that never print values (they may be secrets).
24- **Fast**: about 1 GB/s to parse, 2 allocations per reported change
25  (see [Performance](#performance)).
26
27```sh
28go get github.com/bnema/kvconf
29```
30
31## Quick start
32
33```go
34type Config struct {
35	Theme  string            `kv:"theme" oneof:"dark|light"`
36	Lock   time.Duration     `kv:"lock.after,off" max:"1h"`
37	Shots  kvconf.Path       `kv:"screenshot.dir"`
38	Binds  map[string]string `kv:"bind.*"`
39}
40
41func (c *Config) SetDefaults() { c.Theme, c.Lock = "dark", 5*time.Minute }
42
43func main() {
44	path, _ := kvconf.DefaultPath("myapp", "config")
45	live, warnings, err := kvconf.Open(path, kvconf.LiveOptions[Config]{
46		OnChange: func(c kvconf.Change[Config]) { log.Println("reloaded", c.Keys) },
47		OnError:  func(err error) { log.Println("config:", err) },
48	})
49	if err != nil {
50		log.Fatal(err)
51	}
52	for _, w := range warnings {
53		log.Println("config:", w) // "line 3: theme: must be dark or light"
54	}
55	go live.Run(ctx)
56
57	cfg := live.Get() // safe from any goroutine; never modified after
58}
59```
60
61Without live reload: `cfg, warnings, err := kvconf.Load[Config](path, kvconf.Options{})`.
62
63## File format
64
65- One `key = value` per line. Spaces around the key and value are trimmed.
66- `#` starts a comment at the start of a line, or after a space or a tab.
67  `#ff8800` and `a#b` are values. See `Options.Comments` for other styles.
68- A value that is exactly `"..."` (Go escapes) or `'...'` (literal) is
69  unquoted. `spawn "a b"` stays as written.
70- UTF-8, with an optional BOM and `\r\n` line ends.
71- A repeated key overrides the earlier one, with a warning.
72
73## Tags
74
75| Tag | Meaning |
76| --- | --- |
77| `kv:"name"` | The key. A nested struct prefixes its keys with `name.` |
78| `kv:",inline"` | A nested struct whose keys are not prefixed |
79| `kv:"name,off"` | `off` sets the zero value |
80| `kv:"name,nonempty"` | An empty value is rejected |
81| `kv:"bind.*"` | A map: `bind.<name> = v`. A map of structs takes `bind.<name>.<field>`; names may contain dots |
82| `kv:"*"` | `[]kvconf.Entry` or `map[string]string` collecting unknown keys |
83| `min:"1" max:"10"` | Bounds of a number or duration (`max:"1h"`) |
84| `oneof:"a\|b"` | Allowed values |
85| `sep:"\|"` | List separator (default `,`) |
86
87Any type implementing `encoding.TextUnmarshaler` works as a field.
88`Validate() error` on the config checks rules spanning several keys.
89`SetDefaults()` fills defaults, also for each new element of a map of structs.
90
91## Errors and warnings
92
93By default a bad line or value becomes a `Warning` with its line and key,
94and the rest of the file still applies. `Options.Strict` rejects the whole
95file instead. `Options.AllowUnknown`, `AllowDuplicates`, `RequireFile`,
96`MaxBytes` (1 MiB) and `MaxLine` (64 KiB) tune the rest.
97
98`Live` keeps the current config when the file is broken or removed, and
99skips saves that change no value.
100
101## Lower-level API
102
103- `Parse`, `ParseBytes`: file to `[]Entry` without decoding.
104- `Decode`, `Unmarshal`, `Marshal`: entries or bytes to and from a struct.
105- `Watch(ctx, path, WatchOptions{...}, out)`: change notifications for one
106  file, or every file of a directory with `Dir: true`.
107- `Set`, `SetFile`: change keys in place, keeping comments and layout.
108- `WriteFile`: atomic replace, written through symlinks.
109- `ReadFile`: bounded read that rejects FIFOs and devices without blocking.
110
111## Performance
112
113Measured at `v0.1.2` (`1e7884d`, 2026-10-10) with Go 1.27.1 on linux/amd64
114with an AMD Ryzen 9 7900X3D. Values are medians of 10 runs. The input is a
115realistic ~2.9 KB config of 100 lines with comments, 60 binds and 8 outputs.
116
117| Benchmark | Time/op | Throughput | Bytes/op | Allocs/op |
118|---|---:|---:|---:|---:|
119| `Parse` (file to entries) | 2.9 µs | 992 MB/s | 7.0 KiB | 2 |
120| `Unmarshal` (file to struct, maps included) | 8.3 µs | 351 MB/s | 13.4 KiB | 16 |
121| `Decode` (5 scalar entries) | 173 ns | | 112 B | 1 |
122| `Marshal` (struct to file) | 8.6 µs | | 6.8 KiB | 10 |
123| `Set` (edit one key in place) | 2.6 µs | | 10.5 KiB | 5 |
124| `Watch` (one write to one report, inotify) | 5.3 µs | | 256 B | 2 |
125
126Reproduce with:
127
128```sh
129go test -run '^$' -bench . -benchmem -count 10 .
130```
131
132## License
133
134MIT

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