aboutsummaryrefslogtreecommitdiff
path: root/internal/config/dir.go
blob: 74c538a7025ca52f6748b377ca6a4c47c6f8256f (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
// 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
}