// 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 (from plan 3) plans // and applies actions. It returns data; front ends only render it. package engine import ( "fmt" "os" "time" "krino/internal/cond" "krino/internal/config" "krino/internal/extract" "krino/internal/ignore" "krino/internal/plan" ) // Engine holds a loaded, compiled configuration: everything a front end // needs to check, match and (from plan 3) act. type Engine struct { Config *config.Config Dirs []*Dir Extract *extract.Extractor Now func() time.Time // time.Now; tests replace it MainFile 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 // ContentVariants is the distinct (IgnoreCase, Fold) pairs any of // Rules' content tests evaluate under, in first-seen order. B2: when // this holds exactly one variant, facts.Content releases a file's raw // extracted text once that variant's normalised copy exists, since no // other variant will ever be asked for; with more than one, both must // stay memoised, as before. ContentVariants []cond.Options } // 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) { cfg, errs := config.Load(mainFile, dedupeNames(names)...) if cfg == nil { return nil, errs } var dirs []*Dir 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 } dir.Rules = append(dir.Rules, &Rule{Name: r.Name, Conf: r, Settings: rs, Cond: c}) } dir.ContentVariants = contentVariants(dir.Rules) 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 { 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 } // 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 } // contentVariants returns the distinct (IgnoreCase, Fold) pairs any of // rules' content tests evaluate under, in first-seen order — B2's per-Dir // ContentVariants. A rule whose condition has no content test at all // (Cond.UsesContent false) never calls facts.Content, so its resolved // case/fold settings contribute no variant here. func contentVariants(rules []*Rule) []cond.Options { var out []cond.Options seen := map[cond.Options]bool{} for _, r := range rules { if !r.Cond.UsesContent { continue } opt := cond.Options{IgnoreCase: r.Settings.Case == config.CaseIgnore, Fold: r.Settings.Fold} if seen[opt] { continue } seen[opt] = true out = append(out, opt) } 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 }