Help us shape the future of Go! Take the 2026 Go Developer Survey

kvconf

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 23 Imported by: 0

README

kvconf

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

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

Quick start

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

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

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

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

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

File format

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

Tags

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

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

Errors and warnings

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

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

Lower-level API

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

Performance

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

Benchmark Time/op Throughput Bytes/op Allocs/op
Parse (file to entries) 3.9 µs 744 MB/s 10.4 KiB 5
Unmarshal (file to struct, maps included) 12.2 µs 238 MB/s 21.7 KiB 47
Decode (5 scalar entries) 171 ns 112 B 1
Marshal (struct to file) 11.6 µs 15.5 KiB 19
Set (edit one key in place) 2.5 µs 10.5 KiB 5
Watch (one write to one report, inotify) 5.3 µs 256 B 2

Reproduce with:

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

License

MIT

Documentation

Overview

Package kvconf reads, writes and live-reloads flat `key = value` config files with the standard library only.

# a comment
lock.after   = 5m
border.color = #ff8800          # a value may start with #
title        = "quoted # kept"  # quotes are removed
bind.cmd+return = spawn foot

Parse turns a file into entries, Decode maps entries onto a struct with `kv` tags, Watch reports file changes with the native OS API (inotify, kqueue, ReadDirectoryChangesW, or polling), and Live ties them together into a config that reloads itself.

Index

Constants

View Source
const (
	DefaultMaxBytes = 1 << 20
	DefaultMaxLine  = 64 << 10
)

Default limits, used when the matching Options field is 0.

View Source
const DefaultDebounce = 100 * time.Millisecond

DefaultDebounce is the quiet time Watch waits for after a change.

Variables

View Source
var (
	// ErrTooLarge is returned when the input exceeds Options.MaxBytes.
	ErrTooLarge = errors.New("kvconf: file too large")
	// ErrNotUTF8 is returned when the input is not valid UTF-8.
	ErrNotUTF8 = errors.New("kvconf: file is not valid UTF-8")
)
View Source
var ErrNotRegular = errors.New("not a regular file")

ErrNotRegular is returned by ReadFile for a directory, FIFO or device.

Functions

func Changed

func Changed(old, cur map[string]string) []string

Changed lists, sorted, the keys added, removed or changed between two Values maps.

func DefaultPath

func DefaultPath(app, name string) (string, error)

DefaultPath returns <config dir>/<app>/<name>. The config dir is $XDG_CONFIG_HOME when it is an absolute path; otherwise ~/.config on Unix, ~/Library/Application Support on macOS and %AppData% on Windows.

func ExpandPath

func ExpandPath(s string) (string, error)

ExpandPath expands a leading ~/ and requires an absolute, NUL-free path.

func Marshal

func Marshal(v any) ([]byte, error)

Marshal writes the tagged fields of v (a struct or a pointer to one) as `key = value` lines in declaration order; map entries are sorted by name.

func Open

func Open[T any](path string, opts LiveOptions[T]) (*Live[T], []Warning, error)

Open loads the config file at path and returns it with the warnings of the first load. Call Run to follow its changes.

func Parse

func Parse(r io.Reader, opts Options) ([]Entry, []Warning, error)

Parse reads r and returns its entries in file order: a repeated key appears once per assignment. A line that is not `key = value` is skipped with a warning, or fails the parse in strict mode.

func ParseBool

func ParseBool(s string) (bool, error)

ParseBool accepts on/off, true/false, yes/no and 1/0, in any case.

func ParseBytes

func ParseBytes(data []byte, opts Options) ([]Entry, []Warning, error)

ParseBytes is Parse for data already in memory. The entries do not keep a reference to data.

func Quote

func Quote(v string) string

Quote returns v as it must be written so Parse reads it back unchanged: as is when possible, else as a Go "..." string.

func ReadFile

func ReadFile(path string, opts Options) ([]byte, error)

ReadFile reads a config file safely: it must be a regular file (a FIFO or device is rejected without blocking) no larger than opts.MaxBytes. A missing file returns an error wrapping fs.ErrNotExist.

func Set

func Set(data []byte, values map[string]*string) ([]byte, error)

Set returns data with each key of values assigned, keeping every other line, comment and the spacing of edited lines. An existing assignment (the last one) is replaced; otherwise a commented-out `# key = ...` is enabled; otherwise the key goes after the last key sharing its longest dotted parent, or at the end. A nil value removes every assignment of the key. Keys are applied in sorted order, so the output is deterministic. A key that cannot be read back as written (empty, with '=' or a line break, starting with '#') is an error and nothing is changed.

func SetFile

func SetFile(path string, values map[string]*string, perm os.FileMode) error

SetFile applies Set to the file at path and writes it back atomically. A missing file is created with perm.

func Values

func Values(entries []Entry) map[string]string

Values returns the effective value of each key: the last one wins.

func Watch

func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struct{}) error

Watch reports on out each time the file at path changes, is created, replaced or removed, once it has been quiet for the debounce time. It watches the parent directory, so editors that save by renaming a new file over the old one are seen. It follows symlinks and watches each link of the chain, so a dotfile-managed config is seen at its target and a retargeted link is followed. A missing directory is waited for.

The send never blocks: give out a buffer of 1, and a pending value already means "changed". Watch returns nil when ctx ends, or an error when the OS refuses to watch.

func WriteFile

func WriteFile(path string, data []byte, perm os.FileMode) error

WriteFile replaces path with data atomically: readers and watchers never see a half-written file. A symlinked path is written through to its target so dotfile links survive. A new file gets perm and its directory is created (0700); an existing file keeps its permissions.

Types

type Change

type Change[T any] struct {
	Old, New *T
	// Keys lists, sorted, the keys added, removed or changed.
	Keys     []string
	Warnings []Warning
}

Change describes a reload.

type CommentStyle

type CommentStyle uint8

CommentStyle selects which '#' start a comment. A line whose first non-blank character is '#' is always a comment, and a '#' inside a quoted value never starts one.

const (
	// CommentSpaced: a '#' preceded by a space or a tab ends the value, so
	// a value may start with '#' (colors) or contain one (a#b).
	CommentSpaced CommentStyle = iota
	// CommentLine: only whole-line comments; a value may contain any '#'.
	CommentLine
	// CommentAny: any '#' ends the value.
	CommentAny
)

type Defaulter

type Defaulter interface{ SetDefaults() }

Defaulter is implemented by a config, or by the element of a map of structs, that fills in its defaults. Load calls it before decoding; a new map element calls it before its first key is set.

type Entry

type Entry struct {
	Key   string
	Value string // trimmed, comment removed, unquoted unless Options.RawValues
	Line  int    // 1-based
}

Entry is one `key = value` assignment.

type Live

type Live[T any] struct {
	// contains filtered or unexported fields
}

Live is a config that reloads itself when its file changes. Get is safe for concurrent use; a config it returned is never modified, a reload replaces it.

func (*Live[T]) Get

func (l *Live[T]) Get() *T

Get returns the current config. Do not modify it.

func (*Live[T]) Path

func (l *Live[T]) Path() string

Path returns the path of the config file.

func (*Live[T]) Run

func (l *Live[T]) Run(ctx context.Context) error

Run watches the file and reloads it until ctx ends. A reload that fails, or a removed file, keeps the current config. A save that changes no value (a comment, spacing, the same file written again) reloads nothing. Run returns nil when ctx ends, or the error of the watcher. Call it once.

type LiveOptions

type LiveOptions[T any] struct {
	Options
	Watch WatchOptions
	// OnChange is called from Run after each reload that changed a value.
	OnChange func(Change[T])
	// OnError is called from Run when a reload fails; the current config
	// stays in place. A removed file reports an error wrapping
	// fs.ErrNotExist.
	OnError func(error)
}

LiveOptions controls a Live config.

type Options

type Options struct {
	// Strict rejects the whole file on the first problem.
	Strict bool
	// Comments selects which '#' start a comment.
	Comments CommentStyle
	// AllowDuplicates lets a repeated key override the earlier one
	// silently. Otherwise the last one still wins, with a warning.
	AllowDuplicates bool
	// AllowUnknown ignores keys the target struct does not declare.
	AllowUnknown bool
	// RawValues keeps the quotes of "..." and '...' values.
	RawValues bool
	// RequireFile makes a missing file an error for Load and Live. By
	// default a missing file gives the defaults.
	RequireFile bool
	// MaxBytes bounds the file size: 0 means DefaultMaxBytes, a negative
	// value means no limit. A larger file is an error, even when lenient.
	MaxBytes int64
	// MaxLine bounds the length of a line: 0 means DefaultMaxLine, a
	// negative value means no limit.
	MaxLine int
}

Options controls parsing and decoding. The zero value is lenient: a problem becomes a warning and the rest of the file still applies.

type Path

type Path string

Path is a file system path value: it accepts an absolute path or one starting with ~/ (the home directory), and stores it cleaned. Use it as a field type to validate paths while decoding.

func (Path) MarshalText

func (p Path) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler.

func (*Path) UnmarshalText

func (p *Path) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler.

type Validator

type Validator interface{ Validate() error }

Validator is implemented by a config that checks itself after decoding, for rules that span several keys. Its error always fails the load.

type ValueError

type ValueError string

ValueError is an error message that is safe to show in a Warning: it does not contain the value. Return one from UnmarshalText to explain a rejected value; any other error is reported as "invalid <type>".

func (ValueError) Error

func (e ValueError) Error() string

type Warning

type Warning struct {
	Line int    // 0 when the problem is not tied to a line
	Key  string // empty when there is no key
	Msg  string
}

Warning is a problem found in a file. It never contains a value, so it is safe to log even when the file holds secrets. In strict mode the first warning is returned as the error.

func Decode

func Decode(entries []Entry, v any, opts Options) ([]Warning, error)

Decode sets the fields of v, a pointer to a struct, from entries in order, so a repeated key ends with its last valid value. An invalid value is reported and leaves its field unchanged, or fails the decode in strict mode. An unknown key is reported unless Options.AllowUnknown is set or a kv:"*" field collects it. Validate runs last when v implements Validator.

func Load

func Load[T any](path string, opts Options) (*T, []Warning, error)

Load reads the config file at path into a new T: it starts from the defaults (SetDefaults when *T implements Defaulter), then applies the file. A missing file gives the defaults, unless opts.RequireFile is set.

func Unmarshal

func Unmarshal(data []byte, v any, opts Options) ([]Warning, error)

Unmarshal parses data and decodes it into v, a pointer to a struct. Fill v with its defaults first: keys absent from data keep their value.

func (Warning) Error

func (w Warning) Error() string

type WatchOptions

type WatchOptions struct {
	// Debounce is the quiet time after a change before it is reported:
	// every write restarts it, so a file is never read half written. 0
	// means DefaultDebounce; a negative value reports each change at once.
	Debounce time.Duration
	// Dir watches every file directly inside the path, a directory, instead
	// of the path itself. Hidden files (".swp") and backups ("~") are
	// ignored and subdirectories are not watched.
	Dir bool
	// Poll, when positive, checks the files at this interval instead of
	// using the native OS notifications. Native watching also falls back to
	// polling every second where it is not available.
	Poll time.Duration
	// Ready, when not nil, is closed once the watches are installed: a
	// change after that point is reported.
	Ready chan<- struct{}
}

WatchOptions controls Watch.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL