package main import ( "bufio" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) // Binding is one key binding of the configuration in force. type Binding struct { Mode string `json:"mode"` Kind string `json:"kind"` // bindsym or bindcode Keys string `json:"keys"` Command string `json:"command"` Release bool `json:"release,omitempty"` File string `json:"file"` } var modeOpen = regexp.MustCompile(`^mode\s+(?:--pango_markup\s+)?("(?:[^"\\]|\\.)*"|\S+)\s*\{$`) // Bindings reads the bindings out of configuration text whose variables i3 has already replaced // (GET_CONFIG's variable_replaced_contents), mode blocks included. func Bindings(file, text string) []Binding { var out []Binding mode, depth := "default", 0 sc := bufio.NewScanner(strings.NewReader(joinContinued(text))) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } if m := modeOpen.FindStringSubmatch(line); m != nil { if s, err := strconv.Unquote(m[1]); err == nil { mode = s } else { mode = m[1] } depth = 1 continue } if depth > 0 { depth += strings.Count(line, "{") - strings.Count(line, "}") if depth <= 0 { mode, depth = "default", 0 continue } } f := strings.Fields(line) if len(f) < 3 || (f[0] != "bindsym" && f[0] != "bindcode") { continue } b := Binding{Mode: mode, Kind: f[0], File: file} i := 1 for ; i < len(f) && strings.HasPrefix(f[i], "--"); i++ { if f[i] == "--release" { b.Release = true } } if i >= len(f)-1 { continue } b.Keys = f[i] b.Command = strings.Join(f[i+1:], " ") out = append(out, b) } return out } func joinContinued(text string) string { return strings.ReplaceAll(text, "\\\n", " ") } // ConfigError is one problem `i3 -C` reports. type ConfigError struct { File string `json:"file,omitempty"` Line int `json:"line,omitempty"` Text string `json:"text"` } var ( checkError = regexp.MustCompile(`ERROR: (?:CONFIG: )?(.*)$`) checkFile = regexp.MustCompile(`^\(in file (.+)\)$`) checkLine = regexp.MustCompile(`^Line\s+(\d+): (.*)$`) checkMark = regexp.MustCompile(`^\s*\^+\s*$`) ) // ParseCheck reads what `i3 -C` printed: each error with the file and line it points at. i3 prints // the lines around an error as context; the one marked with carets beneath is the error's. func ParseCheck(text string) []ConfigError { var out []ConfigError var cur *ConfigError file := "" lastLine, lastText := 0, "" for _, raw := range strings.Split(text, "\n") { m := checkError.FindStringSubmatch(raw) if m == nil { continue } msg := m[1] switch { case checkFile.MatchString(msg): file = checkFile.FindStringSubmatch(msg)[1] if cur != nil && cur.File == "" { cur.File = file } case checkLine.MatchString(msg): lm := checkLine.FindStringSubmatch(msg) lastLine, _ = strconv.Atoi(lm[1]) lastText = lm[2] case checkMark.MatchString(msg): if cur != nil { cur.Line = lastLine cur.Text = strings.TrimSpace(cur.Text + " — at: " + strings.TrimSpace(lastText)) } default: out = append(out, ConfigError{File: file, Text: msg}) cur = &out[len(out)-1] } } return out } // Watched is the configuration's files the reload watcher looks at: the main file and every drop-in. func Watched(dir string) map[string]string { out := map[string]string{} paths := []string{filepath.Join(dir, "config")} drop, _ := filepath.Glob(filepath.Join(dir, "config.d", "*.conf")) paths = append(paths, drop...) for _, p := range paths { if info, err := os.Stat(p); err == nil { out[p] = strconv.FormatInt(info.Size(), 10) + "@" + strconv.FormatInt(info.ModTime().UnixNano(), 10) } } return out } // Watcher reloads i3 when its configuration changes on disk, after checking it (ADR 0198: the // module's own long-running code, inside its tools bundle). It replaces the predecessor's inotify // script and user unit: it polls, so a file replaced by rename is seen as surely as one written in // place, and a directory that appears later (config.d) is picked up without a new watch. // // **It never reloads into a broken configuration.** i3 would load what it can and show its error bar; // the watcher instead keeps the running configuration and records why. type Watcher struct { Dir string Every time.Duration Check func() ([]ConfigError, error) Reload func() error // Loaded is what the running i3 loaded, by file (GET_CONFIG). At the watcher's start, a file on // disk that differs from it — or a drop-in added or gone — is a change still to apply: the runtime // restarts with every push, after the push has written the files, so a watcher that only compared // the files with themselves would never reload what the push that started it wrote. Loaded func() (map[string]string, error) mu sync.Mutex status WatcherStatus } // WatcherStatus is what the watcher has done, for the tools to answer. type WatcherStatus struct { Since string `json:"since"` Files int `json:"files_watched"` LastChange string `json:"last_change,omitempty"` Changed []string `json:"changed,omitempty"` LastReload string `json:"last_reload,omitempty"` Reloads int `json:"reloads"` Refused []ConfigError `json:"refused,omitempty"` LastProblem string `json:"last_problem,omitempty"` } // Status is a copy of what the watcher has done. func (w *Watcher) Status() WatcherStatus { w.mu.Lock() defer w.mu.Unlock() return w.status } // Run watches until stop closes. A change is acted on once the files have been still for one more // interval, so a push writing several files is one reload. func (w *Watcher) Run(stop <-chan struct{}) { seen := Watched(w.Dir) w.mu.Lock() w.status.Since = time.Now().Format(time.RFC3339) w.status.Files = len(seen) w.mu.Unlock() pending := w.stale(seen) tick := time.NewTicker(w.Every) defer tick.Stop() for { select { case <-stop: return case <-tick.C: } now := Watched(w.Dir) changed := diff(seen, now) seen = now if len(changed) > 0 { pending = true w.mu.Lock() w.status.LastChange = time.Now().Format(time.RFC3339) w.status.Changed = changed w.status.Files = len(now) w.mu.Unlock() continue } if !pending { continue } pending = false w.act() } } func (w *Watcher) act() { problems, err := w.Check() w.mu.Lock() defer w.mu.Unlock() switch { case err != nil: w.status.LastProblem = "the configuration could not be checked: " + err.Error() return case len(problems) > 0: w.status.Refused = problems w.status.LastProblem = "not reloaded: the configuration has errors" return } w.status.Refused = nil w.mu.Unlock() err = w.Reload() w.mu.Lock() if err != nil { w.status.LastProblem = "reload: " + err.Error() return } w.status.LastProblem = "" w.status.Reloads++ w.status.LastReload = time.Now().Format(time.RFC3339) } // stale is whether the files on disk are not what the running i3 loaded. func (w *Watcher) stale(onDisk map[string]string) bool { if w.Loaded == nil { return false } loaded, err := w.Loaded() if err != nil { return false } if len(loaded) != len(onDisk) { return true } for path := range onDisk { text, ok := loaded[path] if !ok { return true } disk, err := os.ReadFile(path) if err != nil || string(disk) != text { return true } } return false } func diff(a, b map[string]string) []string { var out []string for k, v := range b { if a[k] != v { out = append(out, k) } } for k := range a { if _, ok := b[k]; !ok { out = append(out, k) } } sort.Strings(out) return out }