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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||