package config import ( "bytes" "context" "errors" "fmt" "io" "os" "regexp" "strconv" "strings" "time" "github.com/bnema/kvconf" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/domain/terminalcap" ) var ( processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) ) // Parse reads vev's flat action = value config format. Duplicate action keys // are accepted with a warning; the last value wins while first-seen action order // is preserved for binding conflict resolution. func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { cfg := domain.Defaults() var warnings []domain.Warning seenBindingKeys := make(map[string]bool) seenFloatingKeys := make(map[string]bool) seenCopyKeys := make(map[string]bool) seenPaletteKeys := make(map[string]bool) seenNavKeys := make(map[string]bool) seenTabsKeys := make(map[string]bool) seenEphemeralKeys := make(map[string]bool) seenKeyboardKeys := make(map[string]bool) seenEchoKeys := make(map[string]bool) seenTerminalKeys := make(map[string]bool) seenScrollbackKeys := make(map[string]bool) seenWebKeys := make(map[string]bool) entries, syntax, err := kvconf.Parse(r, parseOptions) if err != nil { return domain.Defaults(), nil, err } pending := syntaxWarnings(syntax) for _, entry := range entries { // Interleave line-syntax warnings with domain warnings in file order. for len(pending) > 0 && pending[0].Line < entry.Line { warnings = append(warnings, pending[0]) pending = pending[1:] } lineNo, key, value := entry.Line, entry.Key, entry.Value // "key = # note" has an empty value: kvconf only strips a '#' that // follows other text. if strings.HasPrefix(value, "#") { value = "" } switch { case key == "web.listen" || key == "web.origin": warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo) // Keep raw values for fail-closed validation at gateway startup. if key == "web.listen" { cfg.WebListen = value } else { cfg.WebOrigin = value } case key == "theme": mode, ok := parseTheme(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)}) continue } cfg.Theme = mode case key == "theme.palette": on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)}) continue } cfg.ThemePalette = on case key == "theme.accent": accent, warning := parseThemeAccent(value) cfg.ThemeAccent = accent if warning != "" { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning}) } case key == "bar.top-right": command, ok := parseBarCommand(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)}) continue } cfg.Bar.TopRight = command case key == "bar.bottom-right": command, ok := parseBarCommand(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)}) continue } cfg.Bar.BottomRight = command case key == "bar.interval": interval, err := time.ParseDuration(value) if err != nil { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) continue } if interval < domain.MinBarInterval { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)}) interval = domain.MinBarInterval } cfg.Bar.Interval = interval case key == "bar.background": on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.background %q", value)}) continue } cfg.Bar.Transparent = !on case key == "palette.anchor": warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo) if strings.EqualFold(strings.TrimSpace(value), "auto") { cfg.Palette = domain.PaletteConfig{} continue } anchor, ok := domain.ParseAnchor(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)}) continue } cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true} case key == "nav.overflow-tabs": warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)}) continue } cfg.Nav.OverflowTabs = on case key == "nav.overflow-sessions": warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)}) continue } cfg.Nav.OverflowSessions = on case key == "tabs.terminal-title": warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)}) continue } cfg.Tabs.TerminalTitle = on case key == "ephemeral.close-on-exit": warnings = warnDuplicateKey(warnings, seenEphemeralKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid ephemeral.close-on-exit %q", value)}) continue } cfg.Ephemeral.CloseOnExit = on case key == "keyboard.kitty-protocol": warnings = warnDuplicateKey(warnings, seenKeyboardKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid keyboard.kitty-protocol %q", value)}) continue } cfg.Keyboard.KittyProtocol = on case key == "echo.predict": warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo) mode, ok := parseEchoPredict(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)}) continue } cfg.Echo.Predict = mode case key == "terminal.colors": warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) mode, auto, err := terminalcap.ParseColorMode(value) if err != nil { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)}) continue } cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto} case key == "copy.word-separators": warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) separators, ok := parseConfigString(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)}) continue } cfg.Copy.WordSeparators = separators case key == "copy.reduce-motion": warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) on, ok := parseOnOff(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)}) continue } cfg.Copy.ReduceMotion = on case key == "floating.command": warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) command, ok := parseBarCommand(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)}) continue } cfg.Floating.Command = command case key == "floating.width": warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) width, ok := parsePercentage(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)}) continue } cfg.Floating.Width = width case key == "floating.height": warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) height, ok := parsePercentage(value) if !ok { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)}) continue } cfg.Floating.Height = height case strings.HasPrefix(key, "code."): codeKey := strings.TrimPrefix(key, "code.") if codeKey == "" { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"}) continue } if _, exists := cfg.Codes[codeKey]; exists { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) } cfg.Codes[codeKey] = value case key == "scrollback.megabytes": warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) n, err := strconv.ParseUint(value, 10, 64) if err != nil || n > domain.MaxScrollbackMegabytes { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)}) continue } cfg.Scrollback.Megabytes = n case key == "scrollback.lines": warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo) n, err := strconv.ParseUint(value, 10, 64) if err != nil || n > domain.MaxScrollbackLines { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)}) continue } cfg.Scrollback.Lines = int(n) case key == "snapshot.restore_processes": var processWarnings []domain.Warning cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) warnings = append(warnings, processWarnings...) cfg.Snapshot.RestoreProcessesSet = true default: if seenBindingKeys[key] { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) updateBindingEntry(cfg.BindingEntries, key, value) } else { seenBindingKeys[key] = true cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) } } } warnings = append(warnings, pending...) return cfg, warnings, nil } // parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes // per key and reports its own duplicate warnings. var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true} // syntaxWarnings maps kvconf line-syntax warnings to domain warnings. func syntaxWarnings(in []kvconf.Warning) []domain.Warning { out := make([]domain.Warning, 0, len(in)) for _, w := range in { msg := w.Msg if msg == "expected key = value" { msg = "missing '='" } out = append(out, domain.Warning{Line: w.Line, Msg: msg}) } return out } // Load reads path and parses it. Missing config files are treated as defaults. func Load(path string) (domain.Config, []domain.Warning, error) { data, err := kvconf.ReadFile(path, parseOptions) if errors.Is(err, os.ErrNotExist) { return domain.Defaults(), nil, nil } if err != nil { return domain.Defaults(), nil, err } return Parse(bytes.NewReader(data)) } // Watch follows path with native file notifications (kvconf.Watch) and calls // onChange with the config reloaded after each change. Missing files are // allowed and reload to defaults. A load failure reports defaults plus a // warning; the same failure is not reported twice in a row. Watch returns // ctx.Err() when ctx ends. func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error { return watch(ctx, path, kvconf.WatchOptions{}, onChange) } // fallbackPoll is the polling interval used when native watching fails. const fallbackPoll = 2 * time.Second // kvWatch is kvconf.Watch; tests replace it to make native watching fail. var kvWatch = kvconf.Watch func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error { lastErrMsg := "" reportErr := func(err error) { if msg := err.Error(); msg != lastErrMsg { lastErrMsg = msg onChange(domain.Defaults(), []domain.Warning{{Msg: msg}}) } } if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) { reportErr(err) } changed := make(chan struct{}, 1) watchErr := make(chan error, 1) go func() { watchErr <- kvWatch(ctx, path, opts, changed) }() for { select { case <-ctx.Done(): <-watchErr return ctx.Err() case err := <-watchErr: if ctx.Err() != nil { return ctx.Err() } if err == nil { err = errors.New("config watcher stopped") } if opts.Poll > 0 { return err } // The OS ran out of watches (inotify limits): poll like before // rather than lose live reload until the daemon restarts. The // warning rides on the current config, which must not reset. opts.Poll = fallbackPoll go func() { watchErr <- kvWatch(ctx, path, opts, changed) }() if cfg, warnings, err2 := Load(path); err2 == nil { lastErrMsg = "" onChange(cfg, append(warnings, domain.Warning{Msg: fmt.Sprintf("config watcher: %v; polling every %s", err, fallbackPoll)})) } case <-changed: cfg, warnings, err := Load(path) if err != nil { reportErr(err) continue } lastErrMsg = "" onChange(cfg, warnings) } } } func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { switch strings.ToLower(value) { case "adaptive": return domain.EchoPredictAdaptive, true case "always": return domain.EchoPredictAlways, true case "never": return domain.EchoPredictNever, true } return 0, false } func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { if seen[key] { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) } seen[key] = true return warnings } func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { for i := range entries { if entries[i].Key == key { entries[i].Value = value return } } } func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { if strings.TrimSpace(value) == "" { return nil, nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) warnings := make([]domain.Warning, 0) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } if !processNamePattern.MatchString(item) { warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)}) continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} out = append(out, item) } return out, warnings } func parsePercentage(value string) (int, bool) { if !percentagePattern.MatchString(value) { return 0, false } percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%")) return percentage, err == nil && percentage >= 1 && percentage <= 100 } func parseOnOff(value string) (bool, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "on": return true, true case "off": return false, true default: return false, false } } func parseBarCommand(value string) (string, bool) { value = strings.TrimSpace(value) if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { unquoted, err := strconv.Unquote(value) if err != nil { return value, false } return unquoted, true } return value, true } func parseConfigString(value string) (string, bool) { value = strings.TrimSpace(value) if value == "" { return "", true } startsQuoted := strings.HasPrefix(value, `"`) endsQuoted := strings.HasSuffix(value, `"`) if startsQuoted != endsQuoted { return "", false } if !startsQuoted { return value, true } unquoted, err := strconv.Unquote(value) if err != nil { return "", false } return unquoted, true } func parseTheme(value string) (domain.ThemeMode, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "auto", "": return domain.ThemeAuto, true case "dark": return domain.ThemeDark, true case "light": return domain.ThemeLight, true default: return domain.ThemeAuto, false } }