detached c5ee955

feat: add kvconf, a stdlib-only key = value config library

Parser with configurable comments, quotes and limits; struct decoder
with kv tags, bounds, enums, lists, paths and dynamic map keys; Marshal,
in-place Set and atomic WriteFile; native file watchers (inotify, kqueue,
ReadDirectoryChangesW) with polling fallback; Live[T] reload that keeps
the last valid config.
commit
c5ee955d269db11fb6d5e5baade7be79f322d7a0
author
brice · · 2026-10-10 02:50 UTC
parent
none (root commit)
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
33 files · +4438 -0
A .github/workflows/ci.yml +37 -0
@@ -0,0 +1,37 @@
1+name: CI
2+on:
3+ push:
4+ branches: [main]
5+ tags: ['v*']
6+ pull_request:
7+concurrency:
8+ group: ci-${{ github.ref }}
9+ cancel-in-progress: true
10+jobs:
11+ # Each OS runs its own native watcher: inotify, kqueue, ReadDirectoryChangesW.
12+ test:
13+ strategy:
14+ fail-fast: false
15+ matrix:
16+ os: [ubuntu-latest, macos-latest, windows-latest]
17+ runs-on: ${{ matrix.os }}
18+ steps:
19+ - uses: actions/checkout@v4
20+ - uses: actions/setup-go@v5
21+ with:
22+ go-version-file: go.mod
23+ - run: go vet ./...
24+ - run: go test -race -count=1 ./...
25+ # The BSDs have no hosted runner: check that kqueue builds there.
26+ build:
27+ runs-on: ubuntu-latest
28+ steps:
29+ - uses: actions/checkout@v4
30+ - uses: actions/setup-go@v5
31+ with:
32+ go-version-file: go.mod
33+ - run: |
34+ for target in freebsd/amd64 openbsd/amd64 netbsd/amd64 dragonfly/amd64 \
35+ windows/386 windows/arm64 js/wasm wasip1/wasm plan9/amd64 solaris/amd64; do
36+ GOOS=${target%/*} GOARCH=${target#*/} go vet ./... || exit 1
37+ done
A .gitignore +4 -0
@@ -0,0 +1,4 @@
1+.worktrees/
2+*.test
3+*.prof
4+testdata/fuzz/
A .golangci.yml +10 -0
@@ -0,0 +1,10 @@
1+version: "2"
2+linters:
3+ settings:
4+ errcheck:
5+ # Best-effort cleanup of descriptors and events: nothing to report.
6+ exclude-functions:
7+ - syscall.Close
8+ - syscall.CloseHandle
9+ - (*os.File).Close
10+ - (*syscall.LazyProc).Call
A AGENTS.md +35 -0
@@ -0,0 +1,35 @@
1+# kvconf
2+
3+Stdlib-only Go library: `key = value` parser, struct decoder, native file
4+watchers and live reload. One package, no dependencies, ever.
5+
6+## Layout
7+
8+- `parse.go`: `Parse`, `ParseBytes`, `Options`, `Warning`.
9+- `decode.go`: struct tags, cached schema, `Decode`, `Unmarshal`.
10+- `marshal.go`: `Marshal`, `Quote`.
11+- `write.go`: `Set`, `SetFile`, `WriteFile`.
12+- `file.go`: `ReadFile`, `DefaultPath`, `Path`.
13+- `watch.go`: OS-independent watch loop (targets, symlink chain, stamps,
14+ debounce). Backends: `watch_linux.go` (inotify), `watch_kqueue*.go`
15+ (macOS/BSD), `watch_windows.go`, `watch_poll.go`, `watch_other.go`.
16+- `live.go`: `Load`, `Open`, `Live`.
17+
18+## Rules
19+
20+- Standard library only; `syscall` for the OS APIs, not `x/sys`.
21+- Warnings and errors never contain config values.
22+- Keep allocations out of hot paths; check with
23+ `go test -bench . -benchmem` and pprof before and after a change.
24+- Tests are table driven. Watch tests run on every backend.
25+
26+## Checks
27+
28+```sh
29+go vet ./... && go test -race ./...
30+golangci-lint run ./...
31+for os in darwin windows freebsd; do GOOS=$os go vet ./...; done
32+go test -run XXX -fuzz FuzzParse -fuzztime 30s .
33+```
34+
35+CI (GitHub mirror) runs the tests on Linux, macOS and Windows.
A LICENSE +21 -0
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 kvconf contributors
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
A README.md +112 -0
@@ -0,0 +1,112 @@
1+# kvconf
2+
3+Flat `key = value` config files for Go, with live reload. Standard library
4+only, no dependencies.
5+
6+```ini
7+# ~/.config/myapp/config
8+theme = dark
9+lock.after = 5m
10+border = #ff8800 # a value may start with #
11+title = "keeps # inside"
12+bind.cmd+q = close-window
13+output.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 540 MB/s to parse, no allocation per watched event.
25+
26+```sh
27+go get github.com/bnema/kvconf
28+```
29+
30+## Quick start
31+
32+```go
33+type Config struct {
34+ Theme string `kv:"theme" oneof:"dark|light"`
35+ Lock time.Duration `kv:"lock.after,off" max:"1h"`
36+ Shots kvconf.Path `kv:"screenshot.dir"`
37+ Binds map[string]string `kv:"bind.*"`
38+}
39+
40+func (c *Config) SetDefaults() { c.Theme, c.Lock = "dark", 5*time.Minute }
41+
42+func main() {
43+ path, _ := kvconf.DefaultPath("myapp", "config")
44+ live, warnings, err := kvconf.Open(path, kvconf.LiveOptions[Config]{
45+ OnChange: func(c kvconf.Change[Config]) { log.Println("reloaded", c.Keys) },
46+ OnError: func(err error) { log.Println("config:", err) },
47+ })
48+ if err != nil {
49+ log.Fatal(err)
50+ }
51+ for _, w := range warnings {
52+ log.Println("config:", w) // "line 3: theme: must be dark or light"
53+ }
54+ go live.Run(ctx)
55+
56+ cfg := live.Get() // safe from any goroutine; never modified after
57+}
58+```
59+
60+Without live reload: `cfg, warnings, err := kvconf.Load[Config](path, kvconf.Options{})`.
61+
62+## File format
63+
64+- One `key = value` per line. Spaces around the key and value are trimmed.
65+- `#` starts a comment at the start of a line, or after a space or a tab.
66+ `#ff8800` and `a#b` are values. See `Options.Comments` for other styles.
67+- A value that is exactly `"..."` (Go escapes) or `'...'` (literal) is
68+ unquoted. `spawn "a b"` stays as written.
69+- UTF-8, with an optional BOM and `\r\n` line ends.
70+- A repeated key overrides the earlier one, with a warning.
71+
72+## Tags
73+
74+| Tag | Meaning |
75+| --- | --- |
76+| `kv:"name"` | The key. A nested struct prefixes its keys with `name.` |
77+| `kv:",inline"` | A nested struct whose keys are not prefixed |
78+| `kv:"name,off"` | `off` sets the zero value |
79+| `kv:"name,nonempty"` | An empty value is rejected |
80+| `kv:"bind.*"` | A map: `bind.<name> = v`. A map of structs takes `bind.<name>.<field>`; names may contain dots |
81+| `kv:"*"` | `[]kvconf.Entry` or `map[string]string` collecting unknown keys |
82+| `min:"1" max:"10"` | Bounds of a number or duration (`max:"1h"`) |
83+| `oneof:"a\|b"` | Allowed values |
84+| `sep:"\|"` | List separator (default `,`) |
85+
86+Any type implementing `encoding.TextUnmarshaler` works as a field.
87+`Validate() error` on the config checks rules spanning several keys.
88+`SetDefaults()` fills defaults, also for each new element of a map of structs.
89+
90+## Errors and warnings
91+
92+By default a bad line or value becomes a `Warning` with its line and key,
93+and the rest of the file still applies. `Options.Strict` rejects the whole
94+file instead. `Options.AllowUnknown`, `AllowDuplicates`, `RequireFile`,
95+`MaxBytes` (1 MiB) and `MaxLine` (64 KiB) tune the rest.
96+
97+`Live` keeps the current config when the file is broken or removed, and
98+skips saves that change no value.
99+
100+## Lower-level API
101+
102+- `Parse`, `ParseBytes`: file to `[]Entry` without decoding.
103+- `Decode`, `Unmarshal`, `Marshal`: entries or bytes to and from a struct.
104+- `Watch(ctx, path, WatchOptions{...}, out)`: change notifications for one
105+ file, or every file of a directory with `Dir: true`.
106+- `Set`, `SetFile`: change keys in place, keeping comments and layout.
107+- `WriteFile`: atomic replace, written through symlinks.
108+- `ReadFile`: bounded read that rejects FIFOs and devices without blocking.
109+
110+## License
111+
112+MIT
A bench_test.go +118 -0
@@ -0,0 +1,118 @@
1+package kvconf
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+type benchConfig struct {
13+ Theme string `kv:"theme" oneof:"auto|dark|light"`
14+ Enabled bool `kv:"enabled"`
15+ Count int `kv:"count" min:"0" max:"1000"`
16+ After time.Duration `kv:"lock.after,off"`
17+ Ratio float64 `kv:"ratio"`
18+ Tags []string `kv:"tags"`
19+ Color string `kv:"border.color"`
20+ Binds map[string]string `kv:"bind.*"`
21+ Outputs map[string]output `kv:"output.*"`
22+}
23+
24+// benchFile is a realistic config: ~100 lines with comments, binds and
25+// per-output settings.
26+var benchFile = func() []byte {
27+ var b strings.Builder
28+ b.WriteString("# a comment\ntheme = dark\nenabled = on\ncount = 42\nlock.after = 5m\nratio = 1.5\n")
29+ b.WriteString("tags = a, b, c\nborder.color = #ff8800 # orange\n\n")
30+ for i := range 60 {
31+ fmt.Fprintf(&b, "bind.cmd+%d = spawn \"foot -e app%d\"\n", i, i)
32+ }
33+ for i := range 8 {
34+ fmt.Fprintf(&b, "output.DP-%d.scale = 1.25\noutput.DP-%d.hdr = off\noutput.DP-%d.sdr-brightness = 203\n", i, i, i)
35+ }
36+ return []byte(b.String())
37+}()
38+
39+func BenchmarkParse(b *testing.B) {
40+ b.ReportAllocs()
41+ b.SetBytes(int64(len(benchFile)))
42+ for b.Loop() {
43+ if _, _, err := ParseBytes(benchFile, Options{}); err != nil {
44+ b.Fatal(err)
45+ }
46+ }
47+}
48+
49+func BenchmarkUnmarshal(b *testing.B) {
50+ b.ReportAllocs()
51+ b.SetBytes(int64(len(benchFile)))
52+ for b.Loop() {
53+ var c benchConfig
54+ if w, err := Unmarshal(benchFile, &c, Options{}); err != nil || len(w) > 0 {
55+ b.Fatal(w, err)
56+ }
57+ }
58+}
59+
60+func BenchmarkDecodeScalars(b *testing.B) {
61+ entries, _, _ := ParseBytes([]byte("theme = dark\nenabled = on\ncount = 42\nlock.after = 5m\nratio = 1.5\n"), Options{})
62+ b.ReportAllocs()
63+ for b.Loop() {
64+ var c benchConfig
65+ if _, err := Decode(entries, &c, Options{}); err != nil {
66+ b.Fatal(err)
67+ }
68+ }
69+}
70+
71+func BenchmarkMarshal(b *testing.B) {
72+ var c benchConfig
73+ if _, err := Unmarshal(benchFile, &c, Options{}); err != nil {
74+ b.Fatal(err)
75+ }
76+ b.ReportAllocs()
77+ for b.Loop() {
78+ if _, err := Marshal(&c); err != nil {
79+ b.Fatal(err)
80+ }
81+ }
82+}
83+
84+// BenchmarkWatchEvent measures one change seen by the watcher: the event,
85+// its classification and the debounced report.
86+func BenchmarkWatchEvent(b *testing.B) {
87+ dir := b.TempDir()
88+ path := dir + "/config"
89+ if err := os.WriteFile(path, benchFile, 0o600); err != nil {
90+ b.Fatal(err)
91+ }
92+ out := make(chan struct{}, 1)
93+ ctx, cancel := context.WithCancel(context.Background())
94+ defer cancel()
95+ ready := make(chan struct{})
96+ go func() { _ = Watch(ctx, path, WatchOptions{Debounce: -1, Ready: ready}, out) }()
97+ <-ready
98+ f, err := os.OpenFile(path, os.O_WRONLY, 0)
99+ if err != nil {
100+ b.Fatal(err)
101+ }
102+ defer f.Close()
103+ b.ReportAllocs()
104+ for b.Loop() {
105+ if _, err := f.WriteAt([]byte{'#'}, 0); err != nil {
106+ b.Fatal(err)
107+ }
108+ <-out
109+ }
110+}
111+
112+func BenchmarkSet(b *testing.B) {
113+ values := map[string]*string{"output.DP-3.scale": ptr("2")}
114+ b.ReportAllocs()
115+ for b.Loop() {
116+ Set(benchFile, values)
117+ }
118+}
A decode.go +565 -0
@@ -0,0 +1,565 @@
1+package kvconf
2+
3+import (
4+ "encoding"
5+ "errors"
6+ "fmt"
7+ "reflect"
8+ "slices"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+// Struct tags understood by Decode and Marshal:
16+//
17+// kv:"name" the key; a nested struct prefixes its keys with "name."
18+// kv:",inline" a nested struct whose keys are not prefixed
19+// kv:"name,off" "off" sets the zero value (durations, numbers, strings)
20+// kv:"name,nonempty" an empty value is rejected
21+// kv:"bind.*" a map: bind.<name> = value fills map[name]. A map of
22+// structs takes bind.<name>.<field>; names may hold dots
23+// kv:"*" a []Entry or map[string]string that collects unknown keys
24+// kv:"-" ignored, like untagged fields
25+// min:"1" max:"100" bounds of a number or duration ("1s", "10m")
26+// oneof:"a|b|c" the allowed values
27+// sep:"|" the separator of a list (default ",")
28+//
29+// A value decodes into a string, a bool (on/off, true/false, yes/no, 1/0), an
30+// integer, a float, a time.Duration, a slice of those, or any type whose
31+// pointer implements encoding.TextUnmarshaler, such as [Path]. Named types
32+// work too: type Mode string with oneof is an enum.
33+
34+// Validator is implemented by a config that checks itself after decoding,
35+// for rules that span several keys. Its error always fails the load.
36+type Validator interface{ Validate() error }
37+
38+// Defaulter is implemented by a config, or by the element of a map of
39+// structs, that fills in its defaults. Load calls it before decoding; a new
40+// map element calls it before its first key is set.
41+type Defaulter interface{ SetDefaults() }
42+
43+var (
44+ durationType = reflect.TypeFor[time.Duration]()
45+ unmarshalType = reflect.TypeFor[encoding.TextUnmarshaler]()
46+ marshalType = reflect.TypeFor[encoding.TextMarshaler]()
47+ entrySliceType = reflect.TypeFor[[]Entry]()
48+ stringMapType = reflect.TypeFor[map[string]string]()
49+ defaulterType = reflect.TypeFor[Defaulter]()
50+ errTarget = errors.New("kvconf: target must be a non-nil pointer to a struct")
51+)
52+
53+type field struct {
54+ key string // full key; the prefix with its trailing dot for a map
55+ index []int
56+ typ reflect.Type
57+ text bool // *typ implements encoding.TextUnmarshaler
58+ duration bool
59+ off bool
60+ nonempty bool
61+ hasMin bool
62+ hasMax bool
63+ min, max float64
64+ minText string
65+ maxText string
66+ oneof []string
67+ sep string
68+ item *field // element of a slice
69+ val *field // element of a map of values
70+ elem *schema // element of a map of structs
71+ defaults bool // map element implements Defaulter
72+}
73+
74+type schema struct {
75+ keys map[string]*field
76+ order []*field // declaration order, for Marshal
77+ prefixes []*field // maps, longest prefix first
78+ catchAll *field
79+}
80+
81+var schemas sync.Map // reflect.Type -> *schema or error
82+
83+func schemaOf(t reflect.Type) (*schema, error) {
84+ if s, ok := schemas.Load(t); ok {
85+ if err, bad := s.(error); bad {
86+ return nil, err
87+ }
88+ return s.(*schema), nil
89+ }
90+ s, err := buildSchema(t)
91+ if err != nil {
92+ schemas.Store(t, err)
93+ return nil, err
94+ }
95+ schemas.Store(t, s)
96+ return s, nil
97+}
98+
99+func buildSchema(t reflect.Type) (*schema, error) {
100+ s := &schema{keys: map[string]*field{}}
101+ if err := s.add(t, "", nil); err != nil {
102+ return nil, err
103+ }
104+ slices.SortStableFunc(s.prefixes, func(a, b *field) int { return len(b.key) - len(a.key) })
105+ return s, nil
106+}
107+
108+func isText(t reflect.Type) bool { return reflect.PointerTo(t).Implements(unmarshalType) }
109+
110+func isLeaf(t reflect.Type) bool { return t.Kind() != reflect.Struct || isText(t) }
111+
112+func (s *schema) add(t reflect.Type, prefix string, index []int) error {
113+ for i := range t.NumField() {
114+ sf := t.Field(i)
115+ tag, ok := sf.Tag.Lookup("kv")
116+ if !ok || tag == "-" || !sf.IsExported() {
117+ continue
118+ }
119+ where := t.String() + "." + sf.Name
120+ name, flags, _ := strings.Cut(tag, ",")
121+ idx := append(slices.Clip(index), i)
122+ inline := false
123+ var opts []string
124+ for flag := range strings.SplitSeq(flags, ",") {
125+ switch flag {
126+ case "":
127+ case "inline":
128+ inline = true
129+ case "off", "nonempty":
130+ opts = append(opts, flag)
131+ default:
132+ return fmt.Errorf("kvconf: %s: unknown tag option %q", where, flag)
133+ }
134+ }
135+ switch {
136+ case name == "*":
137+ if sf.Type != entrySliceType && sf.Type != stringMapType {
138+ return fmt.Errorf("kvconf: %s: kv:\"*\" needs []kvconf.Entry or map[string]string", where)
139+ }
140+ if s.catchAll != nil {
141+ return fmt.Errorf("kvconf: %s: a second kv:\"*\" field", where)
142+ }
143+ s.catchAll = &field{index: idx, typ: sf.Type}
144+ case strings.HasSuffix(name, ".*"):
145+ if sf.Type.Kind() != reflect.Map || sf.Type.Key().Kind() != reflect.String {
146+ return fmt.Errorf("kvconf: %s: %q needs a map with string keys", where, name)
147+ }
148+ et := sf.Type.Elem()
149+ f := &field{}
150+ if isLeaf(et) {
151+ val, err := newField(where, et, sf.Tag, opts)
152+ if err != nil {
153+ return err
154+ }
155+ f.val = val
156+ } else {
157+ elem, err := buildSchema(et)
158+ if err != nil {
159+ return err
160+ }
161+ if elem.catchAll != nil || len(elem.prefixes) > 0 {
162+ return fmt.Errorf("kvconf: %s: a map element cannot hold maps or kv:\"*\"", where)
163+ }
164+ f = &field{elem: elem, defaults: reflect.PointerTo(et).Implements(defaulterType)}
165+ }
166+ f.key, f.index, f.typ = prefix+strings.TrimSuffix(name, "*"), idx, sf.Type
167+ s.prefixes = append(s.prefixes, f)
168+ s.order = append(s.order, f)
169+ case !isLeaf(sf.Type):
170+ p := prefix
171+ if !inline {
172+ if name == "" {
173+ return fmt.Errorf("kvconf: %s: a nested struct needs a name or inline", where)
174+ }
175+ p += name + "."
176+ }
177+ if err := s.add(sf.Type, p, idx); err != nil {
178+ return err
179+ }
180+ default:
181+ if name == "" {
182+ return fmt.Errorf("kvconf: %s: empty key", where)
183+ }
184+ f, err := newField(where, sf.Type, sf.Tag, opts)
185+ if err != nil {
186+ return err
187+ }
188+ f.key, f.index = prefix+name, idx
189+ if _, dup := s.keys[f.key]; dup {
190+ return fmt.Errorf("kvconf: %s: key %q declared twice", where, f.key)
191+ }
192+ s.keys[f.key] = f
193+ s.order = append(s.order, f)
194+ }
195+ }
196+ return nil
197+}
198+
199+// newField checks that t can be decoded and precomputes how.
200+func newField(where string, t reflect.Type, tag reflect.StructTag, opts []string) (*field, error) {
201+ f := &field{typ: t, text: isText(t), duration: t == durationType, sep: tag.Get("sep")}
202+ f.off = slices.Contains(opts, "off")
203+ f.nonempty = slices.Contains(opts, "nonempty")
204+ if f.sep == "" {
205+ f.sep = ","
206+ }
207+ if o := tag.Get("oneof"); o != "" {
208+ f.oneof = strings.Split(o, "|")
209+ }
210+ kind := t.Kind()
211+ if !f.text {
212+ switch kind {
213+ case reflect.String, reflect.Bool, reflect.Float32, reflect.Float64,
214+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
215+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
216+ case reflect.Slice:
217+ item, err := newField(where, t.Elem(), tag, nil)
218+ if err != nil {
219+ return nil, err
220+ }
221+ if item.item != nil {
222+ return nil, fmt.Errorf("kvconf: %s: nested lists are not supported", where)
223+ }
224+ item.oneof, f.oneof = f.oneof, nil
225+ f.item = item
226+ default:
227+ return nil, fmt.Errorf("kvconf: %s: unsupported type %s", where, t)
228+ }
229+ }
230+ bound := func(s string) (float64, error) {
231+ if f.duration || f.item != nil && f.item.duration {
232+ d, err := time.ParseDuration(s)
233+ return float64(d), err
234+ }
235+ return strconv.ParseFloat(s, 64)
236+ }
237+ var err error
238+ if f.minText = tag.Get("min"); f.minText != "" {
239+ f.hasMin = true
240+ if f.min, err = bound(f.minText); err != nil {
241+ return nil, fmt.Errorf("kvconf: %s: bad min %q", where, f.minText)
242+ }
243+ }
244+ if f.maxText = tag.Get("max"); f.maxText != "" {
245+ f.hasMax = true
246+ if f.max, err = bound(f.maxText); err != nil {
247+ return nil, fmt.Errorf("kvconf: %s: bad max %q", where, f.maxText)
248+ }
249+ }
250+ if f.item != nil {
251+ f.item.hasMin, f.item.min, f.item.minText = f.hasMin, f.min, f.minText
252+ f.item.hasMax, f.item.max, f.item.maxText = f.hasMax, f.max, f.maxText
253+ f.hasMin, f.hasMax = false, false
254+ }
255+ return f, nil
256+}
257+
258+// Unmarshal parses data and decodes it into v, a pointer to a struct. Fill v
259+// with its defaults first: keys absent from data keep their value.
260+func Unmarshal(data []byte, v any, opts Options) ([]Warning, error) {
261+ entries, warnings, err := ParseBytes(data, opts)
262+ if err != nil {
263+ return warnings, err
264+ }
265+ more, err := Decode(entries, v, opts)
266+ return append(warnings, more...), err
267+}
268+
269+// Decode sets the fields of v, a pointer to a struct, from entries in order,
270+// so a repeated key ends with its last valid value. An invalid value is
271+// reported and leaves its field unchanged, or fails the decode in strict
272+// mode. An unknown key is reported unless Options.AllowUnknown is set or a
273+// kv:"*" field collects it. Validate runs last when v implements Validator.
274+func Decode(entries []Entry, v any, opts Options) ([]Warning, error) {
275+ rv := reflect.ValueOf(v)
276+ if rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
277+ return nil, errTarget
278+ }
279+ s, err := schemaOf(rv.Elem().Type())
280+ if err != nil {
281+ return nil, err
282+ }
283+ st := decodeState{root: rv.Elem()}
284+ p := problems{strict: opts.Strict}
285+ for i := range entries {
286+ e := &entries[i]
287+ msg, known := s.apply(&st, e)
288+ if !known {
289+ if opts.AllowUnknown {
290+ continue
291+ }
292+ msg = "unknown key"
293+ }
294+ if msg != "" && !p.add(e.Line, e.Key, msg) {
295+ return p.list, p.err()
296+ }
297+ }
298+ st.flush()
299+ if val, ok := v.(Validator); ok {
300+ if err := val.Validate(); err != nil {
301+ return p.list, err
302+ }
303+ }
304+ return p.list, nil
305+}
306+
307+// decodeState holds the scratch values of one Decode call, so a key of a
308+// map does not allocate.
309+type decodeState struct {
310+ root reflect.Value
311+ // scratch key and element per map field, reused for every entry.
312+ keys, vals map[*field]reflect.Value
313+ // pending struct elements, written back to their maps once at the end
314+ // so each name is copied out of its map once, not once per key.
315+ pending map[*field]map[string]*pendingElem
316+}
317+
318+type pendingElem struct {
319+ val reflect.Value
320+ set bool // at least one key was accepted
321+}
322+
323+func (st *decodeState) scratch(f *field) (key, val reflect.Value) {
324+ if st.keys == nil {
325+ st.keys, st.vals = map[*field]reflect.Value{}, map[*field]reflect.Value{}
326+ }
327+ key, ok := st.keys[f]
328+ if !ok {
329+ key = reflect.New(f.typ.Key()).Elem()
330+ val = reflect.New(f.typ.Elem()).Elem()
331+ st.keys[f], st.vals[f] = key, val
332+ }
333+ return key, st.vals[f]
334+}
335+
336+// element returns the addressable pending element name of the map field f.
337+func (st *decodeState) element(f *field, m reflect.Value, name string) *pendingElem {
338+ if st.pending == nil {
339+ st.pending = map[*field]map[string]*pendingElem{}
340+ }
341+ byName := st.pending[f]
342+ if byName == nil {
343+ byName = map[string]*pendingElem{}
344+ st.pending[f] = byName
345+ }
346+ if p, ok := byName[name]; ok {
347+ return p
348+ }
349+ p := &pendingElem{val: reflect.New(f.typ.Elem()).Elem()}
350+ key, _ := st.scratch(f)
351+ key.SetString(name)
352+ if cur := m.MapIndex(key); cur.IsValid() {
353+ p.val.Set(cur)
354+ } else if f.defaults {
355+ p.val.Addr().Interface().(Defaulter).SetDefaults()
356+ }
357+ byName[name] = p
358+ return p
359+}
360+
361+// flush writes the pending struct elements back to their maps.
362+func (st *decodeState) flush() {
363+ for f, byName := range st.pending {
364+ m := st.root.FieldByIndex(f.index)
365+ key, _ := st.scratch(f)
366+ for name, p := range byName {
367+ if p.set {
368+ key.SetString(name)
369+ m.SetMapIndex(key, p.val)
370+ }
371+ }
372+ }
373+}
374+
375+// apply sets the field of e. msg explains a rejected value; known is false
376+// when no field takes the key.
377+func (s *schema) apply(st *decodeState, e *Entry) (msg string, known bool) {
378+ root := st.root
379+ if f := s.keys[e.Key]; f != nil {
380+ return f.set(root.FieldByIndex(f.index), e.Value), true
381+ }
382+ for _, f := range s.prefixes {
383+ rest, ok := strings.CutPrefix(e.Key, f.key)
384+ if !ok || rest == "" {
385+ continue
386+ }
387+ name, sub := rest, (*field)(nil)
388+ if f.elem != nil {
389+ // Names may contain dots: the longest field suffix wins.
390+ for _, ef := range f.elem.order {
391+ n, ok := strings.CutSuffix(rest, ef.key)
392+ if ok && len(n) > 1 && n[len(n)-1] == '.' && (sub == nil || len(ef.key) > len(sub.key)) {
393+ name, sub = n[:len(n)-1], ef
394+ }
395+ }
396+ if sub == nil {
397+ continue
398+ }
399+ }
400+ m := root.FieldByIndex(f.index)
401+ if m.IsNil() {
402+ m.Set(reflect.MakeMap(f.typ))
403+ }
404+ if sub != nil {
405+ // A name whose every key is rejected is not created.
406+ p := st.element(f, m, name)
407+ if msg = sub.set(p.val.FieldByIndex(sub.index), e.Value); msg == "" {
408+ p.set = true
409+ }
410+ return msg, true
411+ }
412+ if f.typ == stringMapType && f.val.plain() {
413+ (*(*map[string]string)(m.Addr().UnsafePointer()))[name] = e.Value
414+ return "", true
415+ }
416+ key, val := st.scratch(f)
417+ if msg = f.val.set(val, e.Value); msg == "" {
418+ key.SetString(name)
419+ m.SetMapIndex(key, val)
420+ }
421+ return msg, true
422+ }
423+ if f := s.catchAll; f != nil {
424+ dst := root.FieldByIndex(f.index)
425+ if f.typ == entrySliceType {
426+ list := dst.Addr().Interface().(*[]Entry)
427+ *list = append(*list, *e)
428+ } else {
429+ m := dst.Addr().Interface().(*map[string]string)
430+ if *m == nil {
431+ *m = map[string]string{}
432+ }
433+ (*m)[e.Key] = e.Value
434+ }
435+ return "", true
436+ }
437+ return "", false
438+}
439+
440+// set decodes raw into dst, leaving dst unchanged on failure. The message
441+// never contains raw: values may be secrets.
442+func (f *field) set(dst reflect.Value, raw string) string {
443+ if f.off && raw == "off" {
444+ dst.SetZero()
445+ return ""
446+ }
447+ if f.nonempty && raw == "" {
448+ return "must not be empty"
449+ }
450+ if err := f.decode(dst, raw); err != nil {
451+ return err.Error()
452+ }
453+ return ""
454+}
455+
456+func (f *field) decode(dst reflect.Value, raw string) error {
457+ if f.oneof != nil && !slices.Contains(f.oneof, raw) {
458+ return errors.New("must be " + orList(f.oneof))
459+ }
460+ if f.text {
461+ p := reflect.New(f.typ)
462+ if err := p.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw)); err != nil {
463+ return err
464+ }
465+ dst.Set(p.Elem())
466+ return nil
467+ }
468+ switch f.typ.Kind() {
469+ case reflect.String:
470+ dst.SetString(raw)
471+ case reflect.Bool:
472+ b, err := ParseBool(raw)
473+ if err != nil {
474+ return err
475+ }
476+ dst.SetBool(b)
477+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
478+ var n int64
479+ var err error
480+ if f.duration {
481+ var d time.Duration
482+ if d, err = time.ParseDuration(raw); err != nil {
483+ return errors.New("must be a duration such as 500ms, 5m or 1h30m")
484+ }
485+ n = int64(d)
486+ } else if n, err = strconv.ParseInt(raw, 10, f.typ.Bits()); err != nil {
487+ return errors.New("must be an integer")
488+ }
489+ if err := f.check(float64(n)); err != nil {
490+ return err
491+ }
492+ dst.SetInt(n)
493+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
494+ n, err := strconv.ParseUint(raw, 10, f.typ.Bits())
495+ if err != nil {
496+ return errors.New("must be a non-negative integer")
497+ }
498+ if err := f.check(float64(n)); err != nil {
499+ return err
500+ }
501+ dst.SetUint(n)
502+ case reflect.Float32, reflect.Float64:
503+ x, err := strconv.ParseFloat(raw, f.typ.Bits())
504+ if err != nil {
505+ return errors.New("must be a number")
506+ }
507+ if err := f.check(x); err != nil {
508+ return err
509+ }
510+ dst.SetFloat(x)
511+ case reflect.Slice:
512+ n := 0
513+ for part := range strings.SplitSeq(raw, f.sep) {
514+ if strings.TrimSpace(part) != "" {
515+ n++
516+ }
517+ }
518+ list := reflect.MakeSlice(f.typ, n, n)
519+ i := 0
520+ for part := range strings.SplitSeq(raw, f.sep) {
521+ if part = strings.TrimSpace(part); part == "" {
522+ continue
523+ }
524+ if err := f.item.decode(list.Index(i), part); err != nil {
525+ return fmt.Errorf("item %d: %w", i+1, err)
526+ }
527+ i++
528+ }
529+ dst.Set(list)
530+ }
531+ return nil
532+}
533+
534+// plain reports a string field without any constraint.
535+func (f *field) plain() bool {
536+ return f.typ.Kind() == reflect.String && !f.text && !f.off && !f.nonempty && f.oneof == nil
537+}
538+
539+func (f *field) check(x float64) error {
540+ if f.hasMin && x < f.min {
541+ return errors.New("must be at least " + f.minText)
542+ }
543+ if f.hasMax && x > f.max {
544+ return errors.New("must be at most " + f.maxText)
545+ }
546+ return nil
547+}
548+
549+func orList(items []string) string {
550+ if len(items) == 1 {
551+ return items[0]
552+ }
553+ return strings.Join(items[:len(items)-1], ", ") + " or " + items[len(items)-1]
554+}
555+
556+// ParseBool accepts on/off, true/false, yes/no and 1/0, in any case.
557+func ParseBool(s string) (bool, error) {
558+ switch {
559+ case s == "on" || s == "1" || strings.EqualFold(s, "on") || strings.EqualFold(s, "true") || strings.EqualFold(s, "yes"):
560+ return true, nil
561+ case s == "off" || s == "0" || strings.EqualFold(s, "off") || strings.EqualFold(s, "false") || strings.EqualFold(s, "no"):
562+ return false, nil
563+ }
564+ return false, errors.New("must be on or off")
565+}
A decode_test.go +358 -0
@@ -0,0 +1,358 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+type mode string
14+
15+type output struct {
16+ Scale float64 `kv:"scale" min:"0.25" max:"4"`
17+ HDR bool `kv:"hdr"`
18+ Primary bool `kv:"primary"`
19+ Bright int `kv:"sdr-brightness" min:"80" max:"1000"`
20+}
21+
22+func (o *output) SetDefaults() { o.Scale, o.Bright = 1, 203 }
23+
24+type testConfig struct {
25+ Name string `kv:"name,nonempty"`
26+ Enabled bool `kv:"enabled"`
27+ Count int `kv:"count" min:"1" max:"10"`
28+ Size uint16 `kv:"size"`
29+ Ratio float32 `kv:"ratio"`
30+ After time.Duration `kv:"lock.after,off" max:"1h"`
31+ Mode mode `kv:"mode" oneof:"auto|dark|light"`
32+ Tags []string `kv:"tags"`
33+ Ports []int `kv:"ports" sep:"|" min:"1" max:"65535"`
34+ Dir Path `kv:"dir"`
35+ Fade struct {
36+ After time.Duration `kv:"after"`
37+ Duration time.Duration `kv:"duration"`
38+ } `kv:"fade"`
39+ Inline struct {
40+ Flag bool `kv:"flag"`
41+ } `kv:",inline"`
42+ Binds map[string]string `kv:"bind.*"`
43+ Outputs map[string]output `kv:"output.*"`
44+ Codes map[string]int `kv:"code.*" min:"0"`
45+ Ignored string `kv:"-"`
46+ Rest []Entry `kv:"*"`
47+ Plain string // untagged: ignored
48+}
49+
50+func defaults() testConfig {
51+ c := testConfig{Name: "x", Count: 5, Mode: "auto", After: 5 * time.Minute}
52+ return c
53+}
54+
55+func TestDecode(t *testing.T) {
56+ home, _ := os.UserHomeDir()
57+ tests := []struct {
58+ name string
59+ in string
60+ opts Options
61+ check func(t *testing.T, c *testConfig)
62+ warnings []string
63+ err string
64+ }{
65+ {
66+ name: "scalars",
67+ in: `name = vev
68+enabled = on
69+count = 7
70+size = 512
71+ratio = 0.5
72+lock.after = 10m
73+mode = dark
74+flag = yes
75+`,
76+ check: func(t *testing.T, c *testConfig) {
77+ want := defaults()
78+ want.Name, want.Enabled, want.Count, want.Size, want.Ratio = "vev", true, 7, 512, 0.5
79+ want.After, want.Mode, want.Inline.Flag = 10*time.Minute, "dark", true
80+ want.Rest = nil
81+ if !reflect.DeepEqual(*c, want) {
82+ t.Errorf("got %+v\nwant %+v", *c, want)
83+ }
84+ },
85+ },
86+ {
87+ name: "absent keys keep defaults",
88+ in: "",
89+ check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 5); eq(t, c.After, 5*time.Minute) },
90+ },
91+ {
92+ name: "off is zero",
93+ in: "lock.after = off\n",
94+ check: func(t *testing.T, c *testConfig) { eq(t, c.After, time.Duration(0)) },
95+ },
96+ {
97+ name: "nested struct",
98+ in: "fade.after = 1s\nfade.duration = 250ms\n",
99+ check: func(t *testing.T, c *testConfig) {
100+ eq(t, c.Fade.After, time.Second)
101+ eq(t, c.Fade.Duration, 250*time.Millisecond)
102+ },
103+ },
104+ {
105+ name: "lists",
106+ in: "tags = a, b ,,c\nports = 80|443\n",
107+ check: func(t *testing.T, c *testConfig) {
108+ eq(t, c.Tags, []string{"a", "b", "c"})
109+ eq(t, c.Ports, []int{80, 443})
110+ },
111+ },
112+ {
113+ name: "empty list",
114+ in: "tags =\n",
115+ check: func(t *testing.T, c *testConfig) { eq(t, c.Tags, []string{}) },
116+ },
117+ {
118+ name: "path",
119+ in: "dir = ~/shots/../pics\n",
120+ check: func(t *testing.T, c *testConfig) { eq(t, c.Dir, Path(filepath.Join(home, "pics"))) },
121+ },
122+ {
123+ name: "map of strings",
124+ in: "bind.cmd+q = close\nbind.cmd+return = spawn foot\n",
125+ check: func(t *testing.T, c *testConfig) {
126+ eq(t, c.Binds, map[string]string{"cmd+q": "close", "cmd+return": "spawn foot"})
127+ },
128+ },
129+ {
130+ name: "map of structs with dotted names and defaults",
131+ in: "output.DP-1.scale = 1.5\noutput.eDP.1.hdr = on\noutput.DP-1.primary = on\n",
132+ check: func(t *testing.T, c *testConfig) {
133+ eq(t, c.Outputs, map[string]output{
134+ "DP-1": {Scale: 1.5, Primary: true, Bright: 203},
135+ "eDP.1": {Scale: 1, HDR: true, Bright: 203},
136+ })
137+ },
138+ },
139+ {
140+ name: "map of structs rejects bad value without creating the entry",
141+ in: "output.DP-1.scale = 9\n",
142+ check: func(t *testing.T, c *testConfig) { eq(t, len(c.Outputs), 0) },
143+ warnings: []string{"line 1: output.DP-1.scale: must be at most 4"},
144+ },
145+ {
146+ name: "map of ints",
147+ in: "code.foo = 3\n",
148+ check: func(t *testing.T, c *testConfig) { eq(t, c.Codes, map[string]int{"foo": 3}) },
149+ },
150+ {
151+ name: "catch-all keeps unknown keys in order",
152+ in: "zz = 1\naa = 2\noutput.DP-1.nope = 3\n",
153+ check: func(t *testing.T, c *testConfig) {
154+ eq(t, c.Rest, []Entry{{"zz", "1", 1}, {"aa", "2", 2}, {"output.DP-1.nope", "3", 3}})
155+ },
156+ },
157+ {
158+ name: "invalid values keep the previous value",
159+ in: `count = 0
160+count = 11
161+count = x
162+enabled = maybe
163+mode = blue
164+lock.after = 2h
165+lock.after = soon
166+name =
167+size = -1
168+ratio = big
169+ports = 80|0
170+dir = rel/path
171+`,
172+ check: func(t *testing.T, c *testConfig) {
173+ want := defaults()
174+ if !reflect.DeepEqual(*c, want) {
175+ t.Errorf("got %+v\nwant %+v", *c, want)
176+ }
177+ },
178+ warnings: []string{
179+ "line 2: count: overrides line 1",
180+ "line 3: count: overrides line 2",
181+ "line 7: lock.after: overrides line 6",
182+ "line 1: count: must be at least 1",
183+ "line 2: count: must be at most 10",
184+ "line 3: count: must be an integer",
185+ "line 4: enabled: must be on or off",
186+ "line 5: mode: must be auto, dark or light",
187+ "line 6: lock.after: must be at most 1h",
188+ "line 7: lock.after: must be a duration such as 500ms, 5m or 1h30m",
189+ "line 8: name: must not be empty",
190+ "line 9: size: must be a non-negative integer",
191+ "line 10: ratio: must be a number",
192+ "line 11: ports: item 2: must be at least 1",
193+ "line 12: dir: must be an absolute path or start with ~/",
194+ },
195+ },
196+ {
197+ name: "last valid value wins",
198+ in: "count = 2\ncount = 3\n",
199+ opts: Options{AllowDuplicates: true},
200+ check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 3) },
201+ },
202+ {
203+ name: "strict fails on first bad value",
204+ in: "count = 2\ncount = 99\nmode = x\n",
205+ opts: Options{Strict: true, AllowDuplicates: true},
206+ warnings: []string{"line 2: count: must be at most 10"},
207+ err: "line 2: count: must be at most 10",
208+ },
209+ {
210+ name: "messages never contain the value",
211+ in: "count = hunter2\n",
212+ warnings: []string{"line 1: count: must be an integer"},
213+ check: func(t *testing.T, c *testConfig) {},
214+ },
215+ }
216+ for _, tt := range tests {
217+ t.Run(tt.name, func(t *testing.T) {
218+ c := defaults()
219+ warnings, err := Unmarshal([]byte(tt.in), &c, tt.opts)
220+ if tt.err != "" {
221+ if err == nil || err.Error() != tt.err {
222+ t.Fatalf("err = %v, want %q", err, tt.err)
223+ }
224+ } else if err != nil {
225+ t.Fatalf("err = %v", err)
226+ }
227+ if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
228+ t.Errorf("warnings =\n%q\nwant\n%q", got, tt.warnings)
229+ }
230+ if tt.check != nil {
231+ tt.check(t, &c)
232+ }
233+ })
234+ }
235+}
236+
237+type plain struct {
238+ A int `kv:"a"`
239+}
240+
241+type validated struct {
242+ Min int `kv:"min"`
243+ Max int `kv:"max"`
244+}
245+
246+func (v *validated) Validate() error {
247+ if v.Min > v.Max {
248+ return errors.New("min must not exceed max")
249+ }
250+ return nil
251+}
252+
253+func TestDecodeOptionsAndValidate(t *testing.T) {
254+ tests := []struct {
255+ name string
256+ in string
257+ target any
258+ opts Options
259+ warnings []string
260+ err string
261+ }{
262+ {name: "unknown key warns", in: "b = 1\n", target: &plain{}, warnings: []string{"line 1: b: unknown key"}},
263+ {name: "unknown key allowed", in: "b = 1\n", target: &plain{}, opts: Options{AllowUnknown: true}},
264+ {name: "unknown key strict", in: "b = 1\n", target: &plain{}, opts: Options{Strict: true},
265+ warnings: []string{"line 1: b: unknown key"}, err: "line 1: b: unknown key"},
266+ {name: "validate passes", in: "min = 1\nmax = 2\n", target: &validated{}},
267+ {name: "validate fails", in: "min = 3\nmax = 2\n", target: &validated{}, err: "min must not exceed max"},
268+ {name: "not a pointer", target: plain{}, err: errTarget.Error()},
269+ {name: "nil pointer", target: (*plain)(nil), err: errTarget.Error()},
270+ }
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ warnings, err := Unmarshal([]byte(tt.in), tt.target, tt.opts)
274+ if gotErr := errString(err); gotErr != tt.err {
275+ t.Fatalf("err = %q, want %q", gotErr, tt.err)
276+ }
277+ if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
278+ t.Errorf("warnings = %q, want %q", got, tt.warnings)
279+ }
280+ })
281+ }
282+}
283+
284+func errString(err error) string {
285+ if err == nil {
286+ return ""
287+ }
288+ return err.Error()
289+}
290+
291+func TestSchemaErrors(t *testing.T) {
292+ tests := []struct {
293+ name string
294+ target any
295+ want string
296+ }{
297+ {"unknown option", &struct {
298+ A int `kv:"a,bogus"`
299+ }{}, `unknown tag option "bogus"`},
300+ {"unsupported type", &struct {
301+ A chan int `kv:"a"`
302+ }{}, "unsupported type chan int"},
303+ {"duplicate key", &struct {
304+ A int `kv:"a"`
305+ B int `kv:"a"`
306+ }{}, `key "a" declared twice`},
307+ {"map without string keys", &struct {
308+ A map[int]int `kv:"a.*"`
309+ }{}, "needs a map with string keys"},
310+ {"bad catch-all", &struct {
311+ A int `kv:"*"`
312+ }{}, `needs []kvconf.Entry or map[string]string`},
313+ {"unnamed struct", &struct {
314+ A struct{} `kv:""`
315+ }{}, "needs a name or inline"},
316+ {"bad bound", &struct {
317+ A int `kv:"a" min:"x"`
318+ }{}, `bad min "x"`},
319+ {"nested list", &struct {
320+ A [][]int `kv:"a"`
321+ }{}, "nested lists are not supported"},
322+ }
323+ for _, tt := range tests {
324+ t.Run(tt.name, func(t *testing.T) {
325+ _, err := Unmarshal(nil, tt.target, Options{})
326+ if err == nil || !strings.Contains(err.Error(), tt.want) {
327+ t.Fatalf("err = %v, want it to contain %q", err, tt.want)
328+ }
329+ })
330+ }
331+}
332+
333+func TestParseBool(t *testing.T) {
334+ tests := []struct {
335+ in string
336+ want bool
337+ err bool
338+ }{
339+ {"on", true, false}, {"ON", true, false}, {"true", true, false}, {"Yes", true, false}, {"1", true, false},
340+ {"off", false, false}, {"False", false, false}, {"no", false, false}, {"0", false, false},
341+ {"", false, true}, {"2", false, true}, {"enable", false, true},
342+ }
343+ for _, tt := range tests {
344+ t.Run(tt.in, func(t *testing.T) {
345+ got, err := ParseBool(tt.in)
346+ if (err != nil) != tt.err || got != tt.want {
347+ t.Fatalf("ParseBool(%q) = %v, %v", tt.in, got, err)
348+ }
349+ })
350+ }
351+}
352+
353+func eq[T any](t *testing.T, got, want T) {
354+ t.Helper()
355+ if !reflect.DeepEqual(got, want) {
356+ t.Errorf("got %#v, want %#v", got, want)
357+ }
358+}
A file.go +103 -0
@@ -0,0 +1,103 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+)
12+
13+// ReadFile reads a config file safely: it must be a regular file (a FIFO or
14+// device is rejected without blocking) no larger than opts.MaxBytes. A
15+// missing file returns an error wrapping fs.ErrNotExist.
16+func ReadFile(path string, opts Options) ([]byte, error) {
17+ f, err := openNonblocking(path)
18+ if err != nil {
19+ return nil, err
20+ }
21+ defer f.Close() //nolint:errcheck // read only
22+ info, err := f.Stat()
23+ if err != nil {
24+ return nil, err
25+ }
26+ if !info.Mode().IsRegular() {
27+ return nil, &os.PathError{Op: "read", Path: path, Err: errNotRegular}
28+ }
29+ data, err := readLimited(f, opts.maxBytes())
30+ if errors.Is(err, ErrTooLarge) {
31+ return nil, fmt.Errorf("%w: %s", ErrTooLarge, path)
32+ }
33+ return data, err
34+}
35+
36+// DefaultPath returns <config dir>/<app>/<name>. The config dir is
37+// $XDG_CONFIG_HOME when it is an absolute path; otherwise ~/.config on Unix,
38+// ~/Library/Application Support on macOS and %AppData% on Windows.
39+func DefaultPath(app, name string) (string, error) {
40+ if dir := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(dir) {
41+ return filepath.Join(dir, app, name), nil
42+ }
43+ var dir string
44+ switch runtime.GOOS {
45+ case "darwin", "ios", "windows", "plan9":
46+ d, err := os.UserConfigDir()
47+ if err != nil {
48+ return "", err
49+ }
50+ dir = d
51+ default:
52+ home, err := os.UserHomeDir()
53+ if err != nil {
54+ return "", err
55+ }
56+ dir = filepath.Join(home, ".config")
57+ }
58+ return filepath.Join(dir, app, name), nil
59+}
60+
61+// Path is a file system path value: it accepts an absolute path or one
62+// starting with ~/ (the home directory), and stores it cleaned. Use it as a
63+// field type to validate paths while decoding.
64+type Path string
65+
66+// UnmarshalText implements encoding.TextUnmarshaler.
67+func (p *Path) UnmarshalText(text []byte) error {
68+ s, err := ExpandPath(string(text))
69+ if err != nil {
70+ return err
71+ }
72+ *p = Path(s)
73+ return nil
74+}
75+
76+// MarshalText implements encoding.TextMarshaler.
77+func (p Path) MarshalText() ([]byte, error) { return []byte(p), nil }
78+
79+// ExpandPath expands a leading ~/ and requires an absolute, NUL-free path.
80+func ExpandPath(s string) (string, error) {
81+ if s == "" || strings.IndexByte(s, 0) >= 0 {
82+ return "", errors.New("must be a non-empty path")
83+ }
84+ if rest, ok := strings.CutPrefix(s, "~/"); ok || s == "~" {
85+ if !ok {
86+ rest = ""
87+ }
88+ home, err := os.UserHomeDir()
89+ if err != nil || !filepath.IsAbs(home) {
90+ return "", errors.New("no absolute home directory for ~")
91+ }
92+ s = filepath.Join(home, rest)
93+ }
94+ if !filepath.IsAbs(s) {
95+ return "", errors.New("must be an absolute path or start with ~/")
96+ }
97+ return filepath.Clean(s), nil
98+}
99+
100+var errNotRegular = errors.New("not a regular file")
101+
102+// isNotExist reports a missing file, including a missing parent directory.
103+func isNotExist(err error) bool { return errors.Is(err, fs.ErrNotExist) }
A fileid_other.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build !unix
2+
3+package kvconf
4+
5+import "io/fs"
6+
7+// fileID is unknown here: size and modification time identify a version.
8+func fileID(fs.FileInfo) uint64 { return 0 }
A fileid_unix.go +16 -0
@@ -0,0 +1,16 @@
1+//go:build unix
2+
3+package kvconf
4+
5+import (
6+ "io/fs"
7+ "syscall"
8+)
9+
10+// fileID is the inode: it changes when a file is replaced by a rename.
11+func fileID(info fs.FileInfo) uint64 {
12+ if st, ok := info.Sys().(*syscall.Stat_t); ok {
13+ return uint64(st.Ino)
14+ }
15+ return 0
16+}
A go.mod +3 -0
@@ -0,0 +1,3 @@
1+module github.com/bnema/kvconf
2+
3+go 1.27
A live.go +177 -0
@@ -0,0 +1,177 @@
1+package kvconf
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io/fs"
8+ "sync/atomic"
9+)
10+
11+// Load reads the config file at path into a new T: it starts from the
12+// defaults (SetDefaults when *T implements Defaulter), then applies the file.
13+// A missing file gives the defaults, unless opts.RequireFile is set.
14+func Load[T any](path string, opts Options) (*T, []Warning, error) {
15+ cfg, _, warnings, err := load[T](path, opts)
16+ return cfg, warnings, err
17+}
18+
19+func load[T any](path string, opts Options) (*T, []Entry, []Warning, error) {
20+ data, err := ReadFile(path, opts)
21+ if err != nil {
22+ if isNotExist(err) && !opts.RequireFile {
23+ cfg := newDefault[T]()
24+ if v, ok := any(cfg).(Validator); ok {
25+ if err := v.Validate(); err != nil {
26+ return nil, nil, nil, err
27+ }
28+ }
29+ return cfg, nil, nil, nil
30+ }
31+ return nil, nil, nil, err
32+ }
33+ return decodeFile[T](path, data, opts)
34+}
35+
36+func decodeFile[T any](path string, data []byte, opts Options) (*T, []Entry, []Warning, error) {
37+ cfg := newDefault[T]()
38+ entries, warnings, err := ParseBytes(data, opts)
39+ if err != nil {
40+ return nil, nil, warnings, fmt.Errorf("%s: %w", path, err)
41+ }
42+ more, err := Decode(entries, cfg, opts)
43+ warnings = append(warnings, more...)
44+ if err != nil {
45+ return nil, nil, warnings, fmt.Errorf("%s: %w", path, err)
46+ }
47+ return cfg, entries, warnings, nil
48+}
49+
50+func newDefault[T any]() *T {
51+ cfg := new(T)
52+ if d, ok := any(cfg).(Defaulter); ok {
53+ d.SetDefaults()
54+ }
55+ return cfg
56+}
57+
58+// Change describes a reload.
59+type Change[T any] struct {
60+ Old, New *T
61+ // Keys lists, sorted, the keys added, removed or changed.
62+ Keys []string
63+ Warnings []Warning
64+}
65+
66+// LiveOptions controls a Live config.
67+type LiveOptions[T any] struct {
68+ Options
69+ Watch WatchOptions
70+ // OnChange is called from Run after each reload that changed a value.
71+ OnChange func(Change[T])
72+ // OnError is called from Run when a reload fails; the current config
73+ // stays in place. A removed file reports an error wrapping
74+ // fs.ErrNotExist.
75+ OnError func(error)
76+}
77+
78+// Live is a config that reloads itself when its file changes. Get is safe
79+// for concurrent use; a config it returned is never modified, a reload
80+// replaces it.
81+type Live[T any] struct {
82+ path string
83+ opts LiveOptions[T]
84+ cur atomic.Pointer[T]
85+ entries []Entry
86+}
87+
88+// Open loads the config file at path and returns it with the warnings of
89+// the first load. Call Run to follow its changes.
90+func Open[T any](path string, opts LiveOptions[T]) (*Live[T], []Warning, error) {
91+ cfg, entries, warnings, err := load[T](path, opts.Options)
92+ if err != nil {
93+ return nil, warnings, err
94+ }
95+ l := &Live[T]{path: path, opts: opts, entries: entries}
96+ l.cur.Store(cfg)
97+ return l, warnings, nil
98+}
99+
100+// Get returns the current config. Do not modify it.
101+func (l *Live[T]) Get() *T { return l.cur.Load() }
102+
103+// Path returns the path of the config file.
104+func (l *Live[T]) Path() string { return l.path }
105+
106+// Run watches the file and reloads it until ctx ends. A reload that fails,
107+// or a removed file, keeps the current config. A save that changes no value
108+// (a comment, spacing, the same file written again) reloads nothing. Run
109+// returns nil when ctx ends, or the error of the watcher.
110+func (l *Live[T]) Run(ctx context.Context) error {
111+ ctx, cancel := context.WithCancel(ctx)
112+ defer cancel()
113+ changed := make(chan struct{}, 1)
114+ ready := make(chan struct{})
115+ wopts := l.opts.Watch
116+ userReady := wopts.Ready
117+ wopts.Ready = ready
118+ errc := make(chan error, 1)
119+ go func() { errc <- Watch(ctx, l.path, wopts, changed) }()
120+ select {
121+ case <-ready:
122+ // A save between Open and the watch would go unseen: check once.
123+ l.reload()
124+ if userReady != nil {
125+ close(userReady)
126+ }
127+ case err := <-errc:
128+ return err
129+ }
130+ for {
131+ select {
132+ case <-changed:
133+ l.reload()
134+ case err := <-errc:
135+ return err
136+ }
137+ }
138+}
139+
140+func (l *Live[T]) reload() {
141+ data, err := ReadFile(l.path, l.opts.Options)
142+ if isNotExist(err) {
143+ // Keep the current config: a removed file is often about to be
144+ // replaced, and an empty config is rarely what the user meant.
145+ if l.entries != nil {
146+ l.report(fmt.Errorf("%s: %w", l.path, fs.ErrNotExist))
147+ }
148+ return
149+ }
150+ if err != nil {
151+ l.report(err)
152+ return
153+ }
154+ cfg, entries, warnings, err := decodeFile[T](l.path, data, l.opts.Options)
155+ if err != nil {
156+ l.report(err)
157+ return
158+ }
159+ if sameEntries(entries, l.entries) {
160+ return
161+ }
162+ keys := Changed(Values(l.entries), Values(entries))
163+ l.entries = entries
164+ if len(keys) == 0 {
165+ return
166+ }
167+ old := l.cur.Swap(cfg)
168+ if l.opts.OnChange != nil {
169+ l.opts.OnChange(Change[T]{Old: old, New: cfg, Keys: keys, Warnings: warnings})
170+ }
171+}
172+
173+func (l *Live[T]) report(err error) {
174+ if l.opts.OnError != nil && !errors.Is(err, context.Canceled) {
175+ l.opts.OnError(err)
176+ }
177+}
A live_test.go +283 -0
@@ -0,0 +1,283 @@
1+package kvconf
2+
3+import (
4+ "context"
5+ "errors"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+)
14+
15+type liveConfig struct {
16+ Theme string `kv:"theme" oneof:"dark|light"`
17+ After time.Duration `kv:"after"`
18+}
19+
20+func (c *liveConfig) SetDefaults() { c.Theme, c.After = "dark", time.Minute }
21+
22+type strictPair struct {
23+ A int `kv:"a"`
24+ B int `kv:"b"`
25+}
26+
27+func (p *strictPair) Validate() error {
28+ if p.A > p.B {
29+ return errors.New("a must not exceed b")
30+ }
31+ return nil
32+}
33+
34+func TestLoad(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ content *string // nil: no file
38+ opts Options
39+ want liveConfig
40+ warnings int
41+ err string
42+ }{
43+ {name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
44+ {name: "missing file required", opts: Options{RequireFile: true}, err: "config"},
45+ {name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
46+ {name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
47+ {name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ path := filepath.Join(t.TempDir(), "config")
52+ if tt.content != nil {
53+ must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
54+ }
55+ cfg, warnings, err := Load[liveConfig](path, tt.opts)
56+ if tt.err != "" {
57+ if err == nil || !strings.Contains(err.Error(), tt.err) {
58+ t.Fatalf("err = %v, want %q", err, tt.err)
59+ }
60+ return
61+ }
62+ must(t, err)
63+ eq(t, *cfg, tt.want)
64+ eq(t, len(warnings), tt.warnings)
65+ })
66+ }
67+}
68+
69+func TestLoadValidatesDefaults(t *testing.T) {
70+ _, _, err := Load[strictPair](filepath.Join(t.TempDir(), "none"), Options{})
71+ must(t, err)
72+ path := writeTemp(t, t.TempDir(), "config", "a = 3\nb = 2\n")
73+ if _, _, err := Load[strictPair](path, Options{}); err == nil || err.Error() != path+": a must not exceed b" {
74+ t.Fatalf("err = %v", err)
75+ }
76+}
77+
78+// liveRecorder collects what a Live reports.
79+type liveRecorder struct {
80+ mu sync.Mutex
81+ changes []Change[liveConfig]
82+ errs []error
83+ signal chan struct{}
84+}
85+
86+func newRecorder() *liveRecorder { return &liveRecorder{signal: make(chan struct{}, 16)} }
87+
88+func (r *liveRecorder) opts() LiveOptions[liveConfig] {
89+ return LiveOptions[liveConfig]{
90+ Watch: WatchOptions{Debounce: 20 * time.Millisecond},
91+ OnChange: func(c Change[liveConfig]) {
92+ r.mu.Lock()
93+ r.changes = append(r.changes, c)
94+ r.mu.Unlock()
95+ r.signal <- struct{}{}
96+ },
97+ OnError: func(err error) {
98+ r.mu.Lock()
99+ r.errs = append(r.errs, err)
100+ r.mu.Unlock()
101+ r.signal <- struct{}{}
102+ },
103+ }
104+}
105+
106+func (r *liveRecorder) wait(t *testing.T, why string) {
107+ t.Helper()
108+ select {
109+ case <-r.signal:
110+ case <-time.After(5 * time.Second):
111+ t.Fatalf("nothing reported after %s", why)
112+ }
113+}
114+
115+func (r *liveRecorder) quiet(t *testing.T, why string) {
116+ t.Helper()
117+ select {
118+ case <-r.signal:
119+ t.Fatalf("unexpected report after %s", why)
120+ case <-time.After(quiet):
121+ }
122+}
123+
124+func (r *liveRecorder) last() (Change[liveConfig], error) {
125+ r.mu.Lock()
126+ defer r.mu.Unlock()
127+ var c Change[liveConfig]
128+ var err error
129+ if len(r.changes) > 0 {
130+ c = r.changes[len(r.changes)-1]
131+ }
132+ if len(r.errs) > 0 {
133+ err = r.errs[len(r.errs)-1]
134+ }
135+ return c, err
136+}
137+
138+func TestLive(t *testing.T) {
139+ tests := []struct {
140+ name string
141+ initial *string
142+ steps func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder)
143+ }{
144+ {
145+ name: "reload on change",
146+ initial: ptr("theme = dark\n"),
147+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
148+ old := l.Get()
149+ must(t, WriteFile(path, []byte("theme = light\n"), 0o600))
150+ r.wait(t, "a change")
151+ c, _ := r.last()
152+ eq(t, c.Keys, []string{"theme"})
153+ eq(t, c.Old, old)
154+ eq(t, l.Get().Theme, "light")
155+ eq(t, old.Theme, "dark") // a published config is never modified
156+ },
157+ },
158+ {
159+ name: "comment and spacing only",
160+ initial: ptr("theme = light\n"),
161+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
162+ must(t, os.WriteFile(path, []byte("# note\ntheme = light # same\n"), 0o600))
163+ r.quiet(t, "a save that changes no value")
164+ },
165+ },
166+ {
167+ name: "broken file keeps the config",
168+ initial: ptr("theme = light\n"),
169+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
170+ must(t, os.WriteFile(path, []byte("theme = light\n\xff\n"), 0o600))
171+ r.wait(t, "an invalid file")
172+ _, err := r.last()
173+ if !errors.Is(err, ErrNotUTF8) {
174+ t.Fatalf("err = %v", err)
175+ }
176+ eq(t, l.Get().Theme, "light")
177+ },
178+ },
179+ {
180+ name: "removed file keeps the config",
181+ initial: ptr("theme = light\n"),
182+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
183+ must(t, os.Remove(path))
184+ r.wait(t, "a removal")
185+ _, err := r.last()
186+ if !errors.Is(err, fs.ErrNotExist) {
187+ t.Fatalf("err = %v", err)
188+ }
189+ eq(t, l.Get().Theme, "light")
190+ must(t, os.WriteFile(path, []byte("theme = dark\n"), 0o600))
191+ r.wait(t, "the file came back")
192+ eq(t, l.Get().Theme, "dark")
193+ },
194+ },
195+ {
196+ name: "file created after start",
197+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
198+ eq(t, l.Get().Theme, "dark")
199+ must(t, os.WriteFile(path, []byte("after = 5s\n"), 0o600))
200+ r.wait(t, "creation")
201+ eq(t, *l.Get(), liveConfig{"dark", 5 * time.Second})
202+ },
203+ },
204+ {
205+ name: "warnings are reported with the change",
206+ initial: ptr("after = 1s\n"),
207+ steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
208+ must(t, WriteFile(path, []byte("after = 2s\ntheme = blue\n"), 0o600))
209+ r.wait(t, "a change with a bad value")
210+ c, _ := r.last()
211+ eq(t, warningStrings(c.Warnings), []string{"line 2: theme: must be dark or light"})
212+ eq(t, l.Get().After, 2*time.Second)
213+ },
214+ },
215+ }
216+ for _, tt := range tests {
217+ t.Run(tt.name, func(t *testing.T) {
218+ t.Parallel()
219+ path := filepath.Join(t.TempDir(), "config")
220+ if tt.initial != nil {
221+ must(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
222+ }
223+ r := newRecorder()
224+ opts := r.opts()
225+ ready := make(chan struct{})
226+ opts.Watch.Ready = ready
227+ l, _, err := Open(path, opts)
228+ must(t, err)
229+ ctx, cancel := context.WithCancel(context.Background())
230+ done := make(chan error, 1)
231+ go func() { done <- l.Run(ctx) }()
232+ <-ready
233+ tt.steps(t, path, l, r)
234+ cancel()
235+ must(t, <-done)
236+ })
237+ }
238+}
239+
240+func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
241+ path := writeTemp(t, t.TempDir(), "config", "theme = dark\n")
242+ r := newRecorder()
243+ l, _, err := Open(path, r.opts())
244+ must(t, err)
245+ must(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
246+ ctx, cancel := context.WithCancel(context.Background())
247+ defer cancel()
248+ go func() { _ = l.Run(ctx) }()
249+ r.wait(t, "a save before Run")
250+ eq(t, l.Get().Theme, "light")
251+}
252+
253+func TestLiveConcurrentGet(t *testing.T) {
254+ path := writeTemp(t, t.TempDir(), "config", "after = 1s\n")
255+ r := newRecorder()
256+ l, _, err := Open(path, r.opts())
257+ must(t, err)
258+ ctx, cancel := context.WithCancel(context.Background())
259+ defer cancel()
260+ go func() { _ = l.Run(ctx) }()
261+ var wg sync.WaitGroup
262+ stop := make(chan struct{})
263+ for range 4 {
264+ wg.Go(func() {
265+ for {
266+ select {
267+ case <-stop:
268+ return
269+ default:
270+ if l.Get().Theme == "" {
271+ t.Error("saw a half-built config")
272+ }
273+ }
274+ }
275+ })
276+ }
277+ for i := range 5 {
278+ must(t, WriteFile(path, []byte("after = "+time.Duration(i+2).String()+"\n"), 0o600))
279+ r.wait(t, "a change")
280+ }
281+ close(stop)
282+ wg.Wait()
283+}
A marshal.go +191 -0
@@ -0,0 +1,191 @@
1+package kvconf
2+
3+import (
4+ "encoding"
5+ "errors"
6+ "fmt"
7+ "maps"
8+ "reflect"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "time"
13+ "unsafe"
14+)
15+
16+// Quote returns v as it must be written so Parse reads it back unchanged:
17+// as is when possible, else as a Go "..." string.
18+func Quote(v string) string {
19+ if needsQuote(v) {
20+ return strconv.Quote(v)
21+ }
22+ return v
23+}
24+
25+func needsQuote(v string) bool {
26+ if v == "" || v != strings.TrimSpace(v) {
27+ return true
28+ }
29+ if v[0] == '"' || v[0] == '\'' {
30+ return true // it would be unquoted on reading
31+ }
32+ for i := 0; i < len(v); i++ {
33+ switch v[i] {
34+ case '\n', '\r':
35+ return true
36+ case '#':
37+ if i > 0 && (v[i-1] == ' ' || v[i-1] == '\t') {
38+ return true
39+ }
40+ }
41+ }
42+ return false
43+}
44+
45+func appendQuoted(b []byte, v string) []byte {
46+ if needsQuote(v) {
47+ return strconv.AppendQuote(b, v)
48+ }
49+ return append(b, v...)
50+}
51+
52+// Marshal writes the tagged fields of v (a struct or a pointer to one) as
53+// `key = value` lines in declaration order; map entries are sorted by name.
54+func Marshal(v any) ([]byte, error) {
55+ rv := reflect.Indirect(reflect.ValueOf(v))
56+ if rv.Kind() != reflect.Struct {
57+ return nil, errors.New("kvconf: Marshal needs a struct")
58+ }
59+ s, err := schemaOf(rv.Type())
60+ if err != nil {
61+ return nil, err
62+ }
63+ m := marshaler{b: make([]byte, 0, 32*len(s.order))}
64+ for _, f := range s.order {
65+ fv := rv.FieldByIndex(f.index)
66+ if f.elem == nil && f.val == nil {
67+ if err := m.line(f.key, "", "", fv, f); err != nil {
68+ return nil, err
69+ }
70+ continue
71+ }
72+ // Copy the map out once, into reused values, then sort by name.
73+ n := fv.Len()
74+ names := make([]string, 0, n)
75+ vals := reflect.MakeSlice(reflect.SliceOf(f.typ.Elem()), n, n)
76+ key := reflect.New(f.typ.Key()).Elem()
77+ for it, i := fv.MapRange(), 0; it.Next(); i++ {
78+ key.SetIterKey(it)
79+ names = append(names, key.String())
80+ vals.Index(i).SetIterValue(it)
81+ }
82+ order := make([]int, n)
83+ for i := range order {
84+ order[i] = i
85+ }
86+ slices.SortFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })
87+ for _, i := range order {
88+ name, ev := names[i], vals.Index(i)
89+ if f.elem == nil {
90+ if err := m.line(f.key, name, "", ev, f.val); err != nil {
91+ return nil, err
92+ }
93+ continue
94+ }
95+ for _, sub := range f.elem.order {
96+ if err := m.line(f.key, name, sub.key, ev.FieldByIndex(sub.index), sub); err != nil {
97+ return nil, err
98+ }
99+ }
100+ }
101+ }
102+ if s.catchAll != nil {
103+ fv := rv.FieldByIndex(s.catchAll.index)
104+ if es, ok := fv.Interface().([]Entry); ok {
105+ for _, e := range es {
106+ m.raw(e.Key, e.Value)
107+ }
108+ } else {
109+ kv := fv.Interface().(map[string]string)
110+ for _, k := range slices.Sorted(maps.Keys(kv)) {
111+ m.raw(k, kv[k])
112+ }
113+ }
114+ }
115+ return m.b, nil
116+}
117+
118+type marshaler struct {
119+ b []byte
120+ tmp []byte // the value being formatted, reused
121+}
122+
123+func (m *marshaler) raw(key, value string) {
124+ m.b = append(m.b, key...)
125+ m.b = append(m.b, " = "...)
126+ m.b = appendQuoted(m.b, value)
127+ m.b = append(m.b, '\n')
128+}
129+
130+// line writes prefix+name[.sub] = value.
131+func (m *marshaler) line(prefix, name, sub string, v reflect.Value, f *field) error {
132+ var err error
133+ if m.tmp, err = appendValue(m.tmp[:0], v, f); err != nil {
134+ key := prefix + name
135+ if sub != "" {
136+ key += "." + sub
137+ }
138+ return fmt.Errorf("kvconf: %s: %w", key, err)
139+ }
140+ m.b = append(m.b, prefix...)
141+ m.b = append(m.b, name...)
142+ if sub != "" {
143+ m.b = append(m.b, '.')
144+ m.b = append(m.b, sub...)
145+ }
146+ m.b = append(m.b, " = "...)
147+ // tmp is only read here, before its next reuse.
148+ m.b = appendQuoted(m.b, unsafe.String(unsafe.SliceData(m.tmp), len(m.tmp)))
149+ m.b = append(m.b, '\n')
150+ return nil
151+}
152+
153+func appendValue(b []byte, v reflect.Value, f *field) ([]byte, error) {
154+ if f.off && v.IsZero() {
155+ return append(b, "off"...), nil
156+ }
157+ if v.Type().Implements(marshalType) {
158+ text, err := v.Interface().(encoding.TextMarshaler).MarshalText()
159+ return append(b, text...), err
160+ }
161+ switch v.Kind() {
162+ case reflect.String:
163+ return append(b, v.String()...), nil
164+ case reflect.Bool:
165+ if v.Bool() {
166+ return append(b, "on"...), nil
167+ }
168+ return append(b, "off"...), nil
169+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
170+ if f.duration {
171+ return append(b, time.Duration(v.Int()).String()...), nil
172+ }
173+ return strconv.AppendInt(b, v.Int(), 10), nil
174+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
175+ return strconv.AppendUint(b, v.Uint(), 10), nil
176+ case reflect.Float32, reflect.Float64:
177+ return strconv.AppendFloat(b, v.Float(), 'f', -1, v.Type().Bits()), nil
178+ case reflect.Slice:
179+ for i := range v.Len() {
180+ if i > 0 {
181+ b = append(b, f.sep...)
182+ }
183+ var err error
184+ if b, err = appendValue(b, v.Index(i), f.item); err != nil {
185+ return b, err
186+ }
187+ }
188+ return b, nil
189+ }
190+ return b, fmt.Errorf("unsupported type %s", v.Type())
191+}
A open_other.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build !unix
2+
3+package kvconf
4+
5+import "os"
6+
7+func openNonblocking(path string) (*os.File, error) {
8+ if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() {
9+ return nil, &os.PathError{Op: "open", Path: path, Err: errNotRegular}
10+ }
11+ return os.Open(path)
12+}
A open_unix.go +14 -0
@@ -0,0 +1,14 @@
1+//go:build unix
2+
3+package kvconf
4+
5+import (
6+ "os"
7+ "syscall"
8+)
9+
10+// openNonblocking opens path without blocking on a FIFO swapped in for the
11+// config file.
12+func openNonblocking(path string) (*os.File, error) {
13+ return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
14+}
A parse.go +317 -0
@@ -0,0 +1,317 @@
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.
14+package kvconf
15+
16+import (
17+ "errors"
18+ "io"
19+ "slices"
20+ "strconv"
21+ "strings"
22+ "unicode/utf8"
23+)
24+
25+// Entry is one `key = value` assignment.
26+type 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.
35+type 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+
41+func (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.
59+type CommentStyle uint8
60+
61+const (
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.
72+const (
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.
79+type 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+
102+func (o *Options) maxBytes() int64 {
103+ if o.MaxBytes == 0 {
104+ return DefaultMaxBytes
105+ }
106+ return o.MaxBytes
107+}
108+
109+func (o *Options) maxLine() int {
110+ if o.MaxLine == 0 {
111+ return DefaultMaxLine
112+ }
113+ return o.MaxLine
114+}
115+
116+var (
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.
124+type problems struct {
125+ strict bool
126+ list []Warning
127+}
128+
129+// add records a warning and reports whether parsing may go on.
130+func (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+
135+func (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.
145+func 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+
153+func 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.
169+func 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+ key := strings.TrimSpace(line[:eq])
210+ if key == "" || strings.ContainsAny(key, " \t") {
211+ msg := "missing key"
212+ if key != "" {
213+ msg = "key contains a space"
214+ }
215+ if !p.add(n, "", msg) {
216+ break
217+ }
218+ continue
219+ }
220+ value := stripComment(strings.TrimSpace(line[eq+1:]), opts.Comments)
221+ if !opts.RawValues {
222+ value = unquote(value)
223+ }
224+ if seen != nil {
225+ if prev, dup := seen[key]; dup && !p.add(n, key, "overrides line "+strconv.Itoa(prev)) {
226+ break
227+ }
228+ seen[key] = n
229+ }
230+ entries = append(entries, Entry{Key: key, Value: value, Line: n})
231+ }
232+ if err := p.err(); err != nil {
233+ return nil, p.list, err
234+ }
235+ return entries, p.list, nil
236+}
237+
238+// stripComment removes a trailing comment from a trimmed value, ignoring a
239+// '#' inside quotes.
240+func stripComment(v string, style CommentStyle) string {
241+ if style == CommentLine || strings.IndexByte(v, '#') < 0 {
242+ return v
243+ }
244+ var quote byte
245+ for i := 0; i < len(v); i++ {
246+ switch c := v[i]; {
247+ case quote != 0:
248+ if c == '\\' && quote == '"' {
249+ i++
250+ } else if c == quote {
251+ quote = 0
252+ }
253+ case c == '"' || c == '\'':
254+ quote = c
255+ case c == '#' && (style == CommentAny || i > 0 && (v[i-1] == ' ' || v[i-1] == '\t')):
256+ return strings.TrimSpace(v[:i])
257+ }
258+ }
259+ return v
260+}
261+
262+// unquote removes the quotes of a value that is exactly one quoted string:
263+// "..." with Go escapes, or '...' taken literally. Anything else, such as a
264+// command line with quoted arguments, is kept as written.
265+func unquote(v string) string {
266+ if len(v) < 2 || v[0] != v[len(v)-1] {
267+ return v
268+ }
269+ switch v[0] {
270+ case '"':
271+ inner := v[1 : len(v)-1]
272+ if strings.IndexByte(inner, '\\') < 0 && strings.IndexByte(inner, '"') < 0 {
273+ return inner // the common case, without allocating
274+ }
275+ if s, err := strconv.Unquote(v); err == nil {
276+ return s
277+ }
278+ case '\'':
279+ if inner := v[1 : len(v)-1]; strings.IndexByte(inner, '\'') < 0 {
280+ return inner
281+ }
282+ }
283+ return v
284+}
285+
286+// Values returns the effective value of each key: the last one wins.
287+func Values(entries []Entry) map[string]string {
288+ m := make(map[string]string, len(entries))
289+ for _, e := range entries {
290+ m[e.Key] = e.Value
291+ }
292+ return m
293+}
294+
295+// Changed lists, sorted, the keys added, removed or changed between two
296+// Values maps.
297+func Changed(old, cur map[string]string) []string {
298+ var keys []string
299+ for k, v := range cur {
300+ if o, ok := old[k]; !ok || o != v {
301+ keys = append(keys, k)
302+ }
303+ }
304+ for k := range old {
305+ if _, ok := cur[k]; !ok {
306+ keys = append(keys, k)
307+ }
308+ }
309+ slices.Sort(keys)
310+ return keys
311+}
312+
313+// sameEntries reports whether two files assign the same values in the same
314+// order, whatever their comments and spacing.
315+func sameEntries(a, b []Entry) bool {
316+ return slices.EqualFunc(a, b, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value })
317+}
A parse_test.go +222 -0
@@ -0,0 +1,222 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "reflect"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestParse(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ in string
14+ opts Options
15+ want []Entry
16+ warnings []string
17+ err string
18+ }{
19+ {name: "empty", in: "", want: []Entry{}},
20+ {
21+ name: "basic",
22+ in: "# comment\n\n a = 1\nb=two words \n",
23+ want: []Entry{{"a", "1", 3}, {"b", "two words", 4}},
24+ },
25+ {
26+ name: "CRLF and BOM",
27+ in: "\ufeffa = 1\r\nb = 2\r\n",
28+ want: []Entry{{"a", "1", 1}, {"b", "2", 2}},
29+ },
30+ {
31+ name: "empty value",
32+ in: "a =\n",
33+ want: []Entry{{"a", "", 1}},
34+ },
35+ {
36+ name: "value with equals",
37+ in: "cmd = env A=1 foo\n",
38+ want: []Entry{{"cmd", "env A=1 foo", 1}},
39+ },
40+ {
41+ name: "spaced comments keep colors",
42+ in: "color = #ff8800 # orange\nx = a#b\ny = a #b\n",
43+ want: []Entry{{"color", "#ff8800", 1}, {"x", "a#b", 2}, {"y", "a", 3}},
44+ },
45+ {
46+ name: "tab before comment",
47+ in: "a = 1\t# note\n",
48+ want: []Entry{{"a", "1", 1}},
49+ },
50+ {
51+ name: "line comments only",
52+ in: "a = 1 # kept\n# skipped = 1\n",
53+ opts: Options{Comments: CommentLine},
54+ want: []Entry{{"a", "1 # kept", 1}},
55+ },
56+ {
57+ name: "any hash is a comment",
58+ in: "a = x#y\n",
59+ opts: Options{Comments: CommentAny},
60+ want: []Entry{{"a", "x", 1}},
61+ },
62+ {
63+ name: "quotes",
64+ in: `a = "x # y" # c
65+b = 'it''s'
66+c = 'lit\n'
67+d = "tab\t"
68+e = "a" "b"
69+f = "unterminated
70+g = spawn "foo bar"
71+`,
72+ want: []Entry{
73+ {"a", "x # y", 1},
74+ {"b", "'it''s'", 2},
75+ {"c", `lit\n`, 3},
76+ {"d", "tab\t", 4},
77+ {"e", `"a" "b"`, 5},
78+ {"f", `"unterminated`, 6},
79+ {"g", `spawn "foo bar"`, 7},
80+ },
81+ },
82+ {
83+ name: "raw values",
84+ in: `a = "x"` + "\n",
85+ opts: Options{RawValues: true},
86+ want: []Entry{{"a", `"x"`, 1}},
87+ },
88+ {
89+ name: "duplicate warns, both kept",
90+ in: "a = 1\na = 2\n",
91+ want: []Entry{{"a", "1", 1}, {"a", "2", 2}},
92+ warnings: []string{"line 2: a: overrides line 1"},
93+ },
94+ {
95+ name: "duplicates allowed",
96+ in: "a = 1\na = 2\n",
97+ opts: Options{AllowDuplicates: true},
98+ want: []Entry{{"a", "1", 1}, {"a", "2", 2}},
99+ },
100+ {
101+ name: "bad lines are skipped",
102+ in: "nope\n= 1\na b = 1\nok = 1\n",
103+ want: []Entry{{"ok", "1", 4}},
104+ warnings: []string{"line 1: expected key = value", "line 2: missing key", "line 3: key contains a space"},
105+ },
106+ {
107+ name: "strict stops at first problem",
108+ in: "ok = 1\nnope\nbad\n",
109+ opts: Options{Strict: true},
110+ warnings: []string{"line 2: expected key = value"},
111+ err: "line 2: expected key = value",
112+ },
113+ {
114+ name: "strict duplicate",
115+ in: "a = 1\na = 2\n",
116+ opts: Options{Strict: true},
117+ err: "line 2: a: overrides line 1",
118+ warnings: []string{"line 2: a: overrides line 1"},
119+ },
120+ {
121+ name: "long line",
122+ in: "a = " + strings.Repeat("x", 20) + "\nb = 1\n",
123+ opts: Options{MaxLine: 10},
124+ want: []Entry{{"b", "1", 2}},
125+ warnings: []string{"line 1: line longer than 10 bytes"},
126+ },
127+ {
128+ name: "too large",
129+ in: "a = 1234567890\n",
130+ opts: Options{MaxBytes: 8},
131+ err: ErrTooLarge.Error(),
132+ },
133+ {
134+ name: "no size limit",
135+ in: "a = 1234567890\n",
136+ opts: Options{MaxBytes: -1, MaxLine: -1},
137+ want: []Entry{{"a", "1234567890", 1}},
138+ },
139+ {
140+ name: "invalid UTF-8",
141+ in: "a = \xff\n",
142+ err: ErrNotUTF8.Error(),
143+ },
144+ }
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ got, warnings, err := Parse(strings.NewReader(tt.in), tt.opts)
148+ if tt.err != "" {
149+ if err == nil || err.Error() != tt.err {
150+ t.Fatalf("err = %v, want %q", err, tt.err)
151+ }
152+ } else if err != nil {
153+ t.Fatalf("err = %v", err)
154+ } else if !reflect.DeepEqual(got, tt.want) {
155+ t.Errorf("entries = %#v\nwant %#v", got, tt.want)
156+ }
157+ if msgs := warningStrings(warnings); !reflect.DeepEqual(msgs, tt.warnings) {
158+ t.Errorf("warnings = %q, want %q", msgs, tt.warnings)
159+ }
160+ })
161+ }
162+}
163+
164+func warningStrings(ws []Warning) []string {
165+ var out []string
166+ for _, w := range ws {
167+ out = append(out, w.Error())
168+ }
169+ return out
170+}
171+
172+func TestStrictErrorIsWarning(t *testing.T) {
173+ _, _, err := Parse(strings.NewReader("bad\n"), Options{Strict: true})
174+ var w Warning
175+ if !errors.As(err, &w) || w.Line != 1 {
176+ t.Fatalf("err = %#v, want a Warning on line 1", err)
177+ }
178+}
179+
180+func TestChanged(t *testing.T) {
181+ tests := []struct {
182+ name string
183+ old, cur map[string]string
184+ want []string
185+ }{
186+ {name: "same", old: map[string]string{"a": "1"}, cur: map[string]string{"a": "1"}},
187+ {name: "added", old: map[string]string{}, cur: map[string]string{"b": "1", "a": "1"}, want: []string{"a", "b"}},
188+ {name: "removed", old: map[string]string{"a": "1"}, cur: nil, want: []string{"a"}},
189+ {name: "changed", old: map[string]string{"a": "1", "b": "1"}, cur: map[string]string{"a": "2", "b": "1"}, want: []string{"a"}},
190+ }
191+ for _, tt := range tests {
192+ t.Run(tt.name, func(t *testing.T) {
193+ if got := Changed(tt.old, tt.cur); !reflect.DeepEqual(got, tt.want) {
194+ t.Errorf("Changed = %q, want %q", got, tt.want)
195+ }
196+ })
197+ }
198+}
199+
200+func FuzzParse(f *testing.F) {
201+ for _, s := range []string{"a = 1\n", "a = \"x # y\" # c\n", "\ufeffa=b\r\n", "x\n=\n'"} {
202+ f.Add(s)
203+ }
204+ f.Fuzz(func(t *testing.T, in string) {
205+ entries, _, err := Parse(strings.NewReader(in), Options{})
206+ if err != nil {
207+ return
208+ }
209+ // Whatever was read can be written and read back the same.
210+ var data []byte
211+ for _, e := range entries {
212+ data = append(data, e.Key+" = "+Quote(e.Value)+"\n"...)
213+ }
214+ again, _, err := ParseBytes(data, Options{AllowDuplicates: true})
215+ if err != nil {
216+ t.Fatalf("re-parse: %v\n%s", err, data)
217+ }
218+ if !sameEntries(entries, again) {
219+ t.Fatalf("round trip:\n%#v\n%#v", entries, again)
220+ }
221+ })
222+}
A rename_other.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !windows
2+
3+package kvconf
4+
5+import "os"
6+
7+func rename(from, to string) error { return os.Rename(from, to) }
A rename_windows.go +28 -0
@@ -0,0 +1,28 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "os"
6+ "syscall"
7+ "time"
8+)
9+
10+// rename retries while another process (an editor, an indexer, our own
11+// watcher) holds the target open, which Windows reports as a sharing error.
12+func rename(from, to string) error {
13+ const (
14+ errorAccessDenied = syscall.Errno(5)
15+ errorSharingViolation = syscall.Errno(32)
16+ )
17+ var err error
18+ for i := 0; i < 20; i++ {
19+ if err = os.Rename(from, to); err == nil {
20+ return nil
21+ }
22+ if !errors.Is(err, errorAccessDenied) && !errors.Is(err, errorSharingViolation) {
23+ return err
24+ }
25+ time.Sleep(time.Duration(i+1) * 5 * time.Millisecond)
26+ }
27+ return err
28+}
A watch.go +415 -0
@@ -0,0 +1,415 @@
1+package kvconf
2+
3+import (
4+ "context"
5+ "io/fs"
6+ "maps"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "slices"
11+ "strings"
12+ "time"
13+)
14+
15+// DefaultDebounce is the quiet time Watch waits for after a change.
16+const DefaultDebounce = 100 * time.Millisecond
17+
18+// retryMissing is how often a missing directory is looked for again, in
19+// case its creation was not seen.
20+const retryMissing = 2 * time.Second
21+
22+// WatchOptions controls Watch.
23+type WatchOptions struct {
24+ // Debounce is the quiet time after a change before it is reported:
25+ // every write restarts it, so a file is never read half written. 0
26+ // means DefaultDebounce; a negative value reports each change at once.
27+ Debounce time.Duration
28+ // Dir watches every file directly inside the path, a directory, instead
29+ // of the path itself. Hidden files (".swp") and backups ("~") are
30+ // ignored and subdirectories are not watched.
31+ Dir bool
32+ // Poll, when positive, checks the files at this interval instead of
33+ // using the native OS notifications. Native watching also falls back to
34+ // polling every second where it is not available.
35+ Poll time.Duration
36+ // Ready, when not nil, is closed once the watches are installed: a
37+ // change after that point is reported.
38+ Ready chan<- struct{}
39+}
40+
41+func (o *WatchOptions) debounce() time.Duration {
42+ switch {
43+ case o.Debounce == 0:
44+ return DefaultDebounce
45+ case o.Debounce < 0:
46+ return 0
47+ }
48+ return o.Debounce
49+}
50+
51+// Watch reports on out each time the file at path changes, is created,
52+// replaced or removed, once it has been quiet for the debounce time. It
53+// watches the parent directory, so editors that save by renaming a new file
54+// over the old one are seen. It follows symlinks and watches each link of
55+// the chain, so a dotfile-managed config is seen at its target and a
56+// retargeted link is followed. A missing directory is waited for.
57+//
58+// The send never blocks: give out a buffer of 1, and a pending value already
59+// means "changed". Watch returns nil when ctx ends, or an error when the OS
60+// refuses to watch.
61+func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struct{}) error {
62+ path, err := filepath.Abs(path)
63+ if err != nil {
64+ return err
65+ }
66+ var b backend
67+ if opts.Poll > 0 {
68+ b = newPoller(opts.Poll)
69+ } else if b, err = newNative(); err != nil {
70+ return err
71+ } else if b == nil {
72+ b = newPoller(time.Second)
73+ }
74+ defer b.close() //nolint:errcheck // nothing to report once watching ends
75+ stop := context.AfterFunc(ctx, b.wake)
76+ defer stop()
77+
78+ w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}
79+ missing, err := w.rewatch(b)
80+ if err != nil {
81+ return err
82+ }
83+ if opts.Ready != nil {
84+ close(opts.Ready)
85+ }
86+ debounce := opts.debounce()
87+ var pending, retry time.Time
88+ if missing {
89+ retry = time.Now().Add(retryMissing)
90+ }
91+ var evs []event
92+ for ctx.Err() == nil {
93+ timeout := time.Duration(-1)
94+ for _, t := range [...]time.Time{pending, retry} {
95+ if !t.IsZero() {
96+ if d := max(time.Until(t), 0); timeout < 0 || d < timeout {
97+ timeout = d
98+ }
99+ }
100+ }
101+ if evs, err = b.wait(timeout, evs[:0]); err != nil {
102+ return err
103+ }
104+ relevant, rewatch, recheck := w.classify(evs)
105+ now := time.Now()
106+ if !retry.IsZero() && !now.Before(retry) {
107+ rewatch, retry = true, time.Time{}
108+ }
109+ if recheck && retry.IsZero() {
110+ // A removed directory may linger, unreported, until its last
111+ // handle closes (Windows): look once more later.
112+ retry = now.Add(retryMissing)
113+ }
114+ if rewatch {
115+ changed := false
116+ if missing, changed, err = w.rewatchChanged(b); err != nil {
117+ return err
118+ }
119+ relevant = relevant || changed
120+ if missing && retry.IsZero() {
121+ retry = now.Add(retryMissing)
122+ }
123+ }
124+ if relevant {
125+ pending = now.Add(debounce)
126+ }
127+ if !pending.IsZero() && !now.Before(pending) {
128+ pending = time.Time{}
129+ select {
130+ case out <- struct{}{}:
131+ default:
132+ }
133+ }
134+ }
135+ return nil
136+}
137+
138+// backend is the native notification API of one OS.
139+type backend interface {
140+ // set replaces the watched directories with targets. It reports
141+ // whether a directory vanished before it could be watched.
142+ set(targets []*target) (missing bool, err error)
143+ // wait appends events to evs. It returns after events arrive, after
144+ // timeout (forever when negative), or after wake.
145+ wait(timeout time.Duration, evs []event) ([]event, error)
146+ // wake makes the current or next wait return. Safe for concurrent use.
147+ wake()
148+ close() error
149+}
150+
151+// target is one watched directory and the names that matter in it.
152+type target struct {
153+ dir string
154+ // names are files whose changes are reported.
155+ names []string
156+ // all reports every visible file (Dir mode).
157+ all bool
158+ // next is the missing child on the way to the wanted directory: its
159+ // creation means the watches must move down.
160+ next string
161+}
162+
163+func (t *target) has(name string) bool {
164+ return slices.Contains(t.names, name) || t.all && visible(name)
165+}
166+
167+// event is a change in a watched directory. An empty name means "something
168+// changed in dir" (kqueue, polling, overflow); an empty dir means anything
169+// may have changed. lost means the watch itself vanished.
170+type event struct {
171+ dir, name string
172+ lost bool
173+ // moved: the name was created, removed or renamed, so a link may now
174+ // point elsewhere. A plain write leaves links alone.
175+ moved bool
176+}
177+
178+func visible(name string) bool {
179+ return name != "" && name[0] != '.' && name[len(name)-1] != '~'
180+}
181+
182+type watcher struct {
183+ path string
184+ dir bool
185+ targets []*target
186+ chain []string
187+ stamps map[string]stamp
188+}
189+
190+// chain lists path and every symlink target it goes through.
191+func chain(path string) []string {
192+ out := []string{filepath.Clean(path)}
193+ for len(out) <= 40 { // the kernel's symlink limit
194+ cur := out[len(out)-1]
195+ link, err := os.Readlink(cur)
196+ if err != nil {
197+ break
198+ }
199+ if !filepath.IsAbs(link) {
200+ link = filepath.Join(filepath.Dir(cur), link)
201+ }
202+ link = filepath.Clean(link)
203+ if slices.Contains(out, link) {
204+ break // a loop: the file is unreadable anyway
205+ }
206+ out = append(out, link)
207+ }
208+ return out
209+}
210+
211+func isDir(path string) bool {
212+ info, err := os.Stat(path)
213+ return err == nil && info.IsDir()
214+}
215+
216+// plan computes the targets for the current state of the file system.
217+func (w *watcher) plan() (targets []*target, files []string) {
218+ byDir := map[string]*target{}
219+ get := func(dir string) *target {
220+ t := byDir[dir]
221+ if t == nil {
222+ t = &target{dir: dir}
223+ byDir[dir] = t
224+ targets = append(targets, t)
225+ }
226+ return t
227+ }
228+ add := func(dir, name string, all bool) {
229+ if !isDir(dir) {
230+ // Watch the closest existing ancestor for the next missing child.
231+ child := dir
232+ for parent := filepath.Dir(child); ; parent = filepath.Dir(child) {
233+ if parent == child {
234+ return // nothing exists, not even the root
235+ }
236+ if isDir(parent) {
237+ get(parent).next = normName(filepath.Base(child))
238+ return
239+ }
240+ child = parent
241+ }
242+ }
243+ t := get(dir)
244+ t.all = t.all || all
245+ if name != "" && !slices.Contains(t.names, name) {
246+ t.names = append(t.names, name)
247+ }
248+ }
249+ if w.dir {
250+ files = []string{w.path}
251+ add(w.path, "", true)
252+ } else {
253+ files = chain(w.path)
254+ for _, f := range files {
255+ add(filepath.Dir(f), normName(filepath.Base(f)), false)
256+ }
257+ }
258+ return targets, files
259+}
260+
261+func (w *watcher) rewatch(b backend) (missing bool, err error) {
262+ missing, _, err = w.rewatchChanged(b)
263+ return missing, err
264+}
265+
266+// rewatchChanged installs the watches again and reports whether the watched
267+// files changed meanwhile: a link was retargeted, a missing directory
268+// appeared, or a file changed while it was not watched.
269+func (w *watcher) rewatchChanged(b backend) (missing, changed bool, err error) {
270+ targets, files := w.plan()
271+ changed = w.chain != nil && !slices.Equal(files, w.chain) || !sameTargets(targets, w.targets)
272+ w.targets, w.chain = targets, files
273+ missing, err = b.set(targets)
274+ for _, t := range targets {
275+ missing = missing || t.next != ""
276+ }
277+ old := w.stamps
278+ w.stamps = make(map[string]stamp, len(old))
279+ for _, t := range targets {
280+ w.stampsChanged(t)
281+ }
282+ changed = changed || !maps.Equal(old, w.stamps)
283+ return missing, changed, err
284+}
285+
286+func sameTargets(a, b []*target) bool {
287+ return slices.EqualFunc(a, b, func(x, y *target) bool {
288+ return x.dir == y.dir && x.all == y.all && x.next == y.next && slices.Equal(x.names, y.names)
289+ })
290+}
291+
292+// classify reports whether evs touch a watched file, and whether the
293+// watches must be installed again.
294+func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {
295+ relinked := false
296+ for _, ev := range evs {
297+ if ev.lost || ev.dir == "" {
298+ relevant, rewatch, recheck = true, true, true
299+ continue
300+ }
301+ t := w.find(ev.dir)
302+ if t == nil {
303+ continue
304+ }
305+ if ev.name == "" {
306+ if w.stampsChanged(t) {
307+ // kqueue watches files by descriptor: a replaced file needs a new one.
308+ relevant, rewatch = true, true
309+ }
310+ continue
311+ }
312+ name := normName(ev.name)
313+ if t.next != "" && name == t.next {
314+ relevant, rewatch = true, true
315+ }
316+ // Some systems (Windows, network file systems) do not report a
317+ // watched directory that was removed: check it on removals.
318+ if ev.moved && !rewatch && !isDir(t.dir) {
319+ relevant, rewatch = true, true
320+ }
321+ if t.has(name) {
322+ relevant = true
323+ relinked = relinked || ev.moved
324+ }
325+ }
326+ if relinked && !rewatch && !w.dir && !slices.Equal(chain(w.path), w.chain) {
327+ rewatch = true // a link points elsewhere now
328+ }
329+ return relevant, rewatch, recheck || relinked
330+}
331+
332+func (w *watcher) find(dir string) *target {
333+ for _, t := range w.targets {
334+ if t.dir == dir {
335+ return t
336+ }
337+ }
338+ return nil
339+}
340+
341+// stamp identifies a version of a file without reading it.
342+type stamp struct {
343+ size int64
344+ mtime int64
345+ mode fs.FileMode
346+ id uint64
347+}
348+
349+func stampOf(info fs.FileInfo) stamp {
350+ return stamp{size: info.Size(), mtime: info.ModTime().UnixNano(), mode: info.Mode(), id: fileID(info)}
351+}
352+
353+// stampsChanged refreshes the stamps of the files that matter in t and
354+// reports whether any was created, changed or removed.
355+func (w *watcher) stampsChanged(t *target) bool {
356+ changed := false
357+ check := func(name string, info fs.FileInfo) {
358+ key := t.dir + string(filepath.Separator) + name
359+ old, had := w.stamps[key]
360+ if info == nil {
361+ if had {
362+ delete(w.stamps, key)
363+ changed = true
364+ }
365+ return
366+ }
367+ if s := stampOf(info); !had || old != s {
368+ w.stamps[key] = s
369+ changed = true
370+ }
371+ }
372+ names := t.names
373+ if t.next != "" {
374+ names = append(slices.Clip(names), t.next)
375+ }
376+ for _, name := range names {
377+ info, err := os.Lstat(filepath.Join(t.dir, name))
378+ if err != nil {
379+ info = nil
380+ }
381+ check(name, info)
382+ }
383+ if t.all {
384+ entries, _ := os.ReadDir(t.dir)
385+ prefix := t.dir + string(filepath.Separator)
386+ live := map[string]bool{}
387+ for _, e := range entries {
388+ if !visible(e.Name()) || e.IsDir() {
389+ continue
390+ }
391+ info, err := e.Info()
392+ if err != nil {
393+ continue
394+ }
395+ live[e.Name()] = true
396+ check(e.Name(), info)
397+ }
398+ for key := range w.stamps {
399+ if rest, ok := strings.CutPrefix(key, prefix); ok && !strings.ContainsRune(rest, filepath.Separator) &&
400+ !live[rest] && !slices.Contains(names, rest) {
401+ delete(w.stamps, key)
402+ changed = true
403+ }
404+ }
405+ }
406+ return changed
407+}
408+
409+// normName folds the case of names on Windows, whose file names ignore it.
410+func normName(name string) string {
411+ if runtime.GOOS == "windows" {
412+ return strings.ToLower(name)
413+ }
414+ return name
415+}
A watch_kqueue.go +144 -0
@@ -0,0 +1,144 @@
1+//go:build darwin || ios || freebsd || netbsd || openbsd || dragonfly
2+
3+package kvconf
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+ "time"
11+)
12+
13+// kqueue watches with BSD/macOS kqueue. A vnode watch needs a descriptor
14+// per file, so it watches each directory (for files created, removed or
15+// renamed) and each file that matters (for writes in place). Events carry no
16+// name: the watcher compares file stamps to see what changed, and asks for a
17+// rewatch when a file was replaced, since its descriptor then points to the
18+// old one.
19+type kqueue struct {
20+ kq int
21+ wakeR int
22+ wakeW int
23+ fds map[int]string // descriptor -> directory it reports for
24+ events []syscall.Kevent_t
25+}
26+
27+const kqueueFflags = syscall.NOTE_WRITE | syscall.NOTE_EXTEND | syscall.NOTE_ATTRIB |
28+ syscall.NOTE_DELETE | syscall.NOTE_RENAME | syscall.NOTE_REVOKE
29+
30+func newNative() (backend, error) {
31+ kq, err := syscall.Kqueue()
32+ if err != nil {
33+ return nil, os.NewSyscallError("kqueue", err)
34+ }
35+ syscall.CloseOnExec(kq)
36+ var p [2]int
37+ if err := syscall.Pipe(p[:]); err != nil {
38+ syscall.Close(kq)
39+ return nil, os.NewSyscallError("pipe", err)
40+ }
41+ for _, fd := range p {
42+ syscall.CloseOnExec(fd)
43+ _ = syscall.SetNonblock(fd, true)
44+ }
45+ k := &kqueue{kq: kq, wakeR: p[0], wakeW: p[1], fds: map[int]string{}, events: make([]syscall.Kevent_t, 64)}
46+ var ev syscall.Kevent_t
47+ syscall.SetKevent(&ev, k.wakeR, syscall.EVFILT_READ, syscall.EV_ADD|syscall.EV_CLEAR)
48+ if _, err := syscall.Kevent(kq, []syscall.Kevent_t{ev}, nil, nil); err != nil {
49+ _ = k.close()
50+ return nil, os.NewSyscallError("kevent", err)
51+ }
52+ return k, nil
53+}
54+
55+func (k *kqueue) set(targets []*target) (missing bool, err error) {
56+ // Descriptors are cheap to reopen and a replaced file needs a new one.
57+ for fd := range k.fds {
58+ syscall.Close(fd) // closing removes its kevents
59+ }
60+ clear(k.fds)
61+ var changes []syscall.Kevent_t
62+ watch := func(path, dir string) bool {
63+ fd, err := syscall.Open(path, openEventOnly|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0)
64+ if err != nil {
65+ return false
66+ }
67+ k.fds[fd] = dir
68+ var ev syscall.Kevent_t
69+ syscall.SetKevent(&ev, fd, syscall.EVFILT_VNODE, syscall.EV_ADD|syscall.EV_CLEAR)
70+ ev.Fflags = kqueueFflags
71+ changes = append(changes, ev)
72+ return true
73+ }
74+ for _, t := range targets {
75+ if !watch(t.dir, t.dir) {
76+ missing = true
77+ continue
78+ }
79+ for _, name := range t.names {
80+ watch(filepath.Join(t.dir, name), t.dir)
81+ }
82+ if t.all {
83+ entries, _ := os.ReadDir(t.dir)
84+ for _, e := range entries {
85+ if visible(e.Name()) && !e.IsDir() {
86+ watch(filepath.Join(t.dir, e.Name()), t.dir)
87+ }
88+ }
89+ }
90+ }
91+ if len(changes) > 0 {
92+ if _, err := syscall.Kevent(k.kq, changes, nil, nil); err != nil {
93+ return missing, os.NewSyscallError("kevent", err)
94+ }
95+ }
96+ return missing, nil
97+}
98+
99+func (k *kqueue) wait(timeout time.Duration, evs []event) ([]event, error) {
100+ var ts *syscall.Timespec
101+ if timeout >= 0 {
102+ t := syscall.NsecToTimespec(int64(timeout))
103+ ts = &t
104+ }
105+ n, err := syscall.Kevent(k.kq, nil, k.events, ts)
106+ if errors.Is(err, syscall.EINTR) {
107+ return evs, nil
108+ }
109+ if err != nil {
110+ return evs, os.NewSyscallError("kevent", err)
111+ }
112+ for _, ev := range k.events[:n] {
113+ fd := int(ev.Ident)
114+ if fd == k.wakeR {
115+ var buf [64]byte
116+ for {
117+ if n, _ := syscall.Read(k.wakeR, buf[:]); n <= 0 {
118+ break
119+ }
120+ }
121+ continue
122+ }
123+ dir, ok := k.fds[fd]
124+ if !ok {
125+ continue
126+ }
127+ lost := ev.Fflags&(syscall.NOTE_DELETE|syscall.NOTE_RENAME|syscall.NOTE_REVOKE) != 0
128+ evs = append(evs, event{dir: dir, lost: lost})
129+ }
130+ return evs, nil
131+}
132+
133+func (k *kqueue) wake() {
134+ _, _ = syscall.Write(k.wakeW, []byte{0})
135+}
136+
137+func (k *kqueue) close() error {
138+ for fd := range k.fds {
139+ syscall.Close(fd)
140+ }
141+ syscall.Close(k.wakeR)
142+ syscall.Close(k.wakeW)
143+ return syscall.Close(k.kq)
144+}
A watch_kqueue_bsd.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build freebsd || netbsd || openbsd || dragonfly
2+
3+package kvconf
4+
5+import "syscall"
6+
7+const openEventOnly = syscall.O_RDONLY
A watch_kqueue_darwin.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build darwin || ios
2+
3+package kvconf
4+
5+import "syscall"
6+
7+// openEventOnly opens a file for notifications only: it does not keep a
8+// volume from being unmounted.
9+const openEventOnly = syscall.O_EVTONLY
A watch_linux.go +147 -0
@@ -0,0 +1,147 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "os"
6+ "sync/atomic"
7+ "syscall"
8+ "time"
9+ "unsafe"
10+)
11+
12+// inotify watches directories with Linux inotify. Its descriptor goes
13+// through the Go runtime poller, so a wait costs no thread and is woken by a
14+// read deadline.
15+type inotify struct {
16+ f *os.File
17+ fd int
18+ dirs map[int32]string // watch descriptor -> directory
19+ wds map[string]int32
20+ buf []byte
21+ woken atomic.Bool
22+ // names interns event names: the same few files change again and again.
23+ names map[string]string
24+}
25+
26+const movedMask = syscall.IN_CREATE | syscall.IN_DELETE | syscall.IN_MOVED_FROM | syscall.IN_MOVED_TO
27+
28+const inotifyMask = syscall.IN_CLOSE_WRITE | syscall.IN_MODIFY | syscall.IN_ATTRIB |
29+ syscall.IN_CREATE | syscall.IN_DELETE | syscall.IN_MOVED_FROM | syscall.IN_MOVED_TO |
30+ syscall.IN_DELETE_SELF | syscall.IN_MOVE_SELF | syscall.IN_ONLYDIR
31+
32+func newNative() (backend, error) {
33+ fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC | syscall.IN_NONBLOCK)
34+ if err != nil {
35+ return nil, os.NewSyscallError("inotify_init1", err)
36+ }
37+ return &inotify{
38+ f: os.NewFile(uintptr(fd), "inotify"),
39+ fd: fd,
40+ dirs: map[int32]string{},
41+ wds: map[string]int32{},
42+ buf: make([]byte, 16<<10),
43+ names: map[string]string{},
44+ }, nil
45+}
46+
47+func (in *inotify) set(targets []*target) (missing bool, err error) {
48+ want := make(map[string]bool, len(targets))
49+ for _, t := range targets {
50+ want[t.dir] = true
51+ }
52+ // Keep unchanged watches: removing one queues an IN_IGNORED.
53+ for dir, wd := range in.wds {
54+ if !want[dir] {
55+ _, _ = syscall.InotifyRmWatch(in.fd, uint32(wd))
56+ delete(in.wds, dir)
57+ delete(in.dirs, wd)
58+ }
59+ }
60+ for dir := range want {
61+ if _, ok := in.wds[dir]; ok {
62+ continue
63+ }
64+ wd, err := syscall.InotifyAddWatch(in.fd, dir, inotifyMask)
65+ if errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ENOTDIR) {
66+ missing = true
67+ continue
68+ }
69+ if err != nil {
70+ return missing, os.NewSyscallError("inotify_add_watch", err)
71+ }
72+ in.wds[dir] = int32(wd)
73+ in.dirs[int32(wd)] = dir
74+ }
75+ return missing, nil
76+}
77+
78+func (in *inotify) wait(timeout time.Duration, evs []event) ([]event, error) {
79+ var deadline time.Time
80+ if timeout >= 0 {
81+ deadline = time.Now().Add(timeout)
82+ }
83+ if err := in.f.SetReadDeadline(deadline); err != nil {
84+ return evs, err
85+ }
86+ if in.woken.Load() {
87+ return evs, nil
88+ }
89+ n, err := in.f.Read(in.buf)
90+ if errors.Is(err, os.ErrDeadlineExceeded) {
91+ return evs, nil
92+ }
93+ if err != nil {
94+ return evs, err
95+ }
96+ for off := 0; off+syscall.SizeofInotifyEvent <= n; {
97+ ev := (*syscall.InotifyEvent)(unsafe.Pointer(&in.buf[off]))
98+ size := syscall.SizeofInotifyEvent + int(ev.Len)
99+ if off+size > n {
100+ break
101+ }
102+ name := in.buf[off+syscall.SizeofInotifyEvent : off+size]
103+ off += size
104+ if ev.Mask&syscall.IN_Q_OVERFLOW != 0 {
105+ evs = append(evs, event{}) // events were lost: check everything
106+ continue
107+ }
108+ dir, ok := in.dirs[ev.Wd]
109+ if !ok {
110+ continue // a watch we removed
111+ }
112+ if ev.Mask&(syscall.IN_IGNORED|syscall.IN_DELETE_SELF|syscall.IN_MOVE_SELF) != 0 {
113+ if ev.Mask&syscall.IN_IGNORED != 0 {
114+ delete(in.dirs, ev.Wd)
115+ delete(in.wds, dir)
116+ }
117+ evs = append(evs, event{dir: dir, lost: true})
118+ continue
119+ }
120+ for len(name) > 0 && name[len(name)-1] == 0 {
121+ name = name[:len(name)-1]
122+ }
123+ if len(name) > 0 {
124+ evs = append(evs, event{dir: dir, name: in.intern(name), moved: ev.Mask&movedMask != 0})
125+ }
126+ }
127+ return evs, nil
128+}
129+
130+// intern returns name as a string without allocating once it was seen.
131+func (in *inotify) intern(name []byte) string {
132+ if s, ok := in.names[string(name)]; ok { // no allocation for the lookup
133+ return s
134+ }
135+ s := string(name)
136+ if len(in.names) < 1024 { // a directory full of churn cannot grow it forever
137+ in.names[s] = s
138+ }
139+ return s
140+}
141+
142+func (in *inotify) wake() {
143+ in.woken.Store(true)
144+ _ = in.f.SetReadDeadline(time.Now())
145+}
146+
147+func (in *inotify) close() error { return in.f.Close() }
A watch_other.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build !linux && !windows && !darwin && !ios && !freebsd && !netbsd && !openbsd && !dragonfly
2+
3+package kvconf
4+
5+// newNative returns nil: this OS has no native backend, Watch polls.
6+func newNative() (backend, error) { return nil, nil }
A watch_poll.go +50 -0
@@ -0,0 +1,50 @@
1+package kvconf
2+
3+import "time"
4+
5+// poller reports every watched directory at each tick; the file stamps tell
6+// what changed. It works everywhere.
7+type poller struct {
8+ every time.Duration
9+ targets []*target
10+ woken chan struct{}
11+ timer *time.Timer
12+}
13+
14+func newPoller(every time.Duration) *poller {
15+ t := time.NewTimer(time.Hour)
16+ t.Stop()
17+ return &poller{every: every, woken: make(chan struct{}, 1), timer: t}
18+}
19+
20+func (p *poller) set(targets []*target) (bool, error) {
21+ p.targets = targets
22+ return false, nil
23+}
24+
25+func (p *poller) wait(timeout time.Duration, evs []event) ([]event, error) {
26+ d := p.every
27+ if timeout >= 0 && timeout < d {
28+ d = timeout
29+ }
30+ p.timer.Reset(d)
31+ select {
32+ case <-p.timer.C:
33+ case <-p.woken:
34+ p.timer.Stop()
35+ return evs, nil
36+ }
37+ for _, t := range p.targets {
38+ evs = append(evs, event{dir: t.dir})
39+ }
40+ return evs, nil
41+}
42+
43+func (p *poller) wake() {
44+ select {
45+ case p.woken <- struct{}{}:
46+ default:
47+ }
48+}
49+
50+func (p *poller) close() error { return nil }
A watch_test.go +290 -0
@@ -0,0 +1,290 @@
1+package kvconf
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+)
11+
12+// quiet is how long a test waits to be sure no change is reported.
13+const quiet = 400 * time.Millisecond
14+
15+type watchEnv struct {
16+ t *testing.T
17+ dir string
18+ path string
19+ out chan struct{}
20+}
21+
22+func (e *watchEnv) write(rel, content string) {
23+ e.t.Helper()
24+ p := filepath.Join(e.dir, rel)
25+ must(e.t, os.MkdirAll(filepath.Dir(p), 0o700))
26+ must(e.t, os.WriteFile(p, []byte(content), 0o600))
27+}
28+
29+// replace saves like most editors: write a temporary file, rename it over.
30+func (e *watchEnv) replace(rel, content string) {
31+ e.t.Helper()
32+ p := filepath.Join(e.dir, rel)
33+ tmp := p + ".tmp~"
34+ must(e.t, os.WriteFile(tmp, []byte(content), 0o600))
35+ must(e.t, os.Rename(tmp, p))
36+}
37+
38+func (e *watchEnv) symlink(target, rel string) {
39+ e.t.Helper()
40+ p := filepath.Join(e.dir, rel)
41+ _ = os.Remove(p)
42+ if err := os.Symlink(filepath.Join(e.dir, target), p); err != nil {
43+ e.t.Skip("symlinks unavailable:", err)
44+ }
45+}
46+
47+func (e *watchEnv) expectChange(why string) {
48+ e.t.Helper()
49+ select {
50+ case <-e.out:
51+ case <-time.After(5 * time.Second):
52+ e.t.Fatalf("no change reported after %s", why)
53+ }
54+}
55+
56+func (e *watchEnv) expectQuiet(why string) {
57+ e.t.Helper()
58+ select {
59+ case <-e.out:
60+ e.t.Fatalf("unexpected change after %s", why)
61+ case <-time.After(quiet):
62+ }
63+}
64+
65+// settle lets a step's events drain so the next expectation is its own.
66+func (e *watchEnv) settle() {
67+ time.Sleep(quiet / 2)
68+ select {
69+ case <-e.out:
70+ default:
71+ }
72+}
73+
74+type backendCase struct {
75+ name string
76+ poll time.Duration
77+}
78+
79+func backends() []backendCase {
80+ return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}}
81+}
82+
83+func TestWatch(t *testing.T) {
84+ tests := []struct {
85+ name string
86+ rel string // watched path, relative to the temp dir
87+ dir bool
88+ // setup runs before the watch starts.
89+ setup func(e *watchEnv)
90+ // steps run in order once the watch is ready.
91+ steps func(e *watchEnv)
92+ }{
93+ {
94+ name: "write in place",
95+ rel: "config",
96+ setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
97+ steps: func(e *watchEnv) {
98+ e.write("config", "a = 2\n")
99+ e.expectChange("an in-place write")
100+ },
101+ },
102+ {
103+ name: "atomic replace",
104+ rel: "config",
105+ setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
106+ steps: func(e *watchEnv) {
107+ e.replace("config", "a = 2\n")
108+ e.expectChange("a rename over the file")
109+ e.replace("config", "a = 3\n")
110+ e.expectChange("a second rename")
111+ },
112+ },
113+ {
114+ name: "create then remove",
115+ rel: "config",
116+ steps: func(e *watchEnv) {
117+ e.write("config", "a = 1\n")
118+ e.expectChange("creation")
119+ must(e.t, os.Remove(e.path))
120+ e.expectChange("removal")
121+ },
122+ },
123+ {
124+ name: "other files are ignored",
125+ rel: "config",
126+ setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
127+ steps: func(e *watchEnv) {
128+ e.write("other", "x")
129+ e.write(".config.swp", "x")
130+ e.expectQuiet("writing other files")
131+ },
132+ },
133+ {
134+ name: "missing directory appears",
135+ rel: "a/b/config",
136+ steps: func(e *watchEnv) {
137+ e.write("a/b/config", "a = 1\n")
138+ e.expectChange("the directories and file appeared")
139+ e.settle()
140+ e.write("a/b/config", "a = 2\n")
141+ e.expectChange("a write once the directory exists")
142+ },
143+ },
144+ {
145+ name: "directory removed and recreated",
146+ rel: "d/config",
147+ setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") },
148+ steps: func(e *watchEnv) {
149+ must(e.t, os.RemoveAll(filepath.Join(e.dir, "d")))
150+ e.expectChange("the directory removal")
151+ e.settle()
152+ e.write("d/config", "a = 2\n")
153+ e.expectChange("the recreated file")
154+ },
155+ },
156+ {
157+ name: "symlink target changes",
158+ rel: "config",
159+ setup: func(e *watchEnv) {
160+ e.write("dotfiles/config", "a = 1\n")
161+ e.symlink("dotfiles/config", "config")
162+ },
163+ steps: func(e *watchEnv) {
164+ e.replace("dotfiles/config", "a = 2\n")
165+ e.expectChange("a save at the link target")
166+ },
167+ },
168+ {
169+ name: "symlink retargeted",
170+ rel: "config",
171+ setup: func(e *watchEnv) {
172+ e.write("one/config", "a = 1\n")
173+ e.write("two/config", "a = 2\n")
174+ e.symlink("one/config", "config")
175+ },
176+ steps: func(e *watchEnv) {
177+ e.symlink("two/config", "config")
178+ e.expectChange("retargeting the link")
179+ e.settle()
180+ e.write("two/config", "a = 3\n")
181+ e.expectChange("a write at the new target")
182+ e.settle()
183+ e.write("one/config", "a = 4\n")
184+ e.expectQuiet("a write at the old target")
185+ },
186+ },
187+ {
188+ name: "directory mode",
189+ rel: "scripts",
190+ dir: true,
191+ setup: func(e *watchEnv) { e.write("scripts/a.sh", "1") },
192+ steps: func(e *watchEnv) {
193+ e.write("scripts/b.sh", "1")
194+ e.expectChange("a new file")
195+ e.settle()
196+ e.write("scripts/a.sh", "2")
197+ e.expectChange("a modified file")
198+ e.settle()
199+ e.write("scripts/.hidden", "1")
200+ e.write("scripts/a.sh~", "1")
201+ e.expectQuiet("hidden and backup files")
202+ },
203+ },
204+ }
205+ for _, bc := range backends() {
206+ for _, tt := range tests {
207+ t.Run(bc.name+"/"+tt.name, func(t *testing.T) {
208+ t.Parallel()
209+ dir := t.TempDir()
210+ if runtime.GOOS == "darwin" {
211+ dir, _ = filepath.EvalSymlinks(dir) // /var is a link to /private/var
212+ }
213+ e := &watchEnv{t: t, dir: dir, path: filepath.Join(dir, tt.rel), out: make(chan struct{}, 1)}
214+ if tt.setup != nil {
215+ tt.setup(e)
216+ }
217+ ctx, cancel := context.WithCancel(context.Background())
218+ ready := make(chan struct{})
219+ errc := make(chan error, 1)
220+ go func() {
221+ errc <- Watch(ctx, e.path, WatchOptions{Dir: tt.dir, Poll: bc.poll, Debounce: 20 * time.Millisecond, Ready: ready}, e.out)
222+ }()
223+ select {
224+ case <-ready:
225+ case err := <-errc:
226+ t.Fatalf("Watch: %v", err)
227+ }
228+ tt.steps(e)
229+ cancel()
230+ select {
231+ case err := <-errc:
232+ must(t, err)
233+ case <-time.After(5 * time.Second):
234+ t.Fatal("Watch did not return after cancel")
235+ }
236+ })
237+ }
238+ }
239+}
240+
241+func TestWatchDebounce(t *testing.T) {
242+ for _, bc := range backends() {
243+ t.Run(bc.name, func(t *testing.T) {
244+ dir := t.TempDir()
245+ path := filepath.Join(dir, "config")
246+ must(t, os.WriteFile(path, nil, 0o600))
247+ out := make(chan struct{}, 10)
248+ ctx, cancel := context.WithCancel(context.Background())
249+ defer cancel()
250+ ready := make(chan struct{})
251+ go func() {
252+ _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out)
253+ }()
254+ <-ready
255+ for i := range 5 {
256+ must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600))
257+ time.Sleep(50 * time.Millisecond)
258+ }
259+ select {
260+ case <-out:
261+ case <-time.After(5 * time.Second):
262+ t.Fatal("no change reported")
263+ }
264+ select {
265+ case <-out:
266+ t.Fatal("a burst of writes was reported more than once")
267+ case <-time.After(quiet):
268+ }
269+ })
270+ }
271+}
272+
273+func TestWatchCancelBeforeEvents(t *testing.T) {
274+ for _, bc := range backends() {
275+ t.Run(bc.name, func(t *testing.T) {
276+ ctx, cancel := context.WithCancel(context.Background())
277+ cancel()
278+ done := make(chan error, 1)
279+ go func() {
280+ done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1))
281+ }()
282+ select {
283+ case err := <-done:
284+ must(t, err)
285+ case <-time.After(5 * time.Second):
286+ t.Fatal("Watch ignored a cancelled context")
287+ }
288+ })
289+ }
290+}
A watch_windows.go +216 -0
@@ -0,0 +1,216 @@
1+package kvconf
2+
3+import (
4+ "errors"
5+ "os"
6+ "syscall"
7+ "time"
8+ "unsafe"
9+)
10+
11+// The standard syscall package lacks these, or declares them with a wrong
12+// signature on 64-bit Windows; they are loaded from kernel32 directly.
13+var (
14+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
15+ procCreateEventW = kernel32.NewProc("CreateEventW")
16+ procSetEvent = kernel32.NewProc("SetEvent")
17+ procResetEvent = kernel32.NewProc("ResetEvent")
18+ procWaitForMultipleObjects = kernel32.NewProc("WaitForMultipleObjects")
19+ procGetOverlappedResult = kernel32.NewProc("GetOverlappedResult")
20+)
21+
22+const (
23+ waitObject0 = 0
24+ waitFailed = 0xFFFFFFFF
25+ infinite = 0xFFFFFFFF
26+ maximumWait = 64 // MAXIMUM_WAIT_OBJECTS
27+ errorIOIncomplete = syscall.Errno(996)
28+ errorIOPending = syscall.Errno(997)
29+
30+ notifyMask = syscall.FILE_NOTIFY_CHANGE_FILE_NAME | syscall.FILE_NOTIFY_CHANGE_DIR_NAME |
31+ syscall.FILE_NOTIFY_CHANGE_SIZE | syscall.FILE_NOTIFY_CHANGE_LAST_WRITE |
32+ syscall.FILE_NOTIFY_CHANGE_CREATION | syscall.FILE_NOTIFY_CHANGE_ATTRIBUTES
33+)
34+
35+// dirWatch is one directory with its pending overlapped ReadDirectoryChangesW.
36+// It lives on the heap, which Go never moves, so the kernel may write into
37+// ov and buf until the read completes or is cancelled and drained.
38+type dirWatch struct {
39+ dir string
40+ handle syscall.Handle
41+ ov syscall.Overlapped
42+ buf []uint32 // DWORD aligned, as the API requires
43+ pending bool
44+}
45+
46+type winWatch struct {
47+ wakeEv syscall.Handle
48+ dirs []*dirWatch
49+ handles []syscall.Handle
50+}
51+
52+func createEvent() (syscall.Handle, error) {
53+ h, _, err := procCreateEventW.Call(0, 1, 0, 0) // manual reset, not signaled
54+ if h == 0 {
55+ return 0, os.NewSyscallError("CreateEventW", err)
56+ }
57+ return syscall.Handle(h), nil
58+}
59+
60+func newNative() (backend, error) {
61+ if err := procWaitForMultipleObjects.Find(); err != nil {
62+ return nil, nil // poll instead
63+ }
64+ ev, err := createEvent()
65+ if err != nil {
66+ return nil, err
67+ }
68+ return &winWatch{wakeEv: ev}, nil
69+}
70+
71+func (w *winWatch) set(targets []*target) (missing bool, err error) {
72+ w.closeDirs()
73+ for _, t := range targets {
74+ if len(w.dirs) == maximumWait-1 {
75+ return missing, errors.New("kvconf: too many directories to watch")
76+ }
77+ d, err := openDir(t.dir)
78+ if err != nil {
79+ // Missing, or removed while a handle keeps it "delete pending"
80+ // (access denied): look again later.
81+ missing = true
82+ continue
83+ }
84+ if err := d.read(); err != nil {
85+ d.close()
86+ missing = true
87+ continue
88+ }
89+ w.dirs = append(w.dirs, d)
90+ }
91+ return missing, nil
92+}
93+
94+func openDir(dir string) (*dirWatch, error) {
95+ p, err := syscall.UTF16PtrFromString(dir)
96+ if err != nil {
97+ return nil, err
98+ }
99+ h, err := syscall.CreateFile(p, syscall.FILE_LIST_DIRECTORY,
100+ syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
101+ nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS|syscall.FILE_FLAG_OVERLAPPED, 0)
102+ if err != nil {
103+ return nil, &os.PathError{Op: "CreateFile", Path: dir, Err: err}
104+ }
105+ ev, err := createEvent()
106+ if err != nil {
107+ syscall.CloseHandle(h)
108+ return nil, err
109+ }
110+ d := &dirWatch{dir: dir, handle: h, buf: make([]uint32, 16<<10)}
111+ d.ov.HEvent = ev
112+ return d, nil
113+}
114+
115+func (d *dirWatch) read() error {
116+ err := syscall.ReadDirectoryChanges(d.handle, (*byte)(unsafe.Pointer(&d.buf[0])),
117+ uint32(len(d.buf)*4), false, notifyMask, nil, &d.ov, 0)
118+ if err != nil && !errors.Is(err, errorIOPending) {
119+ return err
120+ }
121+ d.pending = true
122+ return nil
123+}
124+
125+// result returns the size of the completed read.
126+func (d *dirWatch) result(wait bool) (uint32, error) {
127+ var n uint32
128+ b := uintptr(0)
129+ if wait {
130+ b = 1
131+ }
132+ r, _, err := procGetOverlappedResult.Call(uintptr(d.handle), uintptr(unsafe.Pointer(&d.ov)), uintptr(unsafe.Pointer(&n)), b)
133+ if r == 0 {
134+ return 0, err
135+ }
136+ return n, nil
137+}
138+
139+func (d *dirWatch) close() {
140+ if d.pending {
141+ _ = syscall.CancelIoEx(d.handle, &d.ov)
142+ _, _ = d.result(true) // the kernel is done with ov and buf
143+ d.pending = false
144+ }
145+ syscall.CloseHandle(d.handle)
146+ syscall.CloseHandle(d.ov.HEvent)
147+}
148+
149+func (w *winWatch) closeDirs() {
150+ for _, d := range w.dirs {
151+ d.close()
152+ }
153+ w.dirs = w.dirs[:0]
154+}
155+
156+func (w *winWatch) wait(timeout time.Duration, evs []event) ([]event, error) {
157+ w.handles = append(w.handles[:0], w.wakeEv)
158+ for _, d := range w.dirs {
159+ w.handles = append(w.handles, d.ov.HEvent)
160+ }
161+ ms := uintptr(infinite)
162+ if timeout >= 0 {
163+ ms = uintptr((timeout + time.Millisecond - 1) / time.Millisecond)
164+ }
165+ r, _, err := procWaitForMultipleObjects.Call(uintptr(len(w.handles)),
166+ uintptr(unsafe.Pointer(&w.handles[0])), 0, ms)
167+ switch {
168+ case r == waitFailed:
169+ return evs, os.NewSyscallError("WaitForMultipleObjects", err)
170+ case r == uintptr(syscall.WAIT_TIMEOUT):
171+ return evs, nil
172+ case r == waitObject0:
173+ procResetEvent.Call(uintptr(w.wakeEv))
174+ return evs, nil
175+ }
176+ i := int(r - waitObject0 - 1)
177+ if i < 0 || i >= len(w.dirs) {
178+ return evs, nil
179+ }
180+ d := w.dirs[i]
181+ n, err := d.result(false)
182+ d.pending = false
183+ if err != nil {
184+ if errors.Is(err, errorIOIncomplete) {
185+ d.pending = true
186+ return evs, nil
187+ }
188+ // The directory was removed or became unreadable.
189+ return append(evs, event{dir: d.dir, lost: true}), nil
190+ }
191+ if n == 0 {
192+ evs = append(evs, event{dir: d.dir}) // the buffer overflowed
193+ }
194+ buf := unsafe.Slice((*byte)(unsafe.Pointer(&d.buf[0])), len(d.buf)*4)
195+ for off := uint32(0); n > 0 && int(off)+int(unsafe.Sizeof(syscall.FileNotifyInformation{})) <= int(n); {
196+ info := (*syscall.FileNotifyInformation)(unsafe.Pointer(&buf[off]))
197+ name := unsafe.Slice(&info.FileName, info.FileNameLength/2)
198+ moved := info.Action != syscall.FILE_ACTION_MODIFIED
199+ evs = append(evs, event{dir: d.dir, name: syscall.UTF16ToString(name), moved: moved})
200+ if info.NextEntryOffset == 0 {
201+ break
202+ }
203+ off += info.NextEntryOffset
204+ }
205+ if err := d.read(); err != nil {
206+ evs = append(evs, event{dir: d.dir, lost: true})
207+ }
208+ return evs, nil
209+}
210+
211+func (w *winWatch) wake() { procSetEvent.Call(uintptr(w.wakeEv)) }
212+
213+func (w *winWatch) close() error {
214+ w.closeDirs()
215+ return syscall.CloseHandle(w.wakeEv)
216+}
A write.go +185 -0
@@ -0,0 +1,185 @@
1+package kvconf
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "slices"
9+ "strings"
10+)
11+
12+// Set returns data with each key of values assigned, keeping every other
13+// line, comment and the spacing of edited lines. An existing assignment (the
14+// last one) is replaced; otherwise a commented-out `# key = ...` is enabled;
15+// otherwise the key goes after the last key sharing its longest dotted
16+// parent, or at the end. A nil value removes every assignment of the key.
17+// Keys are applied in sorted order, so the output is deterministic.
18+func Set(data []byte, values map[string]*string) []byte {
19+ keys := make([]string, 0, len(values))
20+ for k := range values {
21+ keys = append(keys, k)
22+ }
23+ slices.Sort(keys)
24+ for _, k := range keys {
25+ if v := values[k]; v == nil {
26+ data = removeKey(data, k)
27+ } else {
28+ data = setKey(data, k, *v)
29+ }
30+ }
31+ return data
32+}
33+
34+// lineKey returns the key of an assignment line, or "" for anything else.
35+func lineKey(line string) string {
36+ body := strings.TrimSpace(line)
37+ if body == "" || body[0] == '#' {
38+ return ""
39+ }
40+ k, _, ok := strings.Cut(body, "=")
41+ if !ok {
42+ return ""
43+ }
44+ return strings.TrimSpace(k)
45+}
46+
47+func removeKey(data []byte, key string) []byte {
48+ lines := strings.SplitAfter(string(data), "\n")
49+ out := lines[:0]
50+ for _, l := range lines {
51+ if lineKey(l) != key {
52+ out = append(out, l)
53+ }
54+ }
55+ return []byte(strings.Join(out, ""))
56+}
57+
58+func setKey(data []byte, key, value string) []byte {
59+ value = Quote(value)
60+ lines := strings.SplitAfter(string(data), "\n")
61+ for i := len(lines) - 1; i >= 0; i-- {
62+ if lineKey(lines[i]) == key {
63+ prefix, rest, _ := strings.Cut(lines[i], "=")
64+ lines[i] = replaceValue(prefix+"=", rest, value)
65+ return []byte(strings.Join(lines, ""))
66+ }
67+ }
68+ for i := len(lines) - 1; i >= 0; i-- {
69+ indent, body, ok := strings.Cut(lines[i], "#")
70+ if !ok || strings.TrimSpace(indent) != "" || lineKey(body) != key {
71+ continue
72+ }
73+ prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74+ lines[i] = replaceValue(indent+prefix+"=", rest, value)
75+ return []byte(strings.Join(lines, ""))
76+ }
77+ // output.DP-2.scale belongs beside output.DP-2.*, not merely output.*.
78+ for parent := key; ; {
79+ dot := strings.LastIndexByte(parent, '.')
80+ if dot < 0 {
81+ break
82+ }
83+ parent = parent[:dot]
84+ index := -1
85+ for i, l := range lines {
86+ if k := lineKey(l); k == parent || strings.HasPrefix(k, parent+".") {
87+ index = i
88+ }
89+ }
90+ if index < 0 {
91+ continue
92+ }
93+ eol := "\n"
94+ if strings.HasSuffix(lines[index], "\r\n") {
95+ eol = "\r\n"
96+ }
97+ if !strings.HasSuffix(lines[index], "\n") {
98+ lines[index] += eol
99+ }
100+ lines = slices.Insert(lines, index+1, key+" = "+value+eol)
101+ return []byte(strings.Join(lines, ""))
102+ }
103+ var b bytes.Buffer
104+ b.Grow(len(data) + len(key) + len(value) + 5)
105+ b.Write(data)
106+ if len(data) > 0 && data[len(data)-1] != '\n' {
107+ b.WriteByte('\n')
108+ }
109+ b.WriteString(key)
110+ b.WriteString(" = ")
111+ b.WriteString(value)
112+ b.WriteByte('\n')
113+ return b.Bytes()
114+}
115+
116+// replaceValue keeps indentation, spacing and a trailing comment.
117+func replaceValue(prefix, rest, value string) string {
118+ plain := strings.TrimRight(rest, "\r\n")
119+ eol := rest[len(plain):]
120+ old := stripComment(strings.TrimSpace(plain), CommentSpaced)
121+ start := strings.Index(plain, old)
122+ if old == "" {
123+ start = len(plain) - len(strings.TrimLeft(plain, " \t"))
124+ if start == 0 {
125+ value = " " + value
126+ }
127+ }
128+ return prefix + plain[:start] + value + plain[start+len(old):] + eol
129+}
130+
131+// WriteFile replaces path with data atomically: readers and watchers never
132+// see a half-written file. A symlinked path is written through to its target
133+// so dotfile links survive. A new file gets perm and its directory is created
134+// (0700); an existing file keeps its permissions.
135+func WriteFile(path string, data []byte, perm os.FileMode) error {
136+ if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
137+ // Replacing a dangling link would lose it: report it instead.
138+ if path, err = filepath.EvalSymlinks(path); err != nil {
139+ return err
140+ }
141+ }
142+ if info, err := os.Stat(path); err == nil {
143+ perm = info.Mode().Perm()
144+ } else if isNotExist(err) {
145+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
146+ return err
147+ }
148+ } else {
149+ return err
150+ }
151+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
152+ if err != nil {
153+ return err
154+ }
155+ defer os.Remove(tmp.Name()) //nolint:errcheck // gone after the rename
156+ if err := writeSync(tmp, data, perm); err != nil {
157+ _ = tmp.Close()
158+ return err
159+ }
160+ if err := tmp.Close(); err != nil {
161+ return err
162+ }
163+ return rename(tmp.Name(), path)
164+}
165+
166+func writeSync(f *os.File, data []byte, perm os.FileMode) error {
167+ // Restrict before writing: the content may be secret.
168+ if err := f.Chmod(perm); err != nil && runtime.GOOS != "windows" {
169+ return err
170+ }
171+ if _, err := f.Write(data); err != nil {
172+ return err
173+ }
174+ return f.Sync()
175+}
176+
177+// SetFile applies Set to the file at path and writes it back atomically. A
178+// missing file is created with perm.
179+func SetFile(path string, values map[string]*string, perm os.FileMode) error {
180+ data, err := os.ReadFile(path)
181+ if err != nil && !isNotExist(err) {
182+ return err
183+ }
184+ return WriteFile(path, Set(data, values), perm)
185+}
A write_test.go +328 -0
@@ -0,0 +1,328 @@
1+package kvconf
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func ptr(s string) *string { return &s }
13+
14+func TestSet(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ in string
18+ values map[string]*string
19+ want string
20+ }{
21+ {name: "empty file", in: "", values: map[string]*string{"a": ptr("1")}, want: "a = 1\n"},
22+ {name: "no final newline", in: "x = 1", values: map[string]*string{"a": ptr("1")}, want: "x = 1\na = 1\n"},
23+ {
24+ name: "replace keeps spacing and comment",
25+ in: "# top\n a = 1 # one\nb = 2\n",
26+ values: map[string]*string{"a": ptr("10")},
27+ want: "# top\n a = 10 # one\nb = 2\n",
28+ },
29+ {
30+ name: "replace the last assignment",
31+ in: "a = 1\na = 2\n",
32+ values: map[string]*string{"a": ptr("3")},
33+ want: "a = 1\na = 3\n",
34+ },
35+ {
36+ name: "replace empty value",
37+ in: "a =\n",
38+ values: map[string]*string{"a": ptr("1")},
39+ want: "a = 1\n",
40+ },
41+ {
42+ name: "enable commented assignment",
43+ in: "# a = 1\nb = 2\n",
44+ values: map[string]*string{"a": ptr("5")},
45+ want: "a = 5\nb = 2\n",
46+ },
47+ {
48+ name: "insert beside the longest dotted parent",
49+ in: "output.DP-1.scale = 1\noutput.DP-2.hdr = on\nother = 1\n",
50+ values: map[string]*string{"output.DP-2.scale": ptr("2")},
51+ want: "output.DP-1.scale = 1\noutput.DP-2.hdr = on\noutput.DP-2.scale = 2\nother = 1\n",
52+ },
53+ {
54+ name: "insert keeps CRLF",
55+ in: "a.x = 1\r\nb = 2\r\n",
56+ values: map[string]*string{"a.y": ptr("3")},
57+ want: "a.x = 1\r\na.y = 3\r\nb = 2\r\n",
58+ },
59+ {
60+ name: "quote when needed",
61+ in: "",
62+ values: map[string]*string{"a": ptr("x # y"), "b": ptr(" pad"), "c": ptr("")},
63+ want: "a = \"x # y\"\nb = \" pad\"\nc = \"\"\n",
64+ },
65+ {
66+ name: "remove every assignment",
67+ in: "a = 1\nb = 2\na = 3\n",
68+ values: map[string]*string{"a": nil},
69+ want: "b = 2\n",
70+ },
71+ }
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ if got := string(Set([]byte(tt.in), tt.values)); got != tt.want {
75+ t.Errorf("got\n%q\nwant\n%q", got, tt.want)
76+ }
77+ })
78+ }
79+}
80+
81+func TestQuoteRoundTrip(t *testing.T) {
82+ for _, v := range []string{"", "plain", "#ff0000", "a # b", " lead", "trail ", `"q"`, "it's", "a\nb", `back\slash`, "x\t#y"} {
83+ t.Run(v, func(t *testing.T) {
84+ entries, _, err := ParseBytes([]byte("k = "+Quote(v)+"\n"), Options{})
85+ if err != nil || len(entries) != 1 || entries[0].Value != v {
86+ t.Fatalf("Quote(%q) = %q reads back as %#v, %v", v, Quote(v), entries, err)
87+ }
88+ })
89+ }
90+}
91+
92+type marshalConfig struct {
93+ Name string `kv:"name"`
94+ On bool `kv:"on"`
95+ After time.Duration `kv:"after,off"`
96+ List []int `kv:"list"`
97+ Dir Path `kv:"dir"`
98+ Sub struct {
99+ N uint8 `kv:"n"`
100+ } `kv:"sub"`
101+ Binds map[string]string `kv:"bind.*"`
102+ Outs map[string]output `kv:"output.*"`
103+ Rest map[string]string `kv:"*"`
104+}
105+
106+func TestMarshalRoundTrip(t *testing.T) {
107+ dir := filepath.Join(t.TempDir(), "x")
108+ in := marshalConfig{
109+ Name: "a # b", On: true, List: []int{1, 2}, Dir: Path(dir),
110+ Binds: map[string]string{"z": "2", "a": "1"},
111+ Outs: map[string]output{"DP-1": {Scale: 1.5, HDR: true, Bright: 100}},
112+ Rest: map[string]string{"extra": "x"},
113+ }
114+ in.Sub.N = 7
115+ data, err := Marshal(&in)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ want := `name = "a # b"
120+on = on
121+after = off
122+list = 1,2
123+dir = ` + Quote(dir) + `
124+sub.n = 7
125+bind.a = 1
126+bind.z = 2
127+output.DP-1.scale = 1.5
128+output.DP-1.hdr = on
129+output.DP-1.primary = off
130+output.DP-1.sdr-brightness = 100
131+extra = x
132+`
133+ if string(data) != want {
134+ t.Fatalf("Marshal =\n%s\nwant\n%s", data, want)
135+ }
136+ var out marshalConfig
137+ if w, err := Unmarshal(data, &out, Options{Strict: true}); err != nil || len(w) > 0 {
138+ t.Fatalf("Unmarshal: %v %v", w, err)
139+ }
140+ eq(t, out, in)
141+}
142+
143+func TestWriteFile(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ setup func(t *testing.T, dir string) string // returns the path to write
147+ check func(t *testing.T, dir, path string)
148+ }{
149+ {
150+ name: "new file and directory",
151+ setup: func(t *testing.T, dir string) string { return filepath.Join(dir, "sub", "config") },
152+ check: func(t *testing.T, dir, path string) {
153+ eq(t, readString(t, path), "a = 1\n")
154+ if runtime.GOOS != "windows" {
155+ info, _ := os.Stat(path)
156+ eq(t, info.Mode().Perm(), os.FileMode(0o600))
157+ }
158+ },
159+ },
160+ {
161+ name: "existing file keeps its mode",
162+ setup: func(t *testing.T, dir string) string {
163+ p := filepath.Join(dir, "config")
164+ must(t, os.WriteFile(p, []byte("old"), 0o640))
165+ return p
166+ },
167+ check: func(t *testing.T, dir, path string) {
168+ eq(t, readString(t, path), "a = 1\n")
169+ if runtime.GOOS != "windows" {
170+ info, _ := os.Stat(path)
171+ eq(t, info.Mode().Perm(), os.FileMode(0o640))
172+ }
173+ entries, _ := os.ReadDir(dir)
174+ eq(t, len(entries), 1) // no temporary file left
175+ },
176+ },
177+ {
178+ name: "symlink is written through",
179+ setup: func(t *testing.T, dir string) string {
180+ target := filepath.Join(dir, "dotfiles", "config")
181+ must(t, os.MkdirAll(filepath.Dir(target), 0o700))
182+ must(t, os.WriteFile(target, []byte("old"), 0o600))
183+ link := filepath.Join(dir, "config")
184+ if err := os.Symlink(target, link); err != nil {
185+ t.Skip("symlinks unavailable:", err)
186+ }
187+ return link
188+ },
189+ check: func(t *testing.T, dir, path string) {
190+ info, err := os.Lstat(path)
191+ must(t, err)
192+ if info.Mode()&os.ModeSymlink == 0 {
193+ t.Fatal("the link was replaced")
194+ }
195+ eq(t, readString(t, filepath.Join(dir, "dotfiles", "config")), "a = 1\n")
196+ },
197+ },
198+ }
199+ for _, tt := range tests {
200+ t.Run(tt.name, func(t *testing.T) {
201+ dir := t.TempDir()
202+ path := tt.setup(t, dir)
203+ must(t, WriteFile(path, []byte("a = 1\n"), 0o600))
204+ tt.check(t, dir, path)
205+ })
206+ }
207+}
208+
209+func TestSetFile(t *testing.T) {
210+ path := filepath.Join(t.TempDir(), "config")
211+ must(t, os.WriteFile(path, []byte("# keep\na = 1\n"), 0o600))
212+ must(t, SetFile(path, map[string]*string{"a": ptr("2"), "b": ptr("3")}, 0o600))
213+ eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
214+}
215+
216+func TestReadFile(t *testing.T) {
217+ dir := t.TempDir()
218+ tests := []struct {
219+ name string
220+ setup func(t *testing.T) string
221+ opts Options
222+ want string
223+ err string
224+ }{
225+ {name: "regular", setup: func(t *testing.T) string { return writeTemp(t, dir, "ok", "a = 1\n") }, want: "a = 1\n"},
226+ {name: "missing", setup: func(t *testing.T) string { return filepath.Join(dir, "none") }, err: "none"},
227+ {name: "directory", setup: func(t *testing.T) string { return dir }, err: "not a regular file"},
228+ {
229+ name: "too large",
230+ setup: func(t *testing.T) string { return writeTemp(t, dir, "big", "0123456789") },
231+ opts: Options{MaxBytes: 4},
232+ err: "file too large",
233+ },
234+ {
235+ name: "at the limit",
236+ setup: func(t *testing.T) string { return writeTemp(t, dir, "limit", "0123") },
237+ opts: Options{MaxBytes: 4},
238+ want: "0123",
239+ },
240+ }
241+ for _, tt := range tests {
242+ t.Run(tt.name, func(t *testing.T) {
243+ data, err := ReadFile(tt.setup(t), tt.opts)
244+ if tt.err != "" {
245+ if err == nil || !strings.Contains(err.Error(), tt.err) {
246+ t.Fatalf("err = %v, want %q", err, tt.err)
247+ }
248+ return
249+ }
250+ must(t, err)
251+ eq(t, string(data), tt.want)
252+ })
253+ }
254+}
255+
256+func TestExpandPath(t *testing.T) {
257+ home, err := os.UserHomeDir()
258+ if err != nil {
259+ t.Skip(err)
260+ }
261+ abs := filepath.Join(home, "x")
262+ tests := []struct {
263+ in, want string
264+ err bool
265+ }{
266+ {in: "~/x", want: abs},
267+ {in: "~", want: home},
268+ {in: abs + string(filepath.Separator) + ".", want: abs},
269+ {in: "", err: true},
270+ {in: "rel", err: true},
271+ {in: "~user/x", err: true},
272+ {in: "/a\x00b", err: true},
273+ }
274+ for _, tt := range tests {
275+ t.Run(tt.in, func(t *testing.T) {
276+ got, err := ExpandPath(tt.in)
277+ if (err != nil) != tt.err || got != tt.want {
278+ t.Fatalf("ExpandPath(%q) = %q, %v", tt.in, got, err)
279+ }
280+ })
281+ }
282+}
283+
284+func TestDefaultPath(t *testing.T) {
285+ tests := []struct{ name, xdg string }{
286+ {name: "absolute XDG", xdg: filepath.Join(t.TempDir(), "cfg")},
287+ {name: "relative XDG is ignored", xdg: "relative"},
288+ {name: "no XDG"},
289+ }
290+ for _, tt := range tests {
291+ t.Run(tt.name, func(t *testing.T) {
292+ t.Setenv("XDG_CONFIG_HOME", tt.xdg)
293+ got, err := DefaultPath("app", "config")
294+ must(t, err)
295+ var base string
296+ if filepath.IsAbs(tt.xdg) {
297+ base = tt.xdg
298+ } else if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
299+ home, _ := os.UserHomeDir()
300+ base = filepath.Join(home, ".config")
301+ } else {
302+ base, _ = os.UserConfigDir()
303+ }
304+ eq(t, got, filepath.Join(base, "app", "config"))
305+ })
306+ }
307+}
308+
309+func readString(t *testing.T, path string) string {
310+ t.Helper()
311+ data, err := os.ReadFile(path)
312+ must(t, err)
313+ return string(data)
314+}
315+
316+func writeTemp(t *testing.T, dir, name, content string) string {
317+ t.Helper()
318+ p := filepath.Join(dir, name)
319+ must(t, os.WriteFile(p, []byte(content), 0o600))
320+ return p
321+}
322+
323+func must(t *testing.T, err error) {
324+ t.Helper()
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+}

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