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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"fmt"
"path/filepath"
"strings"
"krino/internal/sexp"
"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
Rules []*Rule
}
// Rule is a named condition with the actions it performs.
type Rule struct {
Name string
Pos sexp.Pos
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 == "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, 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
}
r := &Rule{Name: args[0].Text, Pos: n.Pos}
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
}
|