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)