summaryrefslogtreecommitdiff
path: root/gui/internal/model/forms.go
blob: 681c15cf68761472470fe189734e4389735fae36 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
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
}