src/ledger/ignore.bend
ezx@1.6.0.0 · 396 lines · 0 definitions
1# ledger/ignore: the gitignore allowlist, derived from the ledger. A
2# dependency marked `vendor = true` has its tree committed under
3# `.ez/lib/<hash>`, and git only keeps it when `.gitignore` says so with a
4# `!.ez/lib/<hash>` line. That line has to follow the ledger exactly: a hash
5# the ledger stopped naming leaves a dead tree committed, and one it names
6# but the file does not is a tree a clone never gets. So the allowlist is not
7# edited, it is derived: `sync` takes the ledger's dependencies and the file,
8# and gives back the file whose allowlist lines are exactly the vendored
9# hashes, with every line that is not ez's left as it was. `ez add`,
10# `ez remove` and `ez lock --upgrade` all write what it returns.
11#
12# Which lines are ez's. A line that starts `!.ez/lib/` is ez's allowlist, as
13# written, and whatever follows the prefix is the hash it names. Anything
14# else is read loosely: surrounding blanks trimmed, one leading `/` and one
15# trailing `/` dropped, so `!/.ez/lib/<h>/` and ` !.ez/lib/<h>` are
16# allowlist lines too. ez owns everything under `.ez/lib`, so any exception
17# under it is ez's to keep or drop; a line spelled differently from `allow`
18# is dropped and the hash, when the ledger still vendors it, written back in
19# the canonical spelling. Three more lines are what makes the allowlist work
20# at all: `.ez/*`, `!.ez/lib` and `.ez/lib/*`. Git cannot re-include a file
21# under a directory it excluded, so a bare `.ez/` (or `.ez`, `/.ez/`) makes
22# every allowlist line inert. That line is replaced, where it stands, by
23# whichever of the three the file does not already have.
24#
25# Where the allowlist goes. The vendored hashes are written as one block, each
26# once, in the order the ledger first names them, where the file's first
27# allowlist line was. A file with
28# none gets the block at its end, which is where the upgrade has always
29# appended one, preceded by whichever of the three rules the file lacks.
30import Base
31import ezx@1.6.0.0/src/ledger/manifest.bend as M
32
33# what one line of an ignore file is to ez
34type Rule is Data:
35 Other{}
36 Dir{}
37 Star{}
38 Lib{}
39 Pkgs{}
40 Allow{hash: String}
41
42# a line, with what it is to ez beside it
43type Line is Data:
44 Line{kind: Rule, text: String}
45
46# the gitignore line that keeps one committed tree
47def allow(+hash: String) -> String:
48 "!.ez/lib/" ++ hash
49
50# one leading `/`, which anchors a pattern to the file's own directory and
51# so changes nothing for a `.gitignore` at the root
52def lead(+pat: String) -> String:
53 Bool.pick(String, String.starts_with(pat, "/"), String.drop(pat, 1n), pat)
54
55# one trailing `/`, which only narrows a pattern to directories, and every
56# path ez names here is one
57def trail(+pat: String) -> String:
58 Bool.pick(String, String.ends_with(pat, "/"),
59 String.take(pat, Nat.sub(String.length(pat), 1n)), pat)
60
61# a pattern with both of those dropped
62def bare(+pat: String) -> String:
63 trail(lead(pat))
64
65# an exception under `.ez/lib/` names the hash after the prefix
66def loose.under(under: Bool, +rest: String) -> Rule:
67 match under:
68 case True{}:
69 Bool.pick(Rule, String.is_empty(rest), Other{}, Allow{rest})
70 case False{}:
71 Other{}
72
73# an exception: the re-included library directory, or one tree under it
74def loose.neg(+pat: String) -> Rule:
75 Bool.pick(Rule, String.eq(pat, ".ez/lib"), Lib{},
76 loose.under(String.starts_with(pat, ".ez/lib/"), String.drop(pat, 8n)))
77
78# an exclusion: the whole of `.ez`, what is inside it, or what is inside
79# its library directory
80def loose.pos(+pat: String) -> Rule:
81 Bool.pick(Rule, String.eq(pat, ".ez"), Dir{},
82 Bool.pick(Rule, String.eq(pat, ".ez/*"), Star{},
83 Bool.pick(Rule, String.eq(pat, ".ez/lib/*"), Pkgs{}, Other{})))
84
85# a trimmed line, read by whether it is an exception
86def loose.go(neg: Bool, +pat: String) -> Rule:
87 match neg:
88 case True{}:
89 loose.neg(bare(String.drop(pat, 1n)))
90 case False{}:
91 loose.pos(bare(pat))
92
93# a line that is not written the way `allow` writes one
94def loose(+line: String) -> Rule:
95 +pat = String.trim(line)
96 loose.go(String.starts_with(pat, "!"), pat)
97
98# `!.ez/lib/` with nothing after it is the directory, with a trailing slash
99def canon(empty: Bool, +rest: String) -> Rule:
100 match empty:
101 case True{}:
102 Lib{}
103 case False{}:
104 Allow{rest}
105
106# a line written the way `allow` writes one names the hash after the prefix
107def kind.go(exact: Bool, +line: String) -> Rule:
108 match exact:
109 case True{}:
110 canon(String.is_empty(String.drop(line, 9n)), String.drop(line, 9n))
111 case False{}:
112 loose(line)
113
114# what a line of an ignore file is to ez
115def kind(+line: String) -> Rule:
116 kind.go(String.starts_with(line, "!.ez/lib/"), line)
117
118# every line, with what it is
119def tag(ls: List<&2, String>) -> List<&2, Line>:
120 match ls:
121 case []:
122 []
123 case +h <> t:
124 Line{kind(h), h} <> tag(t)
125
126# the hash an allowlist line names, in front of the rest
127def hashes.put(what: Rule, rest: List<&2, String>) -> List<&2, String>:
128 match what:
129 case Allow{h}:
130 h <> rest
131 case Other{}:
132 rest
133 case Dir{}:
134 rest
135 case Star{}:
136 rest
137 case Lib{}:
138 rest
139 case Pkgs{}:
140 rest
141
142# every hash the allowlist lines of a file name, in order. This is what the
143# allowlist says, and what the laws hold against the ledger.
144def hashes(ls: List<&2, String>) -> List<&2, String>:
145 match ls:
146 case []:
147 []
148 case +h <> t:
149 hashes.put(kind(h), hashes(t))
150
151# a line that is not ez's, in front of the rest
152def others.put(what: Rule, +line: String, rest: List<&2, String>) -> List<&2, String>:
153 match what:
154 case Other{}:
155 line <> rest
156 case Allow{_h}:
157 rest
158 case Dir{}:
159 rest
160 case Star{}:
161 rest
162 case Lib{}:
163 rest
164 case Pkgs{}:
165 rest
166
167# every line that is not ez's, in order: the lines `sync` must not touch
168def others(ls: List<&2, String>) -> List<&2, String>:
169 match ls:
170 case []:
171 []
172 case +h <> t:
173 others.put(kind(h), h, others(t))
174
175# a dependency's hash, in front of the rest, when its tree is committed
176def vendors.put(vend: Bool, +hash: String, rest: List<&2, String>) -> List<&2, String>:
177 match vend:
178 case True{}:
179 hash <> rest
180 case False{}:
181 rest
182
183# the hash of every dependency marked `vendor = true`, in the order the ledger
184# wrote them. Two dependencies may name one hash.
185def vendors(ds: List<&2, M.Dep>) -> List<&2, String>:
186 match ds:
187 case []:
188 []
189 case +h <> t:
190 vendors.put(M.source.vendor(M.source.dep(h)), M.dep.hash(h), vendors(t))
191
192# whether a list of hashes already names a hash
193def named(hs: List<&2, String>, +hash: String) -> Bool:
194 match hs:
195 case []:
196 False{}
197 case h <> t:
198 +rest = named(t, hash)
199 Bool.or(String.eq(h, hash), rest)
200
201# a hash in front of the rest, unless it is passed over
202def once.put(skip: Bool, +hash: String, rest: List<&2, String>) -> List<&2, String>:
203 match skip:
204 case True{}:
205 rest
206 case False{}:
207 hash <> rest
208
209# the hashes seen so far, with this one when it was kept
210def once.seen(skip: Bool, +hash: String, +seen: List<&2, String>) -> List<&2, String>:
211 match skip:
212 case True{}:
213 seen
214 case False{}:
215 hash <> seen
216
217# each hash once, where it first appears, passing over any `seen` already
218# holds. An empty hash names no tree; `M.parse` refuses a dependency with
219# none, so that only guards a model built by hand.
220def once.go(hs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
221 match hs:
222 case []:
223 []
224 case +h <> t:
225 +skip = Bool.or(String.is_empty(h), named(seen, h))
226 once.put(skip, h, once.go(t, once.seen(skip, h, seen)))
227
228# the hashes the allowlist must name: every dependency marked `vendor = true`,
229# each hash once, in the order the ledger first names it. Two dependencies
230# that share a tree share its one line.
231def vended(ds: List<&2, M.Dep>) -> List<&2, String>:
232 once.go(vendors(ds), [])
233
234# one allowlist line per hash
235def allow.each(hs: List<&2, String>) -> List<&2, String>:
236 match hs:
237 case []:
238 []
239 case +h <> t:
240 allow(h) <> allow.each(t)
241
242# the allowlist lines of a ledger, in its order
243def allows(ds: List<&2, M.Dep>) -> List<&2, String>:
244 allow.each(vended(ds))
245
246# whether two kinds are the same rule. Two allowlist lines are never asked.
247def same(left: Rule, right: Rule) -> Bool:
248 match left right:
249 case Dir{} Dir{}:
250 True{}
251 case Star{} Star{}:
252 True{}
253 case Lib{} Lib{}:
254 True{}
255 case Pkgs{} Pkgs{}:
256 True{}
257 case _ _:
258 False{}
259
260# the rest of the scan only when this line was not it
261def has.step(hit: Bool, rest: Unit -> Bool) -> Bool:
262 match hit:
263 case True{}:
264 True{}
265 case False{}:
266 rest(Unit{})
267
268# whether a file already has a line of this kind
269def has(ls: List<&2, Line>, +want: Rule) -> Bool:
270 match ls:
271 case []:
272 False{}
273 case Line{+what, _text} <> t:
274 has.step(same(what, want), _u => has(t, want))
275
276# a rule, in front of the rest, when the file does not have it
277def rule.put(there: Bool, +line: String, rest: List<&2, String>) -> List<&2, String>:
278 match there:
279 case True{}:
280 rest
281 case False{}:
282 line <> rest
283
284# the three rules the allowlist needs, less the ones already there
285def rules.go(star: Bool, lib: Bool, pkgs: Bool) -> List<&2, String>:
286 rule.put(star, ".ez/*", rule.put(lib, "!.ez/lib", rule.put(pkgs, ".ez/lib/*", [])))
287
288# the rules a file lacks
289def rules(+ls: List<&2, Line>) -> List<&2, String>:
290 rules.go(has(ls, Star{}), has(ls, Lib{}), has(ls, Pkgs{}))
291
292# the rules go in front of the block only when nothing else puts them in: a
293# `.ez/` line is replaced by them where it stands, and a ledger that vendors
294# nothing needs none
295def need(skip: Bool, +rs: List<&2, String>) -> List<&2, String>:
296 match skip:
297 case True{}:
298 []
299 case False{}:
300 rs
301
302# the allowlist block: the missing rules, then one line per vendored hash
303def block(+ds: List<&2, M.Dep>, +ls: List<&2, Line>) -> List<&2, String>:
304 List.append(&2, String,
305 need(Bool.or(has(ls, Dir{}), List.is_empty(&2, String, vended(ds))), rules(ls)),
306 allows(ds))
307
308# the block, where it goes: at the first allowlist line, or at the end
309def placed(done: Bool, +blk: List<&2, String>) -> List<&2, String>:
310 Bool.pick(List<&2, String>, done, [], blk)
311
312# every line, walked once. `fix` replaces a `.ez/` line. `blk` replaces the
313# first allowlist line, every later one is dropped, and `done` says the
314# block is already down; a file with no allowlist line gets it at the end.
315# Every other line is kept as it was.
316def walk(ls: List<&2, Line>, +fix: List<&2, String>, +blk: List<&2, String>, done: Bool) -> List<&2, String>:
317 match ls:
318 case []:
319 placed(done, blk)
320 case Line{k, +line} <> t:
321 match k:
322 case Other{}:
323 line <> walk(t, fix, blk, done)
324 case Dir{}:
325 List.append(&2, String, fix, walk(t, fix, blk, done))
326 case Star{}:
327 line <> walk(t, fix, blk, done)
328 case Lib{}:
329 line <> walk(t, fix, blk, done)
330 case Pkgs{}:
331 line <> walk(t, fix, blk, done)
332 case Allow{_h}:
333 List.append(&2, String, placed(done, blk), walk(t, fix, blk, True{}))
334
335# the walk over a file's lines, each already read for what it is
336def lines.go(+ds: List<&2, M.Dep>, +ls: List<&2, Line>) -> List<&2, String>:
337 walk(ls, rules(ls), block(ds, ls), False{})
338
339# the lines of an ignore file with the allowlist the ledger's dependencies
340# call for: exactly the vendored hashes, and nothing else changed
341def lines(ds: List<&2, M.Dep>, ls: List<&2, String>) -> List<&2, String>:
342 lines.go(ds, tag(ls))
343
344# The text layer. A file is its lines, each ending with a newline, and `sync`
345# reads the file into lines, derives the allowlist over them, and writes them
346# back. A file of one blank line is one empty line and not an empty file, so a
347# blank line the project wrote is kept like any other line.
348
349# the last piece of a file cut at its newlines, dropped when it is empty: it
350# is what follows the newline the file ends with, and not a line a person
351# wrote
352def unend.go(rest: List<&2, String>, +last: String) -> List<&2, String>:
353 match rest:
354 case []:
355 Bool.pick(List<&2, String>, String.is_empty(last), [], [last])
356 case +h2 <> t2:
357 last <> unend.go(t2, h2)
358
359# the lines of a file cut at its newlines, less that last empty piece
360def unend(ps: List<&2, String>) -> List<&2, String>:
361 match ps:
362 case []:
363 []
364 case +h <> t:
365 unend.go(t, h)
366
367# the lines of a file. An empty file has none, a file of one newline has one
368# empty line, and a last line with no newline after it is a line all the same.
369def file.lines(text: String) -> List<&2, String>:
370 unend(String.lines(text))
371
372# lines written out as a file, each ending with a newline, which `file.lines`
373# reads back as those lines
374def file.text(ls: List<&2, String>) -> String:
375 match ls:
376 case []:
377 ""
378 case h <> t:
379 String.append(h, SCon{'\n', file.text(t)})
380
381# a file whose lines did not change comes back byte for byte, whatever it
382# ended with. One whose lines did is its new lines, each ending with a newline.
383def text.out(same: Bool, +text: String, +out: String) -> String:
384 match same:
385 case True{}:
386 text
387 case False{}:
388 out
389
390# the ignore file with the allowlist the ledger calls for. `ez add`,
391# `ez remove` and `ez lock --upgrade` write this when it differs from what
392# they read.
393def sync(ds: List<&2, M.Dep>, +text: String) -> String:
394 +was = file.lines(text)
395 +out = file.text(lines(ds, was))
396 text.out(String.eq(out, file.text(was)), text, out)