// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "fmt" "strings" "git.labunix.xyz/krino/internal/config" "git.labunix.xyz/krino/internal/sexp" ) // FormKind is what an editable form is. type FormKind int const ( RuleForm FormKind = iota ExcludeForm ) // Form is one editable form of a directory's file, as the list on the left // of the Rules tab shows it (GUI design §5.1). type Form struct { Kind FormKind Label string // the rule's name, or the exclude as written Pos sexp.Pos End sexp.Pos Rule *config.Rule // RuleForm Exclude *config.Exclude // ExcludeForm } // Forms parses the text in the editor and lists what can be edited, in file // order: the excludes and rules of this directory. Text that does not parse // has no forms - the Text tab keeps it until it is fixed (GUI design §5.2). func (r *Rules) Forms() ([]Form, error) { over := map[string][]byte{r.File: []byte(r.Text)} cfg, diags := config.LoadWith(r.e.MainFile, over, r.Name) if len(diags) > 0 { return nil, fmt.Errorf("%s", diags[0]) } var d *config.Dir for _, cd := range cfg.Dirs { if cd.Name == r.Name { d = cd break } } if d == nil { return nil, fmt.Errorf("model: %s is not in the configuration", r.Name) } var out []Form for _, x := range d.Excludes { out = append(out, Form{Kind: ExcludeForm, Label: x.Text, Pos: x.Pos, End: x.End, Exclude: x}) } for _, rule := range d.Rules { out = append(out, Form{Kind: RuleForm, Label: rule.Name, Pos: rule.Pos, End: rule.End, Rule: rule}) } sortByPosition(out) return out, nil } // sortByPosition puts forms in the order they are written, since excludes // and rules are parsed into separate lists. func sortByPosition(forms []Form) { for i := 1; i < len(forms); i++ { for j := i; j > 0 && forms[j].Pos.Offset < forms[j-1].Pos.Offset; j-- { forms[j], forms[j-1] = forms[j-1], forms[j] } } } // ReplaceForm swaps form i's text for text - what config.PrintRule or // PrintExclude wrote for the edited form - leaving every other byte of the // file, comments and layout included, exactly as it was (GUI design §5.1). func (r *Rules) ReplaceForm(i int, text string) error { f, err := r.form(i) if err != nil { return err } out, err := config.Splice([]byte(r.Text), f.Pos, f.End, strings.TrimRight(text, "\n")) if err != nil { return err } r.Text = string(out) return nil } // DeleteForm removes form i and the comment lines directly above it - its // block (GUI design §5.1). A comment separated from the form by a blank // line belongs to the file, not to the form, and stays. func (r *Rules) DeleteForm(i int) error { f, err := r.form(i) if err != nil { return err } start, end := r.block(f) r.Text = join(r.Text[:start], r.Text[end:]) return nil } // MoveForm moves form i one place up (delta -1) or down (delta 1), with its // comments. Moving past either end does nothing. func (r *Rules) MoveForm(i, delta int) error { forms, err := r.Forms() if err != nil { return err } j := i + delta if i < 0 || i >= len(forms) || j < 0 || j >= len(forms) { return nil } a, b := forms[i], forms[j] if a.Pos.Offset > b.Pos.Offset { a, b = b, a } as, ae := r.block(a) bs, be := r.block(b) if ae > bs { return fmt.Errorf("model: the two forms share lines") } r.Text = r.Text[:as] + r.Text[bs:be] + r.Text[ae:bs] + r.Text[as:ae] + r.Text[be:] return nil } // AddRuleAfter inserts a new rule after form i - after every form when i is // out of range - and returns its place in the new list. The rule it writes // is the smallest one that loads, for the form editor to fill in. func (r *Rules) AddRuleAfter(i int, name string) (int, error) { forms, err := r.Forms() if err != nil { return 0, err } text := config.PrintRule(&config.Rule{ Name: name, Actions: []config.Action{{Kind: config.Move, Arg: "TODO"}}, }) at := len(r.Text) if i >= 0 && i < len(forms) { _, at = r.block(forms[i]) } r.Text = join(r.Text[:at], "\n"+text+r.Text[at:]) after, err := r.Forms() if err != nil { return 0, err } for n, f := range after { if f.Kind == RuleForm && f.Label == name && !was(forms, f) { return n, nil } } return 0, fmt.Errorf("model: the new rule is not in the file") } // was reports whether a form of the same kind and label was already there // at the same offset, so AddRuleAfter can tell its new rule from a rule of // the same name that existed before. func was(before []Form, f Form) bool { for _, b := range before { if b.Kind == f.Kind && b.Label == f.Label && b.Pos.Offset == f.Pos.Offset { return true } } return false } // FormHasComments reports whether form i holds a comment inside it. A form // editor writes the form back from what was parsed, and the parser drops // comments, so the caller warns before replacing one (GUI design §5.1). func (r *Rules) FormHasComments(i int) (bool, error) { f, err := r.form(i) if err != nil { return false, err } return hasComment(r.Text[f.Pos.Offset:f.End.Offset]), nil } // hasComment reports whether s holds a ";" that starts a comment: one // outside a string, since a name pattern may well contain a semicolon. func hasComment(s string) bool { inString, escaped := false, false for _, c := range s { switch { case escaped: escaped = false case c == '\\' && inString: escaped = true case c == '"': inString = !inString case c == ';' && !inString: return true } } return false } // form is form i of the current text. func (r *Rules) form(i int) (Form, error) { forms, err := r.Forms() if err != nil { return Form{}, err } if i < 0 || i >= len(forms) { return Form{}, fmt.Errorf("model: no form %d", i) } return forms[i], nil } // block is the span a form moves and is deleted with: from the start of the // first comment line that touches it, to the end of the line its closing // paren is on. func (r *Rules) block(f Form) (start, end int) { start = lineStart(r.Text, f.Pos.Offset) for start > 0 { prev := lineStart(r.Text, start-1) line := strings.TrimSpace(r.Text[prev : start-1]) if !strings.HasPrefix(line, ";") { break } start = prev } end = lineEnd(r.Text, f.End.Offset) return start, end } // lineStart is the offset just after the newline before at. func lineStart(s string, at int) int { if at > len(s) { at = len(s) } if i := strings.LastIndexByte(s[:at], '\n'); i >= 0 { return i + 1 } return 0 } // lineEnd is the offset just past the newline that ends at's line. func lineEnd(s string, at int) int { if at >= len(s) { return len(s) } if i := strings.IndexByte(s[at:], '\n'); i >= 0 { return at + i + 1 } return len(s) } // join puts two pieces of a file back together without leaving a run of // blank lines where something was taken out or put in. func join(before, after string) string { for strings.HasSuffix(before, "\n\n") && strings.HasPrefix(after, "\n") { after = after[1:] } return before + after }