// SPDX-License-Identifier: GPL-3.0-or-later // Package engine is what every krino front end calls: it loads and compiles // the configuration, matches files against rules, and plans and applies // actions. It returns data; front ends only render it. package engine import ( "fmt" "os" "sort" "strings" "time" "git.labunix.xyz/krino/internal/cond" "git.labunix.xyz/krino/internal/config" "git.labunix.xyz/krino/internal/extract" "git.labunix.xyz/krino/internal/ignore" "git.labunix.xyz/krino/internal/plan" ) // Engine holds a loaded, compiled configuration: everything a front end // needs to check, match and act. type Engine struct { Config *config.Config Dirs []*Dir Extract *extract.Extractor Now func() time.Time // time.Now; tests replace it MainFile string // CacheDir holds each directory's keyword cache (spec §6.1), as // NAME.cache; "" means no cache is read or written. Load leaves it // empty: the command line sets it. CacheDir string } // Dir is one configured directory, with its ignore matcher and rules // compiled. type Dir struct { Name string Root string // absolute Conf *config.Dir Settings config.Resolved // built-in, then defaults, then the directory Ignore *ignore.Matcher Rules []*Rule // ContentKeywords is every content keyword the directory's excludes // and rules test, once each, sorted by Key. When a file's text is // extracted, every one of them is answered at once, so one extraction // serves every content test and fills the keyword cache. ContentKeywords []cond.Keyword // Excludes are the (exclude ...) forms that apply here, compiled with the // directory's settings: krino.conf's first, then the directory's own. A // file any of them matches is set aside before any rule runs. Excludes []*Exclude // DupScopes is every distinct directory list the duplicate tests of // Rules use, in first-seen order, the plain (duplicate) as an empty // list. Spec §5.5 rule 2 looks a file up under each of them before any // rule may delete it. DupScopes [][]string } // Exclude is one compiled (exclude ...) form. type Exclude struct { Text string // the form as written, for check, explain and the plan Cond *cond.Cond } // Rule is one directory's rule, with its condition compiled. type Rule struct { Name string Conf *config.Rule Settings config.Resolved // the rule's own settings over its directory's Cond *cond.Cond } // Load reads and compiles everything. Any problem anywhere returns a nil // Engine and every diagnostic: krino never acts on a configuration it only // partly understood. Duplicate names are ignored after their first use. func Load(mainFile string, names ...string) (*Engine, []*config.Diag) { return LoadWith(mainFile, nil, names...) } // LoadWith is Load with some configuration files' text supplied by the // caller (config.LoadWith): the GUI checks unsaved editor text with it, // compiled exactly as a run would compile it (GUI design §1.3). func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*Engine, []*config.Diag) { cfg, errs := config.LoadWith(mainFile, overrides, dedupeNames(names)...) if cfg == nil { return nil, errs } var dirs []*Dir // A mistake inside a krino.conf exclude is compiled once per directory, // but reported once. reported := map[string]bool{} if len(cfg.Dirs) == 0 { // With no directory to compile them for, krino.conf's excludes are // still checked, so a mistake is reported before one is included. // Compiled with the defaults' case and fold, as a directory without // settings of its own would compile them. def := cfg.Main.Defaults.Over(config.Builtin()) opt := cond.Options{IgnoreCase: def.Case == config.CaseIgnore, Fold: def.Fold} for _, x := range cfg.Main.Excludes { _, cerrs := cond.Compile(cfg.Main.File, x.When, opt) errs = append(errs, cerrs...) } } for _, d := range cfg.Dirs { dir := &Dir{ Name: d.Name, Root: d.Path, Conf: d, Settings: cfg.Resolved(d), } if m, err := ignore.New(d.Ignore); err != nil { errs = append(errs, &config.Diag{File: d.File, Msg: err.Error()}) } else { dir.Ignore = m } for _, r := range d.Rules { rs := r.Settings.Over(dir.Settings) c, cerrs := cond.Compile(d.File, r.When, cond.Options{ IgnoreCase: rs.Case == config.CaseIgnore, Fold: rs.Fold, }) if len(cerrs) > 0 { errs = append(errs, cerrs...) continue } if diag := checkCaptures(d.File, r, c); diag != nil { errs = append(errs, diag) continue } if diag := checkDuplicateDelete(d.File, r, c); diag != nil { errs = append(errs, diag) continue } dir.Rules = append(dir.Rules, &Rule{Name: r.Name, Conf: r, Settings: rs, Cond: c}) } dirOpt := cond.Options{IgnoreCase: dir.Settings.Case == config.CaseIgnore, Fold: dir.Settings.Fold} for _, src := range []struct { file string excludes []*config.Exclude }{{cfg.Main.File, cfg.Main.Excludes}, {d.File, d.Excludes}} { for _, x := range src.excludes { c, cerrs := cond.Compile(src.file, x.When, dirOpt) for _, ce := range cerrs { if key := ce.Error(); !reported[key] { reported[key] = true errs = append(errs, ce) } } if len(cerrs) == 0 { dir.Excludes = append(dir.Excludes, &Exclude{Text: x.Text, Cond: c}) } } } dir.ContentKeywords = contentKeywords(dir.Rules, dir.Excludes) dir.DupScopes = dupScopes(dir.Rules, dir.Excludes) dirs = append(dirs, dir) } if len(errs) > 0 { return nil, errs } return &Engine{ Config: cfg, Dirs: dirs, Extract: extract.New(), Now: time.Now, MainFile: mainFile, }, nil } // checkCaptures validates a compiled rule's actions against the capture // groups its own name tests can supply (spec 7.3): a rule using {N} needs a // name test at all, and every name test in it needs at least N groups. It // reports only the first offending action, so one config mistake yields one // diagnostic. func checkCaptures(file string, r *config.Rule, c *cond.Cond) *config.Diag { groups := c.NameGroups() for _, a := range r.Actions { if err := plan.CheckTemplate(a.Arg); err != nil { // A placeholder that could never expand is a config error, not a // step skipped at plan time. return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: %v", r.Name, err)} } n, err := plan.MaxIndex(a.Arg) if err != nil || n == 0 { continue } if len(groups) == 0 { return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: {%d} needs a name test to capture from", r.Name, n)} } for _, g := range groups { if g < n { return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: {%d} but a name test has only %s", r.Name, n, captureGroups(g))} } } } return nil } // checkDuplicateDelete refuses a rule that combines a duplicate test with a // delete action (spec §4.5, §5.5): duplicates are found, never deleted. // Cond.DupDirs records every duplicate test compiled, inside or and not // too, so a test anywhere in the condition counts. It reports only the // first delete action, so one config mistake yields one diagnostic. func checkDuplicateDelete(file string, r *config.Rule, c *cond.Cond) *config.Diag { if len(c.DupDirs) == 0 { return nil } for _, a := range r.Actions { if a.Kind == config.Delete || a.Kind == config.DeletePermanent { return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: (duplicate) cannot be combined with (%s): duplicates are never deleted, move them aside instead", r.Name, a.Kind)} } } return nil } // captureGroups renders a capture-group count with correct singular/plural. func captureGroups(n int) string { if n == 1 { return "1 capture group" } return fmt.Sprintf("%d capture groups", n) } // dedupeNames returns names with every repeat after its first occurrence // removed, order preserved. func dedupeNames(names []string) []string { var out []string seen := map[string]bool{} for _, n := range names { if seen[n] { continue } seen[n] = true out = append(out, n) } return out } // contentKeywords returns every content keyword excludes and rules test, // each once, sorted by Key: Dir.ContentKeywords. func contentKeywords(rules []*Rule, excludes []*Exclude) []cond.Keyword { seen := map[string]bool{} var out []cond.Keyword add := func(c *cond.Cond) { for _, k := range c.Keywords { if !seen[k.Key()] { seen[k.Key()] = true out = append(out, k) } } } for _, x := range excludes { add(x.Cond) } for _, r := range rules { add(r.Cond) } sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() }) return out } // dupScopes returns the distinct Cond.DupDirs lists a directory uses, in // first-seen order, from its rules and its excludes alike. Lists are // compared as written, with their length in the key so (duplicate) and // (duplicate "") stay apart; two spellings of one directory stay two // entries, which costs a second lookup but never a wrong answer, since // facts.Duplicate resolves and shares the index itself. // // The excludes count because the protection these scopes drive - no rule // deletes a file krino has found to be a duplicate - is about what krino // knows, not about which form taught it. A directory whose only duplicate // test sat in an (exclude ...) had no scopes at all, so a later rule could // permanently delete every copy of a file krino had just called a // duplicate. func dupScopes(rules []*Rule, excludes []*Exclude) [][]string { var out [][]string seen := map[string]bool{} add := func(lists [][]string) { for _, dirs := range lists { key := fmt.Sprintf("%d\x00%s", len(dirs), strings.Join(dirs, "\x00")) if seen[key] { continue } seen[key] = true out = append(out, dirs) } } for _, r := range rules { add(r.Cond.DupDirs) } for _, x := range excludes { add(x.Cond.DupDirs) } return out } // Report is what Check reports: the files involved and each directory's // state. type Report struct { MainFile string LogFile string Dirs []DirReport Tools []extract.Tool } // DirReport is one directory's state in a Report. type DirReport struct { Dir *Dir Missing bool // the root is not a directory right now } // Check reports the engine's configuration files, each directory's current // state and the external tools found for content extraction. func (e *Engine) Check() Report { r := Report{ MainFile: e.MainFile, LogFile: e.Config.LogFile(), Tools: e.Extract.Tools(), } for _, d := range e.Dirs { fi, err := os.Stat(d.Root) r.Dirs = append(r.Dirs, DirReport{Dir: d, Missing: err != nil || !fi.IsDir()}) } return r }