// SPDX-License-Identifier: GPL-3.0-or-later package config import ( "errors" "fmt" "io/fs" "maps" "os" "path/filepath" "slices" "git.labunix.xyz/krino/internal/sexp" "git.labunix.xyz/krino/internal/xdg" ) // Config is the whole configuration: the main file and the directories it // includes. type Config struct { Main *Main Dirs []*Dir } // unusedOverrides reports text for a file no part of this configuration // names: it would otherwise pass as checked while the file on disk was read // instead. Text for an included directory this call did not load - an // editor holding buffers for several while checking one - is not reported. func unusedOverrides(over map[string][]byte, used map[string]bool, mainFile string, include []string) []*Diag { known := map[string]bool{filepath.Clean(mainFile): true} for _, name := range include { known[filepath.Clean(DirFile(mainFile, name))] = true } var out []*Diag for _, file := range slices.Sorted(maps.Keys(over)) { if !used[file] && !known[file] { out = append(out, &Diag{File: mainFile, Msg: fmt.Sprintf("unsaved text for %s, which this configuration does not read", file)}) } } return out } // readSource is the text of file: the caller's override when it has one, // else the file's own bytes. It records which overrides were used, so // LoadWith can report one it never reached. func readSource(overrides map[string][]byte, used map[string]bool, file string) ([]byte, error) { key := filepath.Clean(file) if src, ok := overrides[key]; ok { used[key] = true return src, nil } return os.ReadFile(file) } // cleanOverrides keys the caller's overrides by cleaned path, so a file // named with a "." or ".." segment is still recognised as itself. Two keys // that name one file are refused rather than resolved by map order. func cleanOverrides(overrides map[string][]byte) (map[string][]byte, []*Diag) { if len(overrides) == 0 { return nil, nil } out := make(map[string][]byte, len(overrides)) var errs []*Diag for _, file := range slices.Sorted(maps.Keys(overrides)) { key := filepath.Clean(file) if _, seen := out[key]; seen { errs = append(errs, &Diag{File: key, Msg: "unsaved text given twice for this file"}) continue } out[key] = overrides[file] } return out, errs } // DefaultFile is krino.conf in the XDG config directory. func DefaultFile() string { return filepath.Join(xdg.ConfigHome(), "krino", "krino.conf") } // DirFile is where directory name's config lives, beside the main file. func DirFile(mainFile, name string) string { return filepath.Join(filepath.Dir(mainFile), "dirs", name+".conf") } // Load reads the main file and the files of the directories it includes. // With names, only those directories are read, and each must be included. // The Config is nil only when the main file itself cannot be read. func Load(mainFile string, names ...string) (*Config, []*Diag) { return LoadWith(mainFile, nil, names...) } // LoadWith is Load with some files' text supplied by the caller: overrides // maps a file path - mainFile, or DirFile(mainFile, name) - to the text to // read instead of that file's own, so an editor can have unsaved text // checked exactly as a run would read it (GUI design §1.3). A nil map is // Load. func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*Config, []*Diag) { over, collisions := cleanOverrides(overrides) used := map[string]bool{} src, err := readSource(over, used, mainFile) if errors.Is(err, fs.ErrNotExist) { return nil, []*Diag{{File: mainFile, Msg: "not found; create it with: krino init"}} } if err != nil { return nil, []*Diag{{File: mainFile, Msg: err.Error()}} } m, errs := ParseMain(mainFile, src) cfg := &Config{Main: m} if _, perr := sexp.Parse(mainFile, src); perr != nil { // krino.conf itself is unreadable: report just that, and load none // of the directories, so a syntax error never also produces // "NAME is not in include" for names the caller asked for. return cfg, errs } want := m.Include if len(names) > 0 { want = nil for _, n := range names { if _, ok := m.IncludePos[n]; !ok { errs = append(errs, &Diag{File: mainFile, Msg: fmt.Sprintf("%q is not in include", n)}) continue } want = append(want, n) } } for _, name := range want { file := DirFile(mainFile, name) src, err := readSource(over, used, file) if err != nil { msg := err.Error() if errors.Is(err, fs.ErrNotExist) { msg = fmt.Sprintf("included %q, but %s does not exist; create it with: krino new %s PATH", name, file, name) } errs = append(errs, &Diag{File: mainFile, Pos: m.IncludePos[name], Msg: msg}) continue } dir, derrs := ParseDir(name, file, src) errs = append(errs, derrs...) cfg.Dirs = append(cfg.Dirs, dir) } errs = append(errs, collisions...) return cfg, append(errs, unusedOverrides(over, used, mainFile, m.Include)...) } // Resolved is the settings that apply in dir: built-in, then the main // file's defaults, then the directory's own. func (c *Config) Resolved(dir *Dir) Resolved { return dir.Settings.Over(c.Main.Defaults.Over(Builtin())) } // LogFile is the main file's (log ...), or the default under XDG_STATE_HOME. func (c *Config) LogFile() string { if c.Main.Log != "" { return c.Main.Log } return filepath.Join(xdg.StateHome(), "krino", "krino.log") } // LockFile is where a directory's lock lives while a run is active: // $XDG_STATE_HOME/krino/.lock, beside the log (spec §3). func (c *Config) LockFile(name string) string { return filepath.Join(xdg.StateHome(), "krino", name+".lock") }