diff options
Diffstat (limited to 'gui/internal/model')
| -rw-r--r-- | gui/internal/model/forms.go | 254 | ||||
| -rw-r--r-- | gui/internal/model/forms_test.go | 240 |
2 files changed, 494 insertions, 0 deletions
diff --git a/gui/internal/model/forms.go b/gui/internal/model/forms.go new file mode 100644 index 0000000..681c15c --- /dev/null +++ b/gui/internal/model/forms.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "fmt" + "strings" + + "krino/internal/config" + "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 +} diff --git a/gui/internal/model/forms_test.go b/gui/internal/model/forms_test.go new file mode 100644 index 0000000..2c23462 --- /dev/null +++ b/gui/internal/model/forms_test.go @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "strings" + "testing" + + "krino/internal/config" +) + +// formsFile is a directory file with comments in the places that matter: +// above a rule (its own), inside a rule, and free-standing. +const formsFile = `(path "~/dl") + +(exclude (name "^keep-")) + +;; invoices go to their year +(rule "invoices" + (when (and (type pdf) (content "invoice"))) + (move "Invoices/{mtime:%Y}") + (stop)) + +(rule "images" + (when (type image)) ; anything the type table calls an image + (move "Images") + (stop)) + +;; last resort + +(rule "rest" + (move "Other")) +` + +// openForms puts text in an editor over the sandbox's file. +func openForms(t *testing.T, text string) *Rules { + t.Helper() + e, _ := sandboxDir(t, formsFile, map[string]string{"a.pdf": "one"}) + r, err := OpenRules(e, "dl") + if err != nil { + t.Fatal(err) + } + if text != "" { + r.SetText(text) + } + return r +} + +// TestFormsLists: every editable form of the file, in file order, named the +// way the list shows it. +func TestFormsLists(t *testing.T) { + r := openForms(t, "") + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range forms { + got = append(got, f.Label) + } + want := []string{`(exclude (name "^keep-"))`, "invoices", "images", "rest"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("forms = %v, want %v", got, want) + } + if forms[0].Kind != ExcludeForm || forms[1].Kind != RuleForm { + t.Errorf("kinds = %v, %v", forms[0].Kind, forms[1].Kind) + } + if forms[1].Rule == nil || forms[1].Rule.Name != "invoices" { + t.Errorf("rule form carries no rule: %+v", forms[1]) + } +} + +// TestDeleteFormTakesItsComments: a rule's block is its form plus the +// comment lines directly above it with no blank line between (GUI design +// §5.1), so deleting it does not leave its comment stranded - while a +// comment separated by a blank line stays. +func TestDeleteFormTakesItsComments(t *testing.T) { + r := openForms(t, "") + if err := r.DeleteForm(1); err != nil { // invoices, with its comment + t.Fatal(err) + } + if strings.Contains(r.Text, "invoices go to their year") { + t.Errorf("the rule's own comment was left behind:\n%s", r.Text) + } + if strings.Contains(r.Text, `(rule "invoices"`) { + t.Errorf("the rule is still there:\n%s", r.Text) + } + // The free-standing comment above "rest" is separated by a blank line, + // so deleting that rule leaves it alone. + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + if err := r.DeleteForm(len(forms) - 1); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, ";; last resort") { + t.Errorf("a comment separated by a blank line was taken too:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestMoveForm: a rule moves with its comments, and the order of everything +// else is kept. +func TestMoveForm(t *testing.T) { + r := openForms(t, "") + if err := r.MoveForm(2, -1); err != nil { // images, up past invoices + t.Fatal(err) + } + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range forms { + got = append(got, f.Label) + } + want := []string{`(exclude (name "^keep-"))`, "images", "invoices", "rest"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("after moving up: %v, want %v", got, want) + } + // The comment travelled with its rule, and still sits above it. + iInvoices := strings.Index(r.Text, `(rule "invoices"`) + iComment := strings.Index(r.Text, ";; invoices go to their year") + if iComment < 0 || iComment > iInvoices { + t.Errorf("the comment did not travel with its rule:\n%s", r.Text) + } + if strings.Index(r.Text, `(rule "images"`) > iComment { + t.Errorf("images did not move above invoices:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } + // Moving the first form up, or the last down, does nothing. + before := r.Text + if err := r.MoveForm(0, -1); err != nil { + t.Fatal(err) + } + if err := r.MoveForm(len(forms)-1, 1); err != nil { + t.Fatal(err) + } + if r.Text != before { + t.Error("moving past the ends changed the file") + } +} + +// TestAddRuleAfter: a new rule lands after the one selected, is the form +// the printer writes, and the file still loads. +func TestAddRuleAfter(t *testing.T) { + r := openForms(t, "") + i, err := r.AddRuleAfter(1, "new one") + if err != nil { + t.Fatal(err) + } + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + if i != 2 || forms[2].Label != "new one" { + t.Fatalf("new rule at %d: %v", i, labels(forms)) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestReplaceForm: editing one form rewrites that form's text and nothing +// else - every comment and every other line stays byte for byte. +func TestReplaceForm(t *testing.T) { + r := openForms(t, "") + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + rule := *forms[1].Rule // invoices + rule.Actions = []config.Action{{Kind: config.Move, Arg: "Faktury"}} + if err := r.ReplaceForm(1, config.PrintRule(&rule)); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, `(move "Faktury")`) { + t.Errorf("the edit did not land:\n%s", r.Text) + } + if strings.Contains(r.Text, `(move "Invoices/{mtime:%Y}")`) { + t.Errorf("the old action is still there:\n%s", r.Text) + } + for _, keep := range []string{";; invoices go to their year", ";; last resort", + "; anything the type table calls an image", `(exclude (name "^keep-"))`} { + if !strings.Contains(r.Text, keep) { + t.Errorf("replacing one form lost %q:\n%s", keep, r.Text) + } + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestFormHasComments: a form with a comment inside it cannot be rewritten +// from a form editor without losing it, so the caller is warned; a +// semicolon inside a string is not a comment (GUI design §5.1). +func TestFormHasComments(t *testing.T) { + r := openForms(t, "") + forms, err := r.Forms() + if err != nil { + t.Fatal(err) + } + if got, err := r.FormHasComments(2); err != nil || !got { // images + t.Errorf("FormHasComments(images) = %v, %v; want true", got, err) + } + if got, err := r.FormHasComments(1); err != nil || got { // invoices + t.Errorf("FormHasComments(invoices) = %v, %v; want false", got, err) + } + _ = forms + + r.SetText("(path \"~/dl\")\n(rule \"semi\" (when (name \"a;b\")) (move \"Out\"))\n") + if got, err := r.FormHasComments(0); err != nil || got { + t.Errorf("a semicolon inside a string counted as a comment: %v, %v", got, err) + } +} + +// TestFormsRefusesBrokenText: the forms list comes from parsing, so text +// that does not parse has no forms to show, and says so. +func TestFormsRefusesBrokenText(t *testing.T) { + r := openForms(t, "(path \"~/dl\")\n(rule \"unclosed\"\n") + if _, err := r.Forms(); err == nil { + t.Error("Forms answered for text that does not parse") + } + if err := r.DeleteForm(0); err == nil { + t.Error("DeleteForm acted on text that does not parse") + } +} + +func labels(forms []Form) []string { + var out []string + for _, f := range forms { + out = append(out, f.Label) + } + return out +} |
