cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / config / config.go
config.go 8.8K
1package config23import (4 "bufio"5 "errors"6 "fmt"7 "io"8 "io/fs"9 "os"10 "path/filepath"11 "strconv"12 "strings"13 "time"14)1516// SourceKind says where a value came from.17type SourceKind int1819const (20 FromDefault SourceKind = iota21 FromFile22 FromEnv23 FromFlag24)2526// Source is where a value came from: the default, a line of a config27// file, an environment variable or a flag.28type Source struct {29 Kind SourceKind30 Name string // the file, variable or flag31 Line int // for files32}3334func (s Source) String() string {35 switch s.Kind {36 case FromFile:37 return fmt.Sprintf("%s:%d", s.Name, s.Line)38 case FromEnv:39 return "$" + s.Name40 case FromFlag:41 return s.Name42 }43 return "default"44}4546// Value is one setting's value and where it came from.47type Value struct {48 Raw string49 Src Source50}5152// Warning is a problem with the config that didn't stop 012: an unknown53// key, a bad value, a missing include. The option keeps its default.54type Warning struct {55 Src Source56 Msg string57}5859func (w Warning) String() string {60 if w.Src.Kind == FromDefault {61 return w.Msg62 }63 return w.Src.String() + ": " + w.Msg64}6566// Short is the warning with a file in the config directory named67// relative to it ("config:3: ..."), to fit on the context line.68func (w Warning) Short() string {69 if w.Src.Kind == FromFile {70 if dir, err := Dir(); err == nil {71 if rel, err := filepath.Rel(dir, w.Src.Name); err == nil && !strings.HasPrefix(rel, "..") {72 s := w.Src73 s.Name = rel74 return Warning{Src: s, Msg: w.Msg}.String()75 }76 }77 }78 return w.String()79}8081// Config is the effective configuration.82type Config struct {83 // Path is the main config file, which may not exist.84 Path string85 Warnings []Warning86 vals map[string][]Value // file values, then env, then flags override87}8889// Dir is 012's config directory: $XDG_CONFIG_HOME/012 when that's set90// (on every OS, so tests and dotfile setups can point it anywhere),91// otherwise os.UserConfigDir()/012 (~/.config/012 on Linux,92// ~/Library/Application Support/012 on macOS, %AppData%\012 on Windows).93func Dir() (string, error) {94 if x := os.Getenv("XDG_CONFIG_HOME"); x != "" && filepath.IsAbs(x) {95 return filepath.Join(x, "012"), nil96 }97 d, err := os.UserConfigDir()98 if err != nil {99 return "", err100 }101 return filepath.Join(d, "012"), nil102}103104// DefaultPath is the main config file, <Dir>/config.105func DefaultPath() (string, error) {106 d, err := Dir()107 if err != nil {108 return "", err109 }110 return filepath.Join(d, "config"), nil111}112113// ThemesDir is where theme files go, <Dir>/themes.114func ThemesDir() string {115 d, err := Dir()116 if err != nil {117 return ""118 }119 return filepath.Join(d, "themes")120}121122// Load reads the config file at path (a missing file is fine), then123// applies environment variables (from getenv) and flags, which win in124// that order: flags > environment > config file > defaults.125func Load(path string, getenv func(string) string, flags map[string]string) *Config {126 c := &Config{Path: path, vals: map[string][]Value{}}127 if path != "" {128 c.readFile(path, false, map[string]bool{})129 }130 if getenv != nil {131 for i := range Options {132 o := &Options[i]133 for _, env := range o.Env {134 if v := strings.TrimSpace(getenv(env)); v != "" {135 c.set(o, v, Source{Kind: FromEnv, Name: env})136 break137 }138 }139 c.fallBack(o, getenv)140 }141 }142 for name, v := range flags {143 if o, ok := byName[name]; ok {144 c.set(o, v, Source{Kind: FromFlag, Name: o.Flag})145 }146 }147 return c148}149150// fallBack sets an option nothing else set from the first of its151// Fallback variables that Adopt makes a value of.152func (c *Config) fallBack(o *Option, getenv func(string) string) {153 if len(c.vals[o.Name]) > 0 {154 return155 }156 for _, env := range o.Fallback {157 if v := o.Adopt(strings.TrimSpace(getenv(env))); v != "" {158 c.set(o, v, Source{Kind: FromEnv, Name: env})159 return160 }161 }162}163164// readFile reads one file and then the files it includes. seen holds the165// files being read, to stop include cycles.166func (c *Config) readFile(path string, optional bool, seen map[string]bool) {167 abs, err := filepath.Abs(path)168 if err == nil {169 path = abs170 }171 if seen[path] {172 c.warn(Source{}, "config-file "+path+" includes itself; skipped")173 return174 }175 f, err := os.Open(path)176 switch {177 case errors.Is(err, fs.ErrNotExist):178 if !optional && len(seen) > 0 {179 c.warn(Source{}, "config-file "+path+" doesn't exist")180 }181 return182 case err != nil:183 c.warn(Source{}, err.Error())184 return185 }186 defer f.Close()187 seen[path] = true188 defer delete(seen, path)189 // Includes are read after the whole file, as in Ghostty, so they190 // override what comes before and after them.191 for _, inc := range c.parse(f, path) {192 c.readFile(inc.path, inc.optional, seen)193 }194}195196type include struct {197 path string198 optional bool199}200201// parse applies a file's lines and returns its includes.202func (c *Config) parse(r io.Reader, path string) []include {203 var incs []include204 sc := bufio.NewScanner(r)205 for n := 1; sc.Scan(); n++ {206 src := Source{Kind: FromFile, Name: path, Line: n}207 key, val, ok := splitLine(sc.Text())208 if !ok {209 if strings.TrimSpace(sc.Text()) != "" && !strings.HasPrefix(strings.TrimSpace(sc.Text()), "#") {210 c.warn(src, fmt.Sprintf("expected key = value, got %q", strings.TrimSpace(sc.Text())))211 }212 continue213 }214 o, known := byName[key]215 if !known {216 c.warn(src, "unknown key "+strconv.Quote(key))217 continue218 }219 if o.Name == "config-file" && val != "" {220 inc := include{path: val}221 if strings.HasPrefix(val, "?") {222 inc = include{path: strings.TrimPrefix(val, "?"), optional: true}223 }224 inc.path = expandPath(inc.path, filepath.Dir(path))225 incs = append(incs, inc)226 }227 c.set(o, val, src)228 }229 return incs230}231232// splitLine splits "key = value", ignoring blank lines and # comments. A233// value may be quoted to keep spaces at its ends or a #.234func splitLine(line string) (key, val string, ok bool) {235 line = strings.TrimSpace(line)236 if line == "" || strings.HasPrefix(line, "#") {237 return "", "", false238 }239 key, val, ok = strings.Cut(line, "=")240 if !ok {241 return "", "", false242 }243 key, val = strings.TrimSpace(key), strings.TrimSpace(val)244 if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {245 if u, err := strconv.Unquote(val); err == nil {246 val = u247 }248 }249 return key, val, key != ""250}251252// set records a value after validating it. An empty value resets the253// option (for repeatable ones, clears the list).254func (c *Config) set(o *Option, v string, src Source) {255 if err := o.validate(v); err != nil {256 c.warn(src, o.Name+": "+err.Error())257 return258 }259 switch {260 case v == "":261 delete(c.vals, o.Name)262 case o.Repeat && src.Kind == FromFile:263 c.vals[o.Name] = append(c.vals[o.Name], Value{Raw: v, Src: src})264 default:265 c.vals[o.Name] = []Value{{Raw: v, Src: src}}266 }267}268269// Override sets an option for the rest of the session, over every other270// source, e.g. a theme picked in Settings. why says who set it. An271// invalid value is ignored and returned as an error.272func (c *Config) Override(name, value, why string) error {273 o := mustLookup(name)274 if err := o.validate(value); err != nil {275 return err276 }277 c.vals[name] = []Value{{Raw: value, Src: Source{Kind: FromFlag, Name: why}}}278 return nil279}280281func (c *Config) warn(src Source, msg string) {282 c.Warnings = append(c.Warnings, Warning{Src: src, Msg: msg})283}284285// Get returns an option's value and where it came from.286func (c *Config) Get(name string) Value {287 o := mustLookup(name)288 if c != nil {289 if vs := c.vals[name]; len(vs) > 0 {290 return vs[len(vs)-1]291 }292 }293 return Value{Raw: o.Default}294}295296// String returns an option's value, with ~ expanded for paths.297func (c *Config) String(name string) string {298 v := c.Get(name)299 if byName[name].Kind == Path && v.Raw != "" {300 return expandPath(v.Raw, "")301 }302 return v.Raw303}304305// Bool returns a true-or-false option.306func (c *Config) Bool(name string) bool {307 b, _ := strconv.ParseBool(c.Get(name).Raw)308 return b309}310311// Int returns a number option.312func (c *Config) Int(name string) int {313 n, _ := strconv.Atoi(c.Get(name).Raw)314 return n315}316317// Duration returns a duration option.318func (c *Config) Duration(name string) time.Duration {319 d, _ := time.ParseDuration(c.Get(name).Raw)320 return d321}322323// List returns every value of a repeatable option.324func (c *Config) List(name string) []Value {325 mustLookup(name)326 if c == nil {327 return nil328 }329 return c.vals[name]330}331332// Theme returns the theme choice.333func (c *Config) Theme() ThemeChoice {334 t, _ := ParseThemeChoice(c.Get("theme").Raw)335 return t336}337338func mustLookup(name string) *Option {339 o, ok := byName[name]340 if !ok {341 panic("config: no option " + name)342 }343 return o344}345346// expandPath expands a leading ~ and makes a relative path relative to347// dir, when dir is given.348func expandPath(p, dir string) string {349 if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {350 if home, err := os.UserHomeDir(); err == nil {351 p = filepath.Join(home, p[1:])352 }353 }354 if dir != "" && !filepath.IsAbs(p) {355 p = filepath.Join(dir, p)356 }357 return p358}