aboutsummaryrefslogtreecommitdiff
path: root/internal/config/dir.go
blob: a025d748d7b8660a9bad770258ad8962e890c1c1 (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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
// SPDX-License-Identifier: GPL-3.0-or-later

package config

import (
	"fmt"
	"path/filepath"
	"strings"

	"git.labunix.xyz/krino/internal/sexp"
	"git.labunix.xyz/krino/internal/xdg"
)

// Dir is one directory's config, dirs/<name>.conf.
type Dir struct {
	Name     string
	File     string
	Path     string // absolute and cleaned
	PathText string // as written in the file
	Settings Settings
	Ignore   []string // gitignore patterns, in order
	Excludes []*Exclude
	Rules    []*Rule
}

// Exclude is one (exclude COND...) form: a file for which every condition
// holds is set aside before any rule runs. Forms may repeat, so a file is
// excluded when any one form matches it.
type Exclude struct {
	Pos  sexp.Pos
	End  sexp.Pos     // just past the form's closing paren, for Splice
	Text string       // the form as written, whitespace collapsed, for check and explain
	When []*sexp.Node // the conditions, all of which must hold
}

// parseExclude reads an (exclude COND...) form from src; nil when it has no
// usable condition.
func parseExclude(n *sexp.Node, src []byte, d *diags) *Exclude {
	conds := n.Args()
	if len(conds) == 0 {
		d.at(n, "(exclude) needs a condition, like (exclude (type iso))")
		return nil
	}
	bad := false
	for _, c := range conds {
		if c.Kind != sexp.List {
			d.at(c, "exclude: a condition is a form like (type pdf), not %s", c)
			bad = true
		}
	}
	if bad {
		return nil
	}
	text := strings.Join(strings.Fields(string(src[n.Pos.Offset:n.End.Offset])), " ")
	return &Exclude{Pos: n.Pos, End: n.End, Text: text, When: conds}
}

// Rule is a named condition with the actions it performs.
type Rule struct {
	Name     string
	Pos      sexp.Pos
	End      sexp.Pos     // just past the form's closing paren, for Splice
	When     []*sexp.Node // the conditions, all of which must hold
	HasWhen  bool         // false: the rule matches every file
	Settings Settings     // only case, fold and on-conflict
	Actions  []Action     // in the order written
	Stop     bool
}

// ActionKind is what an action does.
type ActionKind int

const (
	Copy ActionKind = iota
	Move
	Rename
	Delete          // to the Trash
	DeletePermanent // unlink
)

func (k ActionKind) String() string {
	switch k {
	case Copy:
		return "copy"
	case Move:
		return "move"
	case Rename:
		return "rename"
	case Delete:
		return "delete"
	case DeletePermanent:
		return "delete permanent"
	}
	return fmt.Sprintf("ActionKind(%d)", int(k))
}

// Action is one step of a rule.
type Action struct {
	Kind ActionKind
	Arg  string // the directory of copy and move, the new name of rename
	Pos  sexp.Pos
}

// ParseDir reads the text of dirs/<name>.conf.
func ParseDir(name, file string, src []byte) (*Dir, []*Diag) {
	dir := &Dir{Name: name, File: file}
	nodes, err := sexp.Parse(file, src)
	if err != nil {
		return dir, []*Diag{fromSyntax(err)}
	}
	d := &diags{file: file}
	var pathNode *sexp.Node
	seen := map[string]*sexp.Node{}
	rules := map[string]*Rule{}
	for _, n := range nodes {
		switch head := n.Head(); {
		case head == "":
			d.at(n, "expected a form like (rule ...), got %s", n)
		case head == "path":
			if pathNode != nil {
				d.at(n, "path given twice (first at line %d)", pathNode.Pos.Line)
				continue
			}
			pathNode = n
			dir.parsePath(n, d)
		case head == "ignore":
			for _, a := range n.Args() {
				if a.Kind != sexp.String {
					d.at(a, `ignore takes patterns in quotes, like "*.part"; got %s`, a)
					continue
				}
				dir.Ignore = append(dir.Ignore, a.Text)
			}
		case head == "exclude":
			if x := parseExclude(n, src, d); x != nil {
				dir.Excludes = append(dir.Excludes, x)
			}
		case head == "rule":
			r := parseRule(n, d)
			if r == nil {
				continue
			}
			if first, ok := rules[r.Name]; ok {
				d.at(n, "rule %q defined twice (first at line %d)", r.Name, first.Pos.Line)
				continue
			}
			rules[r.Name] = r
			dir.Rules = append(dir.Rules, r)
		case isSetting(head):
			dir.Settings.parse(n, d, seen)
		default:
			d.at(n, "unknown form (%s ...); a directory file has path, ignore, exclude, rule and settings like (recursive yes)", head)
		}
	}
	if pathNode == nil {
		d.at(nil, "no (path ...): say which directory this file sorts")
	}
	return dir, d.list
}

func (dir *Dir) parsePath(n *sexp.Node, d *diags) {
	args := n.Args()
	if len(args) == 1 && args[0].Kind == sexp.Symbol {
		d.at(args[0], "path must be a string: write (path %s)", sexp.Quote(args[0].Text))
		return
	}
	if len(args) != 1 || args[0].Kind != sexp.String {
		d.at(n, `path takes one directory in quotes, like (path "~/downloads")`)
		return
	}
	p := xdg.Expand(args[0].Text)
	if !filepath.IsAbs(p) {
		d.at(args[0], "path must be absolute or start with ~, not %s", sexp.Quote(args[0].Text))
		return
	}
	dir.Path = filepath.Clean(p)
	dir.PathText = args[0].Text
}

var actionHeads = map[string]bool{"copy": true, "move": true, "rename": true, "delete": true}

// parseRule reads (rule "NAME" ITEM...); nil if it has no usable name.
func parseRule(n *sexp.Node, d *diags) *Rule {
	args := n.Args()
	if len(args) == 0 || args[0].Kind != sexp.String || args[0].Text == "" {
		d.at(n, `rule needs a name in quotes first, like (rule "invoices" ...)`)
		return nil
	}
	if strings.HasPrefix(args[0].Text, "(") {
		d.at(n, `rule names cannot start with "(": (review) marks choices made in review`)
		return nil
	}
	r := &Rule{Name: args[0].Text, Pos: n.Pos, End: n.End}
	seen := map[string]*sexp.Node{}
	var whenNode *sexp.Node
	triedAction, deleted := false, false
	errorCount := len(d.list)
	for _, item := range args[1:] {
		switch head := item.Head(); {
		case head == "":
			d.at(item, "rule %q: expected a form like (when ...) or (move ...), got %s", r.Name, item)
		case head == "when":
			if whenNode != nil {
				d.at(item, "rule %q: when given twice (first at line %d)", r.Name, whenNode.Pos.Line)
				continue
			}
			whenNode = item
			r.HasWhen = true
			r.When = item.Args()
			if len(r.When) == 0 {
				d.at(item, "rule %q: (when) needs a condition; leave it out to match every file", r.Name)
			}
			for _, c := range r.When {
				if c.Kind != sexp.List {
					d.at(c, "rule %q: a condition is a form like (type pdf), not %s", r.Name, c)
				}
			}
		case head == "stop":
			if len(item.Args()) != 0 {
				d.at(item, "rule %q: stop takes nothing: write (stop)", r.Name)
				continue
			}
			r.Stop = true
		case isSetting(head):
			if !ruleSettings[head] {
				d.at(item, "rule %q: %s cannot be set in a rule, only case, fold and on-conflict", r.Name, head)
				continue
			}
			r.Settings.parse(item, d, seen)
		case actionHeads[head]:
			triedAction = true
			a, ok := parseAction(r.Name, item, d)
			if !ok {
				continue
			}
			if deleted {
				d.at(item, "rule %q: %s after delete would never run", r.Name, item)
				continue
			}
			deleted = a.Kind == Delete || a.Kind == DeletePermanent
			r.Actions = append(r.Actions, a)
		default:
			d.at(item, "rule %q: unknown form (%s ...); a rule has when, copy, move, rename, delete, stop, case, fold and on-conflict", r.Name, head)
			triedAction = true
		}
	}
	if !triedAction && !r.Stop && len(d.list) == errorCount {
		d.at(n, `rule %q does nothing: give it an action like (move "Somewhere") or (stop)`, r.Name)
	}
	return r
}

// parseAction reads one of copy, move, rename or delete.
func parseAction(rule string, n *sexp.Node, d *diags) (Action, bool) {
	head, args := n.Head(), n.Args()
	a := Action{Pos: n.Pos}
	if head == "delete" {
		switch {
		case len(args) == 0:
			a.Kind = Delete
		case len(args) == 1 && args[0].Kind == sexp.Symbol && args[0].Text == "permanent":
			a.Kind = DeletePermanent
		default:
			d.at(n, "rule %q: write (delete) for the Trash or (delete permanent)", rule)
			return a, false
		}
		return a, true
	}
	if len(args) == 1 && args[0].Kind == sexp.Symbol {
		d.at(args[0], "rule %q: %s takes a string: write (%s %s)", rule, head, head, sexp.Quote(args[0].Text))
		return a, false
	}
	what := map[string]string{"copy": "directory", "move": "directory", "rename": "new name"}[head]
	if len(args) != 1 || args[0].Kind != sexp.String || args[0].Text == "" {
		d.at(n, "rule %q: %s takes one %s in quotes", rule, head, what)
		return a, false
	}
	a.Kind = map[string]ActionKind{"copy": Copy, "move": Move, "rename": Rename}[head]
	a.Arg = args[0].Text
	if a.Kind == Rename && strings.Contains(a.Arg, "/") {
		d.at(args[0], "rule %q: rename gives a new name, not a path; use move to change directory", rule)
		return a, false
	}
	return a, true
}