// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "fmt" "strings" "git.labunix.xyz/krino/internal/sexp" ) // DirSettings are the forms a directory file may carry above its rules, in // the order the form shows them. They are the settings krino.conf(5) // documents for a directory; anything else in the file is a rule, an // exclude, or not krino's (GUI design ยง5.1). var DirSettings = []string{ "path", "recursive", "max-depth", "min-age", "max-read", "max-size", "busy", "case", "fold", "on-conflict", "ignore", } // Setting reads one directory setting as it is written - the form's // arguments, not their meaning - and whether the file sets it at all. func (r *Rules) Setting(head string) (args string, set bool, err error) { if !isDirSetting(head) { return "", false, fmt.Errorf("model: %s is not a directory setting", head) } node, err := r.settingNode(head) if err != nil || node == nil { return "", false, err } return argsOf(node, r.Text), true, nil } // SetSetting writes one directory setting: args as they are to be written, // or "" to take the setting out of the file. A setting already in the file // is rewritten where it stands; a new one goes at the end of the header, // above the first exclude or rule, so a directory file keeps its shape. // The path is the one setting that cannot be cleared - without it the file // does not load. func (r *Rules) SetSetting(head, args string) error { if !isDirSetting(head) { return fmt.Errorf("model: %s is not a directory setting", head) } args = strings.TrimSpace(args) if args == "" && head == "path" { return fmt.Errorf("model: a directory needs its (path ...)") } node, err := r.settingNode(head) if err != nil { return err } switch { case node != nil && args == "": start, end := r.lineSpan(node) r.Text = join(r.Text[:start], r.Text[end:]) case node != nil: r.Text = r.Text[:node.Pos.Offset] + "(" + head + " " + args + ")" + r.Text[node.End.Offset:] default: at, err := r.headerEnd() if err != nil { return err } r.Text = r.Text[:at] + "(" + head + " " + args + ")\n" + r.Text[at:] } return nil } // isDirSetting reports whether head is one of the directory settings. func isDirSetting(head string) bool { for _, s := range DirSettings { if s == head { return true } } return false } // settingNode is the form for head in the current text, or nil. A file that // does not parse has no settings to read. func (r *Rules) settingNode(head string) (*sexp.Node, error) { nodes, err := sexp.Parse(r.File, []byte(r.Text)) if err != nil { return nil, err } for _, n := range nodes { if n.Kind == sexp.List && n.Head() == head { return n, nil } } return nil, nil } // headerEnd is where a new setting goes: after the last setting already // written, and in any case above the first exclude or rule. func (r *Rules) headerEnd() (int, error) { nodes, err := sexp.Parse(r.File, []byte(r.Text)) if err != nil { return 0, err } at := 0 for _, n := range nodes { if n.Kind != sexp.List { continue } switch { case isDirSetting(n.Head()): at = lineEnd(r.Text, n.End.Offset) case n.Head() == "exclude" || n.Head() == "rule": if at == 0 { at = lineStart(r.Text, n.Pos.Offset) } return at, nil } } if at == 0 { at = len(r.Text) } return at, nil } // lineSpan is the whole line a setting is written on, so clearing it takes // the comment that trails it rather than leaving it to dangle. func (r *Rules) lineSpan(n *sexp.Node) (start, end int) { return lineStart(r.Text, n.Pos.Offset), lineEnd(r.Text, n.End.Offset) } // argsOf is a form's arguments exactly as the file writes them. func argsOf(n *sexp.Node, text string) string { if len(n.Children) < 2 { return "" } from := n.Children[1].Pos.Offset to := n.Children[len(n.Children)-1].End.Offset return strings.TrimSpace(text[from:to]) }