src/pub/plan.bend
ezx@1.9.0.0 · 1,318 lines · 0 definitions
1# pub/plan: the planner for `ez publish`, pure. It reads a World
2# (pub/world.bend) and either asks the questions still open or makes the plan.
3# The package is sent to the hub by `bend --publish`, which is the only thing
4# that can send it, but the `0x` name ez reports is the one the package walk
5# (`pkg/pkg.bend`) computed from the files, never the one bend printed.
6# Reading bend's output is a check, not a lookup: ez already knows the answer
7# before bend runs, and what bend says is compared against it.
8#
9# Publishing is irreversible and public, so every check that can be made
10# before the upload is made first, in this order: there is a ledger that
11# parses, and it names the entry, and the name it publishes under, if any, is
12# one bend takes (EZ-PUB-3); git answers `git status --porcelain
13# --untracked-files=normal` about the project and names no path (EZ-PUB-1);
14# the walk from the entry makes a package; git tracks every file of that
15# package; and the hub the ledger names has every hub package the package
16# imports (EZ-PUB-5). Only then is the upload asked for, and its answer is checked
17# against ez's hash (EZ-PUB-2). bend cannot report a package's hash without
18# uploading it, so that check comes after the upload, and a disagreement
19# exits 1 with no import line rather than taking the upload back.
20#
21# Each stage of `decide` reads what it needs from the World itself rather
22# than from the stage before, so a law about one stage holds of every World
23# whatever the others say.
24import Base
25import ezx@1.9.0.0/src/lock/plan.bend as P
26import ezx@1.9.0.0/src/tool/world.bend as TW
27import ezx@1.9.0.0/src/ledger/manifest.bend as M
28import ezx@1.9.0.0/src/pkg/pkg.bend as K
29import ezx@1.9.0.0/src/pkg/path.bend as Path
30import ezx@1.9.0.0/src/git/git.bend as G
31import ezx@1.9.0.0/src/pub/world.bend as PW
32import ezx@1.9.0.0/src/pub/blurb.bend as B
33import ezx@1.9.0.0/src/lock/lock.bend as L
34import ezx@1.9.0.0/src/share/sha.bend as Sha
35import ezx@1.9.0.0/src/io/file.bend as F
36
37# ---------------------------------------------------------------------------
38# bend's answer
39
40# how many characters a `0x` name is written with: the two of the prefix and
41# the thirty-two `pkg/pkg.bend`'s `hash_of` takes off a sha256
42def name.length() -> Nat:
43 34n
44
45# whether a line is a `0x` package name and nothing else. Anchored at both
46# ends: the prefix, the exact length, and every character after the prefix one
47# a lowercase hex digest may hold. `git/git.bend` already walks a hex string
48# for `is_rev`, and its walk stops at the first character that is not hex.
49#
50# The lines bend prints around its answer fail this, and `pub/LAWS.bend` says
51# so for the two that hold the name, rather than leaving it to a reading of
52# bend's source: `publishing N files, ... as 0x... (mining its proof of work)`
53# and `import 0x.../entry.bend as Name` each start with something that is not
54# `0x`. The terms notice and the license line bend 2.0.27 prints first
55# (`Publishing to BendHub: ...`, `License: ...`) start with a word too.
56def is_name(+word: String) -> Bool:
57 Bool.and(String.starts_with(word, "0x"),
58 Bool.and(Nat.is_eq(String.length(word), name.length()),
59 G.hex.all(String.to_list(String.drop(word, 2n)))))
60
61# one line of bend's output weighed. A line that is a name must be ours, and
62# makes the rest of the walk one that has seen a name; any other line is
63# passed over. `Bool.and` builds both of its sides, so the rest of the walk
64# arrives as a function of whether a name was seen and is applied once.
65def agrees.step(name: Bool, same: Bool, seen: Bool, rest: Bool -> Bool) -> Bool:
66 match name:
67 case True{}:
68 Bool.and(same, rest(True{}))
69 case False{}:
70 rest(seen)
71
72# whether bend's output, from this line on, agrees with ez's hash, `seen`
73# saying whether a line before it was a name
74def agrees.go(+ours: String, ls: List<&2, String>, seen: Bool) -> Bool:
75 match ls:
76 case []:
77 seen
78 case +h <> t:
79 agrees.step(is_name(h), String.eq(ours, h), seen,
80 s => agrees.go(ours, t, s))
81
82# whether bend's output agrees with ez's hash: at least one line is a `0x`
83# name, and every line that is one is ez's hash. There is no second pattern
84# behind this one. An output with no bare name answers nothing, and one that
85# names two packages names one that is not ours, whichever it names first.
86def agrees(+ours: String, ls: List<&2, String>) -> Bool:
87 agrees.go(ours, ls, False{})
88
89# the first line that is a `0x` name and not ours, "" when there is none
90def other.step(odd: Bool, line: String, rest: Unit -> String) -> String:
91 match odd:
92 case True{}:
93 line
94 case False{}:
95 rest(Unit{})
96
97# the walk that finds it, one line at a time
98def other(+ours: String, ls: List<&2, String>) -> String:
99 match ls:
100 case []:
101 ""
102 case +h <> t:
103 other.step(Bool.and(is_name(h), Bool.not(String.eq(ours, h))), h,
104 _u => other(ours, t))
105
106# ---------------------------------------------------------------------------
107# the working tree
108
109# one line of `git status --porcelain` weighed, with the rest of the walk
110# behind a thunk so a status that names a hundred files stops at the first.
111# Every line git prints there names a path whose state on disk is not the
112# state the commit holds, so every one of them counts, whatever its two-letter
113# code says, `??` for an untracked file included.
114def dirty.step(blank: Bool, rest: Unit -> Bool) -> Bool:
115 match blank:
116 case True{}:
117 rest(Unit{})
118 case False{}:
119 True{}
120
121# whether any line of the status says anything. The only empty line git status
122# ever produces is the one `String.lines` makes of the trailing newline, so an
123# empty line is the absence of a change rather than a change with no name.
124def dirty.lines(ls: List<&2, String>) -> Bool:
125 match ls:
126 case []:
127 False{}
128 case +h <> t:
129 dirty.step(String.is_empty(h), _u => dirty.lines(t))
130
131# one line of the other walk, the same way round
132def blanks.step(blank: Bool, rest: Unit -> Bool) -> Bool:
133 match blank:
134 case True{}:
135 rest(Unit{})
136 case False{}:
137 False{}
138
139# whether the status named nothing. Stated as its own walk rather than as the
140# negation of the one above, so `pub/LAWS.bend` can hold the two to each other:
141# a tree is clean exactly when git named no path at all.
142def blanks(ls: List<&2, String>) -> Bool:
143 match ls:
144 case []:
145 True{}
146 case +h <> t:
147 blanks.step(String.is_empty(h), _u => blanks(t))
148
149# whether `git status --porcelain` named a path. Ignored files are not named,
150# and that is the project's call, written in its own `.gitignore`: `.ez/` and
151# `bin/` hold build output that no commit should. What the package itself
152# holds is checked apart from this (`stray` below), so an ignored file the
153# entry imports is refused there.
154def dirty(text: String) -> Bool:
155 dirty.lines(String.lines(text))
156
157# ---------------------------------------------------------------------------
158# the name a package is published under
159
160# one character of a version: a digit or a dot
161def semver.char(+ch: Char) -> Bool:
162 Bool.or(Char.is_digit(ch), Char.is_eq(ch, '.'))
163
164# whether every character of a version is a digit or a dot. A pre-release
165# (`1.0.0-rc.1`) or build (`1.0.0+abc`) suffix holds a character that is
166# neither, so this is where one is refused, whatever follows it.
167def semver.chars(text: String) -> Bool:
168 match text:
169 case SNil{}:
170 True{}
171 case SCon{+h, t}:
172 +rest = semver.chars(t)
173 Bool.and(semver.char(h), rest)
174
175# whether a version is MAJOR.MINOR.PATCH: digits and dots only, and three
176# numbers, none with a leading zero (`K.ver.num`, the rule bend's NAMED
177# holds each of its four numbers to)
178def semver(+text: String) -> Bool:
179 +ps = String.split(text, '.')
180 Bool.and(semver.chars(text),
181 Bool.and(Nat.is_eq(List.length(&2, String, ps), 3n), K.ver.nums(ps)))
182
183# how the ledger names the package on the hub: not at all, so it is
184# published by its hash alone; by `<name>@<version>`, as bend's `--publish`
185# takes it; or in a way ez refuses, and why
186type Named is Data:
187 ByHash{}
188 ByName{nv: String}
189 Unnamed{why: String}
190
191# why a `publish-as` with no `version` is refused
192def why.nover(+name: String) -> String:
193 "ez: ez.toml's [package] has publish-as = \"" ++ name ++ "\" and no version; " ++
194 "a named publish needs both, and one with neither publishes by hash"
195
196# why a `version` with no `publish-as` is refused
197def why.noname(+ver: String) -> String:
198 "ez: ez.toml's [package] has version = \"" ++ ver ++ "\" and no publish-as; " ++
199 "a named publish needs both, and one with neither publishes by hash"
200
201# why a `publish-as` the hub would not take is refused
202def why.name(+name: String) -> String:
203 "ez: publish-as = \"" ++ name ++ "\" is not a hub name: a lowercase letter, " ++
204 "then a-z, 0-9 and -, 1 to 64 characters in all"
205
206# why a `version` that is not MAJOR.MINOR.PATCH is refused
207def why.ver(+ver: String) -> String:
208 "ez: version = \"" ++ ver ++ "\" is not MAJOR.MINOR.PATCH: three numbers " ++
209 "with no leading zeros and no pre-release or build suffix, which ez " ++
210 "publishes as bend's four-part MAJOR.MINOR.PATCH.0"
211
212# both keys given: the name first, then the version, then the name bend is
213# asked for, the version with bend's fourth number, `0`, after it
214def naming.both(name: Bool, ver: Bool, +pa: String, +pv: String) -> Named:
215 match name:
216 case False{}:
217 Unnamed{why.name(pa)}
218 case True{}:
219 match ver:
220 case False{}:
221 Unnamed{why.ver(pv)}
222 case True{}:
223 ByName{pa ++ "@" ++ pv ++ ".0"}
224
225# which of the two keys the ledger gives. Neither is a publish by hash, as
226# before the keys existed; one without the other is refused rather than
227# guessed at, as `cargo publish` refuses a package with no version.
228def naming.go(noname: Bool, nover: Bool, +pa: String, +pv: String) -> Named:
229 match noname:
230 case True{}:
231 match nover:
232 case True{}:
233 ByHash{}
234 case False{}:
235 Unnamed{why.noname(pv)}
236 case False{}:
237 match nover:
238 case True{}:
239 Unnamed{why.nover(pa)}
240 case False{}:
241 naming.both(K.name.part(pa), semver(pv), pa, pv)
242
243# how `publish-as` and `version` name the package
244def naming(+pa: String, +pv: String) -> Named:
245 naming.go(String.is_empty(pa), String.is_empty(pv), pa, pv)
246
247# ---------------------------------------------------------------------------
248# the ledger
249
250# what the ledger says: the entry it names and how the package is named on
251# the hub, by its hash or by a `<name>@<version>`, or why there is none
252type Led is Data:
253 Led{entry: String, named: Named}
254 LedNo{why: String}
255
256# the entry, with the name the package goes under, or why it goes under none
257def led.named(+entry: String, got: Named) -> Led:
258 match got:
259 case ByHash{}:
260 Led{entry, ByHash{}}
261 case ByName{nv}:
262 Led{entry, ByName{nv}}
263 case Unnamed{why}:
264 LedNo{why}
265
266# the entry a parsed ledger names, `main.bend` when it names none, and the
267# name its `publish-as` and `version` give it
268def led.read(ledger: M.Read) -> Led:
269 match ledger:
270 case M.Bad{why}:
271 LedNo{"ez: " ++ M.show(M.Bad{why})}
272 case M.Good{m}:
273 M.Manifest{_n, +e, _b, _h, +pa, +pv, _ds, _ts} = m
274 led.named(Bool.pick(String, String.is_empty(e), "main.bend", e), naming(pa, pv))
275
276# the ledger read. With no ez.toml there is no package, and nothing is
277# guessed in its place (`cargo publish` refuses a directory with no
278# Cargo.toml the same way).
279def led.of(got: Maybe<&2, String>) -> Led:
280 match got:
281 case None{}:
282 LedNo{"ez: no " ++ PW.toml() ++ " here; ez publish sends the package " ++
283 "a project's ledger names, and ez init makes one"}
284 case Some{text}:
285 led.read(M.parse(text))
286
287# what the World's ledger says
288def led(world: PW.World) -> Led:
289 led.of(PW.ledger(world))
290
291# the entry, as the walk takes it, "" when the ledger names none
292def entry.of(ledger: Led) -> String:
293 match ledger:
294 case Led{e, _nv}:
295 Path.norm(e)
296 case LedNo{_why}:
297 ""
298
299# how the package goes on the hub; a ledger that names no entry sends
300# nothing, so it is taken for one that goes by hash
301def named.of(ledger: Led) -> Named:
302 match ledger:
303 case Led{_e, got}:
304 got
305 case LedNo{_why}:
306 ByHash{}
307
308# how a World's package goes on the hub
309def named(world: PW.World) -> Named:
310 named.of(led(world))
311
312# the `<name>@<version>` the upload names the package by, "" for none
313def named.word(got: Named) -> String:
314 match got:
315 case ByName{nv}:
316 nv
317 case ByHash{}:
318 ""
319 case Unnamed{_why}:
320 ""
321
322# the entry of a World
323def entry(world: PW.World) -> String:
324 entry.of(led(world))
325
326# ---------------------------------------------------------------------------
327# git status
328
329# what `git status` said of the project, still to ask, or why it said
330# nothing
331type Status is Data:
332 Said{text: String}
333 StatusWait{}
334 StatusNo{why: String}
335
336# the question: what git says of the directory the project is in
337def locate(world: PW.World) -> PW.Ask:
338 PW.Tool{TW.Locate{PW.here(world)}}
339
340# a status that git answered, or the command stopped with what git said. A
341# directory that is not in a git repository has no commit for what goes to
342# the hub to exist in, so it is refused rather than taken for clean.
343def status.ran(ok: Bool, +text: String) -> Status:
344 match ok:
345 case True{}:
346 Said{text}
347 case False{}:
348 StatusNo{"ez: git status: " ++ text}
349
350def status.told(got: TW.Answer) -> Status:
351 match got:
352 case TW.Located{_abs, _top, _head, ok, text}:
353 status.ran(ok, text)
354 case TW.Miss{why}:
355 StatusNo{why}
356 case _:
357 StatusNo{"ez: git was not asked about the project"}
358
359def status.answer(got: PW.Answer) -> Status:
360 match got:
361 case PW.Told{t}:
362 status.told(t)
363 case PW.Ran{_ok, _text}:
364 StatusNo{"ez: git was not asked about the project"}
365
366def status.of(got: PW.Heard) -> Status:
367 match got:
368 case PW.Open{}:
369 StatusWait{}
370 case PW.Heard{a}:
371 status.answer(a)
372
373# what git status said of the project
374def status(+world: PW.World) -> Status:
375 status.of(PW.heard(world, locate(world)))
376
377# ---------------------------------------------------------------------------
378# the package
379
380# a file the walk asked for, added to what it is given: its text, or gone
381# when there is none
382def tree.told(got: TW.Answer, +path: String, rest: K.Tree) -> K.Tree:
383 match got:
384 case TW.Plain{m}:
385 K.tree.add(m, rest, path)
386 case _:
387 K.tree.gone(rest, path)
388
389def tree.read(got: PW.Answer, +path: String, rest: K.Tree) -> K.Tree:
390 match got:
391 case PW.Told{t}:
392 tree.told(t, path, rest)
393 case PW.Ran{_ok, _text}:
394 K.tree.gone(rest, path)
395
396def tree.tool(ask: TW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree:
397 match ask:
398 case TW.Read{path}:
399 tree.read(got, path, rest)
400 case _:
401 rest
402
403def tree.one(ask: PW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree:
404 match ask:
405 case PW.Tool{q}:
406 tree.tool(q, got, rest)
407 case _:
408 rest
409
410# every file read so far, as the walk is given them: not a whole checkout, so
411# a file not read yet is a question
412def tree.of(rs: List<&2, PW.Reply>) -> K.Tree:
413 match rs:
414 case []:
415 K.Tree{False{}, [], []}
416 case PW.Reply{ask, got} <> t:
417 tree.one(ask, got, tree.of(t))
418
419# the files the walk has been given
420def tree(world: PW.World) -> K.Tree:
421 tree.of(PW.replies(world))
422
423# the walk's fuel: one step for the entry and one per line of the files it
424# was given, since every import it can queue is a line of one of them
425def fuel(fs: List<&2, K.Source>) -> Nat:
426 match fs:
427 case []:
428 1n
429 case K.Source{_at, text} <> t:
430 (List.length(&2, String, String.lines(text)) + fuel(t) : Nat)
431
432def fuel.of(tr: K.Tree) -> Nat:
433 K.Tree{_whole, fs, _gone} = tr
434 fuel(fs)
435
436# the package the entry is published as, over the files read so far
437def walked(+world: PW.World) -> K.Walked:
438 K.of.tree(fuel.of(tree(world)), entry(world), tree(world))
439
440# the package's hash, "" while there is none
441def hash.of(got: K.Walked) -> String:
442 match got:
443 case K.Walked{h, _root, _fs, _sums}:
444 h
445 case K.Asks{_at}:
446 ""
447 case K.Refused{_why}:
448 ""
449
450# the hash ez computed for the package
451def hash(+world: PW.World) -> String:
452 hash.of(walked(world))
453
454# the directory the package's paths are written from, "." for the project's
455def root.of(got: K.Walked) -> String:
456 match got:
457 case K.Walked{_h, root, _fs, _sums}:
458 root
459 case K.Asks{_at}:
460 "."
461 case K.Refused{_why}:
462 "."
463
464# the entry's path inside the package, which is where the hub serves it: the
465# entry's name, under the directories the package re-rooted above it when an
466# import climbs out of the entry's directory
467def at(+world: PW.World) -> String:
468 K.inside(root.of(walked(world)), entry(world))
469
470# the files of a package, as paths of the project
471def paths(+root: String, fs: List<&2, K.Source>) -> List<&2, String>:
472 match fs:
473 case []:
474 []
475 case K.Source{at, _text} <> t:
476 Path.join(root, at) <> paths(root, t)
477
478# ---------------------------------------------------------------------------
479# what git tracks
480
481# whether `git ls-files -v` names a path as tracked in the ordinary way. `H`
482# is a file git holds and watches; a file marked `--assume-unchanged` (`h`) or
483# `--skip-worktree` (`S`) is one whose changes `git status` does not report,
484# so its text on disk need not be the commit's, and it counts as untracked.
485def tracked.step(hit: Bool, rest: Unit -> Bool) -> Bool:
486 match hit:
487 case True{}:
488 True{}
489 case False{}:
490 rest(Unit{})
491
492def tracked.has(ls: List<&2, String>, +path: String) -> Bool:
493 match ls:
494 case []:
495 False{}
496 case +h <> t:
497 tracked.step(String.eq(h, "H " ++ path), _u => tracked.has(t, path))
498
499# the first path of the package that git does not track, "" when every one
500# is tracked
501def stray.step(kept: Bool, +path: String, rest: Unit -> String) -> String:
502 match kept:
503 case True{}:
504 rest(Unit{})
505 case False{}:
506 path
507
508def stray.first(+ls: List<&2, String>, ps: List<&2, String>) -> String:
509 match ps:
510 case []:
511 ""
512 case +h <> t:
513 stray.step(tracked.has(ls, h), h, _u => stray.first(ls, t))
514
515# what git said of the package's files: every one tracked, the first one it
516# does not track, still to ask, or why it said nothing
517type Kept is Data:
518 Kept{}
519 Stray{path: String}
520 KeptWait{}
521 KeptNo{why: String}
522
523def kept.of(+first: String) -> Kept:
524 Bool.pick(Kept, String.is_empty(first), Kept{}, Stray{first})
525
526def kept.ran(ok: Bool, +text: String, +ps: List<&2, String>) -> Kept:
527 match ok:
528 case True{}:
529 kept.of(stray.first(String.lines(text), ps))
530 case False{}:
531 KeptNo{"ez: git ls-files: " ++ text}
532
533def kept.answer(got: PW.Answer, +ps: List<&2, String>) -> Kept:
534 match got:
535 case PW.Ran{ok, text}:
536 kept.ran(ok, text, ps)
537 case PW.Told{_t}:
538 KeptNo{"ez: git was not asked what it tracks"}
539
540def kept.heard(got: PW.Heard, +ps: List<&2, String>) -> Kept:
541 match got:
542 case PW.Open{}:
543 KeptWait{}
544 case PW.Heard{a}:
545 kept.answer(a, ps)
546
547def kept.walked(got: K.Walked, +world: PW.World) -> Kept:
548 match got:
549 case K.Walked{_h, +root, fs, _sums}:
550 kept.heard(PW.heard(world, PW.Tracked{}), paths(root, fs))
551 case K.Asks{_at}:
552 KeptNo{"ez: the package was not walked"}
553 case K.Refused{why}:
554 KeptNo{why}
555
556# whether git tracks every file of the package. An ignored file the entry
557# imports is one `git status` never names, and one the upload would send
558# though no commit holds it, so it is refused here.
559def stray(+world: PW.World) -> Kept:
560 kept.walked(walked(world), world)
561
562# ---------------------------------------------------------------------------
563# the upload
564
565# the words the upload runs: the entry, and after `--publish` the
566# `<name>@<version>` the package goes under when it has one, which is how
567# bend 2.0.27 publishes and names a package in one run (`bend <file>
568# --publish <name>@<version>`). With no name it is the upload by hash alone.
569def upload.go(bare: Bool, +entry: String, +named: String) -> List<&2, String>:
570 match bare:
571 case True{}:
572 ["bend", entry, "--publish"]
573 case False{}:
574 ["bend", entry, "--publish", named]
575
576def upload.line(+entry: String, +named: String) -> List<&2, String>:
577 upload.go(String.is_empty(named), entry, named)
578
579# the files of a package, with their texts, at the paths the hub holds them
580# at; none while there is no package
581def files.of(got: K.Walked) -> List<&2, K.Source>:
582 match got:
583 case K.Walked{_h, _root, fs, _sums}:
584 fs
585 case K.Asks{_at}:
586 []
587 case K.Refused{_why}:
588 []
589
590# what is said just before the upload: the line the hub will describe the
591# package by (`B.blurb`). A package whose first file opens with `import Base`
592# would be listed that way for good, so the author sees the line while the
593# upload can still be stopped.
594def said(+world: PW.World) -> String:
595 "hub description: " ++ B.blurb(files.of(walked(world)))
596
597# the question: the entry sent, under the name the ledger gives it, with the
598# line said before it
599def upload(+world: PW.World) -> PW.Ask:
600 PW.Upload{entry(world), named.word(named(world)), said(world)}
601
602# the upload's answer, looked up
603def uploaded(+world: PW.World) -> PW.Heard:
604 PW.heard(world, upload(world))
605
606# whether an upload's answer agrees with ez's hash: bend exited 0, and its
607# output agrees (`agrees`)
608def agreed.answer(got: PW.Answer, +ours: String) -> Bool:
609 match got:
610 case PW.Ran{ok, text}:
611 Bool.and(ok, agrees(ours, String.lines(text)))
612 case PW.Told{_t}:
613 False{}
614
615def agreed.of(got: PW.Heard, +ours: String) -> Bool:
616 match got:
617 case PW.Open{}:
618 False{}
619 case PW.Heard{a}:
620 agreed.answer(a, ours)
621
622# whether the World's upload agrees with the hash ez computed
623def agreed(+world: PW.World) -> Bool:
624 agreed.of(uploaded(world), hash(world))
625
626# why an upload that bend finished does not agree: it printed no line that
627# is a name, or a name that is not ours. Both numbers are named, since the
628# next question is which walk drifted, and neither is reported as the
629# package's name.
630def why.read(+ours: String, +text: String) -> String:
631 +theirs = other(ours, String.lines(text))
632 Bool.pick(String, String.is_empty(theirs),
633 "ez: bend printed no line that is a 0x name, so there is nothing to " ++
634 "check ez's hash against. bend said:\n" ++ text,
635 "ez: ez computed " ++ ours ++ " and bend published " ++ theirs ++
636 ". One of the two package walks has drifted from the other, so " ++
637 "neither name is safe to write down and ez has reported none.")
638
639# why an upload refuses, `words` being the line it ran. bend has sent the
640# package by the time it answers, since it cannot report the hash without
641# sending it, so this is said after the fact.
642def why.ran(ok: Bool, +words: String, +ours: String, +text: String) -> String:
643 match ok:
644 case False{}:
645 "ez: " ++ words ++ ": " ++ text
646 case True{}:
647 why.read(ours, text)
648
649def why.answer(got: PW.Answer, +words: String, +ours: String) -> String:
650 match got:
651 case PW.Ran{ok, text}:
652 why.ran(ok, words, ours, text)
653 case PW.Told{_t}:
654 "ez: bend was not asked to publish"
655
656def why.of(got: PW.Heard, +words: String, +ours: String) -> String:
657 match got:
658 case PW.Open{}:
659 "ez: bend was not asked to publish"
660 case PW.Heard{a}:
661 why.answer(a, words, ours)
662
663# why the World's upload refuses
664def why.upload(+world: PW.World) -> String:
665 why.of(uploaded(world),
666 String.join(upload.line(entry(world), named.word(named(world))), " "),
667 hash(world))
668
669# ---------------------------------------------------------------------------
670# the hub packages the package imports
671
672# A package on the hub is built from the hub: whoever imports it has bend
673# fetch every hub package it imports in turn. So a package that imports one
674# the hub does not have is one no one can build, and `ez publish` asks the
675# hub about each before it sends anything, as `cargo publish` refuses a
676# dependency that is not on crates.io (EZ-PUB-5). It asks the hub the ledger
677# names, the one `ez lock` resolved the imports against.
678
679# a hub package the package imports, as the hub is asked about it: by its
680# `0x` name, whose manifest the hub serves at `<hub>/<hash>/manifest`; or by
681# its `<name>@<version>`, which the hub resolves at `<hub>/name/<nv>`, with
682# the hash ez.lock.toml or ez.toml resolved the name to, "" when neither
683# records one
684type Probe is Data:
685 ProbeHash{hash: String}
686 ProbeName{nv: String, hash: String}
687
688# the texts of the package's files
689def texts(fs: List<&2, K.Source>) -> List<&2, String>:
690 match fs:
691 case []:
692 []
693 case K.Source{_at, text} <> t:
694 text <> texts(t)
695
696# a string kept unless it was seen before
697def once.pick(dup: Bool, +word: String, rest: List<&2, String>) -> List<&2, String>:
698 match dup:
699 case True{}:
700 rest
701 case False{}:
702 word <> rest
703
704# each string of a list once, where it first appears
705def once.go(xs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
706 match xs:
707 case []:
708 []
709 case +h <> t:
710 once.pick(L.seen.holds(seen, h), h, once.go(t, h <> seen))
711
712# each string of a list once
713def once(xs: List<&2, String>) -> List<&2, String>:
714 once.go(xs, [])
715
716# the texts of the package the walk made, none while there is none
717def sources(+world: PW.World) -> List<&2, String>:
718 texts(files.of(walked(world)))
719
720# every `0x` name the package's files import, once each
721def hashes(+world: PW.World) -> List<&2, String>:
722 once(L.kids(sources(world)))
723
724# every `<name>@<version>` they import, once each
725def nvs(+world: PW.World) -> List<&2, String>:
726 once(L.kids.named(sources(world)))
727
728# whether a list holds anything
729def any(xs: List<&2, String>) -> Bool:
730 match xs:
731 case []:
732 False{}
733 case _h <> _t:
734 True{}
735
736# the ledger read, a bad one when there is none; the planner reaches this
737# stage only past a ledger that parsed
738def parsed.of(got: Maybe<&2, String>) -> M.Read:
739 match got:
740 case None{}:
741 M.Bad{"no " ++ PW.toml()}
742 case Some{text}:
743 M.parse(text)
744
745# the World's ledger, read
746def parsed(world: PW.World) -> M.Read:
747 parsed.of(PW.ledger(world))
748
749# the hub the ledger names, bend's own when it names none
750def hub(world: PW.World) -> String:
751 M.hub_of(parsed(world))
752
753# the dependencies the ledger records
754def deps.of(got: M.Read) -> List<&2, M.Dep>:
755 match got:
756 case M.Bad{_why}:
757 []
758 case M.Good{m}:
759 M.Manifest{_n, _e, _b, _h, _pa, _pv, ds, _ts} = m
760 ds
761
762# the dependencies the World's ledger records
763def deps(world: PW.World) -> List<&2, M.Dep>:
764 deps.of(parsed(world))
765
766# the lock's text, "" when there is none or it was not read
767def lock.told(got: TW.Answer) -> String:
768 match got:
769 case TW.Plain{m}:
770 F.text_of(m)
771 case _:
772 ""
773
774def lock.answer(got: PW.Answer) -> String:
775 match got:
776 case PW.Told{t}:
777 lock.told(t)
778 case PW.Ran{_ok, _text}:
779 ""
780
781def lock.of(got: PW.Heard) -> String:
782 match got:
783 case PW.Open{}:
784 ""
785 case PW.Heard{a}:
786 lock.answer(a)
787
788# ez.lock.toml as the planner was given it
789def lock(+world: PW.World) -> String:
790 lock.of(PW.heard(world, PW.Lock{}))
791
792# every name the lock resolves, then every one the ledger records with the
793# hash it named when it was added
794def names(+world: PW.World) -> List<&2, L.Name>:
795 List.append(&2, L.Name, L.names.read(lock(world)), L.ledger.names(parsed(world)))
796
797# each `0x` name as the hub is asked about it
798def probes.hash(hs: List<&2, String>) -> List<&2, Probe>:
799 match hs:
800 case []:
801 []
802 case h <> t:
803 ProbeHash{h} <> probes.hash(t)
804
805# each name, with the hash the lock or the ledger resolves it to
806def probes.name(+ns: List<&2, L.Name>, vs: List<&2, String>) -> List<&2, Probe>:
807 match vs:
808 case []:
809 []
810 case +h <> t:
811 ProbeName{h, L.name.find(ns, h)} <> probes.name(ns, t)
812
813# every hub package the package imports, as the hub is asked about it
814def probes(+world: PW.World) -> List<&2, Probe>:
815 List.append(&2, Probe, probes.hash(hashes(world)), probes.name(names(world), nvs(world)))
816
817# the url the hub is asked
818def probe.url(+hub: String, one: Probe) -> String:
819 match one:
820 case ProbeHash{h}:
821 L.url_of(hub, h, "manifest")
822 case ProbeName{nv, _h}:
823 hub ++ "/name/" ++ nv
824
825# the question: a GET of that url
826def probe.ask(+hub: String, one: Probe) -> PW.Ask:
827 PW.Hub{probe.url(hub, one)}
828
829# the questions for a list of them
830def probe.asks(+hub: String, ps: List<&2, Probe>) -> List<&2, PW.Ask>:
831 match ps:
832 case []:
833 []
834 case h <> t:
835 probe.ask(hub, h) <> probe.asks(hub, t)
836
837# the import, as the package's files write it
838def probe.word(one: Probe) -> String:
839 match one:
840 case ProbeHash{h}:
841 h
842 case ProbeName{nv, _h}:
843 nv
844
845# where the ledger says a dependency comes from
846def origin.say(src: M.Source) -> String:
847 match src:
848 case M.Hub{_named}:
849 "hub"
850 case M.Git{url, _rev, _tag, _root, _nar, _vend}:
851 "git " ++ url
852
853# a dependency as a refusal names it: its key and its origin
854def dep.say(item: M.Dep) -> String:
855 M.Dep{n, _h, _e, src} = item
856 n ++ ", " ++ origin.say(src)
857
858# whether the ledger's dependency is the one a probe is about: the same hash,
859# or for a name, the same `<name>@<version>` or the hash it resolved to
860def dep.is.go(one: Probe, +hash: String, src: M.Source) -> Bool:
861 match one:
862 case ProbeHash{ph}:
863 String.eq(hash, ph)
864 case ProbeName{nv, +ph}:
865 Bool.or(String.eq(M.source.nv(src), nv),
866 Bool.and(Bool.not(String.is_empty(ph)), String.eq(hash, ph)))
867
868def dep.is(one: Probe, item: M.Dep) -> Bool:
869 M.Dep{_n, h, _e, src} = item
870 dep.is.go(one, h, src)
871
872def dep.find.pick(hit: Bool, item: M.Dep, rest: Unit -> String) -> String:
873 match hit:
874 case True{}:
875 dep.say(item)
876 case False{}:
877 rest(Unit{})
878
879# the first dependency the ledger records for a probe, said, "" for none
880def dep.find(+one: Probe, ds: List<&2, M.Dep>) -> String:
881 match ds:
882 case []:
883 ""
884 case +h <> t:
885 dep.find.pick(dep.is(one, h), h, _u => dep.find(one, t))
886
887# the import a refusal names, with the dependency key and origin the ledger
888# records for it when it records one
889def who.of(+word: String, +dep: String) -> String:
890 Bool.pick(String, String.is_empty(dep), word, word ++ " (" ++ dep ++ ")")
891
892# a probe as a refusal names it
893def who(+one: Probe, ds: List<&2, M.Dep>) -> String:
894 who.of(probe.word(one), dep.find(one, ds))
895
896# where one probe stands: on the hub, not asked yet, or off it, with the line
897# a refusal says of it
898type Seen is Data:
899 There{}
900 Unasked{}
901 Off{line: String}
902
903# what the hub answers for a url it has nothing at: status 404, as
904# `Web.get` says it
905def absent(+text: String) -> Bool:
906 String.eq(text, "the server answered 404")
907
908# the line for a package the hub does not have
909def why.absent(+who: String) -> String:
910 " " ++ who ++ " is not on the hub; publish it first"
911
912# the line for one the hub could not be asked about
913def why.unasked(+who: String, +text: String) -> String:
914 " " ++ who ++ ": the hub could not be asked: " ++ text
915
916# a GET that did not come back 200: the hub does not have it, or it could not
917# be asked. Neither is the hub having it, so both refuse, and nothing is sent
918# on a guess.
919def seen.miss(gone: Bool, +text: String, +who: String) -> Seen:
920 match gone:
921 case True{}:
922 Off{why.absent(who)}
923 case False{}:
924 Off{why.unasked(who, text)}
925
926# a manifest the hub served for a `0x` name: the package is there when the
927# manifest hashes to the name, as `ez lock` checks it
928def seen.hash(ok: Bool, +hash: String, +text: String, +who: String) -> Seen:
929 match ok:
930 case True{}:
931 Bool.pick(Seen, String.starts_with(Sha.hex(text), L.want(hash)), There{},
932 Off{" " ++ who ++ ": the hub served a manifest that does not hash to it"})
933 case False{}:
934 seen.miss(absent(text), text, who)
935
936# why a name the hub answered is not the package imported
937def why.named(+got: String, +want: String, +who: String) -> String:
938 Bool.pick(String, String.is_empty(want),
939 " " ++ who ++ ": the hub answered `" ++ got ++ "`, which is not a 0x name",
940 " " ++ who ++ ": the hub names `" ++ got ++ "` by it, and the lock " ++
941 "resolved it to " ++ want)
942
943# a name the hub resolved: the package is there when the hub names a `0x`
944# name by it, and the one the lock resolved it to when the lock resolved it
945def seen.named.go(good: Bool, +got: String, +want: String, +who: String) -> Seen:
946 match good:
947 case True{}:
948 There{}
949 case False{}:
950 Off{why.named(got, want, who)}
951
952def seen.named(+got: String, +want: String, +who: String) -> Seen:
953 seen.named.go(Bool.and(Bool.or(String.eq(got, want), String.is_empty(want)), L.hash.ok(got)),
954 got, want, who)
955
956# a name asked of the hub, answered
957def seen.name(ok: Bool, +want: String, +text: String, +who: String) -> Seen:
958 match ok:
959 case True{}:
960 seen.named(String.trim(text), want, who)
961 case False{}:
962 seen.miss(absent(text), text, who)
963
964# a probe's GET answered: whether it came back 200, and what it said
965def seen.ran(one: Probe, ok: Bool, +text: String, +who: String) -> Seen:
966 match one:
967 case ProbeHash{h}:
968 seen.hash(ok, h, text, who)
969 case ProbeName{_nv, h}:
970 seen.name(ok, h, text, who)
971
972def seen.answer(one: Probe, got: PW.Answer, +who: String) -> Seen:
973 match got:
974 case PW.Ran{ok, text}:
975 seen.ran(one, ok, text, who)
976 case PW.Told{_t}:
977 Off{" " ++ who ++ ": the hub was not asked"}
978
979def seen.heard(got: PW.Heard, one: Probe, +who: String) -> Seen:
980 match got:
981 case PW.Open{}:
982 Unasked{}
983 case PW.Heard{a}:
984 seen.answer(one, a, who)
985
986# where a probe stands in a World
987def seen(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, +one: Probe) -> Seen:
988 seen.heard(PW.heard(world, probe.ask(hub, one)), one, who(one, ds))
989
990# the probes not asked yet, and the lines for the ones off the hub
991type Tally is Data:
992 Tally{open: List<&2, Probe>, offs: List<&2, String>}
993
994# a probe still to ask about, added
995def tally.unasked(one: Probe, sofar: Tally) -> Tally:
996 Tally{open, offs} = sofar
997 Tally{one <> open, offs}
998
999# a line for a probe off the hub, added
1000def tally.offhub(line: String, sofar: Tally) -> Tally:
1001 Tally{open, offs} = sofar
1002 Tally{open, line <> offs}
1003
1004# one probe's standing added to the rest's
1005def tally.put(standing: Seen, one: Probe, sofar: Tally) -> Tally:
1006 match standing:
1007 case There{}:
1008 sofar
1009 case Unasked{}:
1010 tally.unasked(one, sofar)
1011 case Off{line}:
1012 tally.offhub(line, sofar)
1013
1014# every probe'standing standing
1015def tally(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, ps: List<&2, Probe>) -> Tally:
1016 match ps:
1017 case []:
1018 Tally{[], []}
1019 case +h <> t:
1020 tally.put(seen(world, hub, ds, h), h, tally(world, hub, ds, t))
1021
1022# every hub package the package imports on the hub, some still to ask about,
1023# or why the command refuses
1024type Hubbed is Data:
1025 OnHub{}
1026 HubWait{open: List<&2, Probe>}
1027 OffHub{why: String}
1028
1029# why a package with imports off the hub is refused: every one of them, and
1030# the hub that was asked
1031def why.hub(+hub: String, lines: List<&2, String>) -> String:
1032 "ez: the package imports hub packages that " ++ hub ++ " does not have, or " ++
1033 "could not be asked about, and a package on the hub has to build from the " ++
1034 "hub alone, so nothing was sent:\n" ++ String.join(lines, "\n")
1035
1036def hubbed.offs(offs: List<&2, String>, +hub: String) -> Hubbed:
1037 match offs:
1038 case []:
1039 OnHub{}
1040 case h <> t:
1041 OffHub{why.hub(hub, h <> t)}
1042
1043# the tally read: every probe asked before any is judged, then every one off
1044# the hub named at once
1045def hubbed.go(open: List<&2, Probe>, offs: List<&2, String>, +hub: String) -> Hubbed:
1046 match open:
1047 case []:
1048 hubbed.offs(offs, hub)
1049 case h <> rest:
1050 HubWait{h <> rest}
1051
1052def hubbed.of(sofar: Tally, +hub: String) -> Hubbed:
1053 Tally{open, offs} = sofar
1054 hubbed.go(open, offs, hub)
1055
1056# where the World's hub imports stand
1057def hubbed(+world: PW.World) -> Hubbed:
1058 +hub = hub(world)
1059 hubbed.of(tally(world, hub, deps(world), probes(world)), hub)
1060
1061# whether they are all on the hub
1062def on.hub.v(got: Hubbed) -> Bool:
1063 match got:
1064 case OnHub{}:
1065 True{}
1066 case HubWait{_open}:
1067 False{}
1068 case OffHub{_why}:
1069 False{}
1070
1071def on.hub(+world: PW.World) -> Bool:
1072 on.hub.v(hubbed(world))
1073
1074# ---------------------------------------------------------------------------
1075# where a World stands
1076
1077# every check passed, questions still open, or why the command refuses
1078type Verdict is Data:
1079 Go{}
1080 Pending{asks: List<&2, PW.Ask>}
1081 Stop{why: String}
1082
1083# what a refusal for a dirty tree says. The lines git printed come with it,
1084# since the next question is always which file.
1085def why.dirty(+text: String) -> String:
1086 "ez: the working tree is dirty, and publishing is public and cannot be " ++
1087 "taken back, so what goes to the hub has to exist in a commit. git " ++
1088 "status says:\n" ++ text
1089
1090# what a refusal for a file git does not track says
1091def why.stray(+path: String) -> String:
1092 "ez: " ++ path ++ " is part of the package and git does not track it, " ++
1093 "so what goes to the hub would not exist in a commit"
1094
1095# the upload answered: bend's answer agrees, or the command refuses
1096def decide.agreed(ok: Bool, +world: PW.World) -> Verdict:
1097 match ok:
1098 case True{}:
1099 Go{}
1100 case False{}:
1101 Stop{why.upload(world)}
1102
1103# the upload, asked for once every check before it passed
1104def decide.upload(got: PW.Heard, +world: PW.World) -> Verdict:
1105 match got:
1106 case PW.Open{}:
1107 Pending{[upload(world)]}
1108 case PW.Heard{_a}:
1109 decide.agreed(agreed(world), world)
1110
1111# the hub imports: every one on the hub goes on to the upload, one still to
1112# ask about is asked, and any other refuses, sending nothing (EZ-PUB-5)
1113def decide.hub(on: Hubbed, +world: PW.World) -> Verdict:
1114 match on:
1115 case OnHub{}:
1116 decide.upload(uploaded(world), world)
1117 case HubWait{open}:
1118 Pending{probe.asks(hub(world), open)}
1119 case OffHub{why}:
1120 Stop{why}
1121
1122# the lock, read first when the package imports a package by name, since it
1123# holds the hash the name was resolved to
1124def decide.lock(need: Bool, got: PW.Heard, +world: PW.World) -> Verdict:
1125 match need:
1126 case False{}:
1127 decide.hub(hubbed(world), world)
1128 case True{}:
1129 match got:
1130 case PW.Open{}:
1131 Pending{[PW.Lock{}]}
1132 case PW.Heard{_a}:
1133 decide.hub(hubbed(world), world)
1134
1135# every file of the package tracked, or the first that is not refused
1136def decide.kept(kept: Kept, +world: PW.World) -> Verdict:
1137 match kept:
1138 case Kept{}:
1139 decide.lock(any(nvs(world)), PW.heard(world, PW.Lock{}), world)
1140 case Stray{path}:
1141 Stop{why.stray(path)}
1142 case KeptWait{}:
1143 Pending{[PW.Tracked{}]}
1144 case KeptNo{why}:
1145 Stop{why}
1146
1147# the walk: a package, a file it asks for, or why there is none
1148def decide.walk(got: K.Walked, +world: PW.World) -> Verdict:
1149 match got:
1150 case K.Walked{_h, _root, _fs, _sums}:
1151 decide.kept(stray(world), world)
1152 case K.Asks{at}:
1153 Pending{[PW.Tool{TW.Read{at}}]}
1154 case K.Refused{why}:
1155 Stop{why}
1156
1157# a status that named a path refuses, and nothing is read or sent
1158def decide.clean(bad: Bool, +text: String, +world: PW.World) -> Verdict:
1159 match bad:
1160 case True{}:
1161 Stop{why.dirty(text)}
1162 case False{}:
1163 decide.walk(walked(world), world)
1164
1165# git status: asked, answered, or failed
1166def decide.status(said: Status, +world: PW.World) -> Verdict:
1167 match said:
1168 case Said{+text}:
1169 decide.clean(dirty(text), text, world)
1170 case StatusWait{}:
1171 Pending{[locate(world)]}
1172 case StatusNo{why}:
1173 Stop{why}
1174
1175# the ledger: an entry, or why there is none
1176def decide.led(ledger: Led, +world: PW.World) -> Verdict:
1177 match ledger:
1178 case Led{_e, _nv}:
1179 decide.status(status(world), world)
1180 case LedNo{why}:
1181 Stop{why}
1182
1183# where a World stands
1184def decide(+world: PW.World) -> Verdict:
1185 decide.led(led(world), world)
1186
1187# ---------------------------------------------------------------------------
1188# the plan
1189
1190# what the import line names the package by: its `<name>@<version>` when it
1191# was published under one, its hash otherwise
1192def called.of(got: Named, +hash: String) -> String:
1193 match got:
1194 case ByName{nv}:
1195 nv
1196 case ByHash{}:
1197 hash
1198 case Unnamed{_why}:
1199 hash
1200
1201# what a World's import line names the package by
1202def called(+world: PW.World) -> String:
1203 called.of(named(world), hash(world))
1204
1205# what a publish that agreed says: the hash ez computed, then the import line
1206# for the entry's path inside the package (`G.import.line`, which `ez add`
1207# prints too), by the name the package was published under when it has one.
1208# It writes nothing.
1209def made(+world: PW.World) -> P.Plan:
1210 P.Plan{[P.Say{hash(world)}, P.Say{G.import.line(called(world), at(world))}],
1211 P.Success{}}
1212
1213# the plan for where a World stands. One that still asks is a World the
1214# interpreter did not finish, and is refused rather than guessed at.
1215def plan.v(verdict: Verdict, +world: PW.World) -> P.Plan:
1216 match verdict:
1217 case Go{}:
1218 made(world)
1219 case Pending{_asks}:
1220 P.Plan{[], P.Refused{"ez: the publish of " ++ entry(world) ++
1221 " was never resolved"}}
1222 case Stop{why}:
1223 P.Plan{[], P.Refused{why}}
1224
1225# `ez publish` over a World
1226def plan(+world: PW.World) -> P.Plan:
1227 plan.v(decide(world), world)
1228
1229# the questions still open, or the plan once there are none
1230type Step is Data:
1231 Asking{asks: List<&2, PW.Ask>}
1232 Run{plan: P.Plan}
1233
1234# the step for where the World stands
1235def step.v(verdict: Verdict, +world: PW.World) -> Step:
1236 match verdict:
1237 case Go{}:
1238 Run{made(world)}
1239 case Pending{asks}:
1240 Asking{asks}
1241 case Stop{why}:
1242 Run{plan.v(Stop{why}, world)}
1243
1244# what the interpreter does next on a World
1245def step(+world: PW.World) -> Step:
1246 step.v(decide(world), world)
1247
1248# ---------------------------------------------------------------------------
1249# law vocabulary
1250
1251# whether a plan ends in success
1252def succeeds.plan(pl: P.Plan) -> Bool:
1253 P.Plan{_es, outcome} = pl
1254 match outcome:
1255 case P.Success{}:
1256 True{}
1257 case P.Refused{_why}:
1258 False{}
1259
1260# whether `ez publish` succeeds on a World
1261def succeeds(+world: PW.World) -> Bool:
1262 succeeds.plan(plan(world))
1263
1264# whether `ez publish` refuses on a World
1265def refuses(+world: PW.World) -> Bool:
1266 Bool.not(succeeds(world))
1267
1268# whether a verdict refuses outright: it asks nothing and sends nothing
1269def stops.v(verdict: Verdict) -> Bool:
1270 match verdict:
1271 case Go{}:
1272 False{}
1273 case Pending{_asks}:
1274 False{}
1275 case Stop{_why}:
1276 True{}
1277
1278# whether `ez publish` refuses a World outright, asking nothing more
1279def stops(+world: PW.World) -> Bool:
1280 stops.v(decide(world))
1281
1282# whether a question is the upload
1283def is.upload(ask: PW.Ask) -> Bool:
1284 match ask:
1285 case PW.Upload{_entry, _named, _says}:
1286 True{}
1287 case PW.Tool{_q}:
1288 False{}
1289 case PW.Tracked{}:
1290 False{}
1291 case PW.Lock{}:
1292 False{}
1293 case PW.Hub{_url}:
1294 False{}
1295
1296# whether any of these questions is the upload
1297def any.upload(asks: List<&2, PW.Ask>) -> Bool:
1298 match asks:
1299 case []:
1300 False{}
1301 case h <> t:
1302 +rest = any.upload(t)
1303 Bool.or(is.upload(h), rest)
1304
1305# whether a verdict is at the upload or past it: it asks for the upload, or
1306# has its answer and goes on
1307def sends.v(verdict: Verdict) -> Bool:
1308 match verdict:
1309 case Go{}:
1310 True{}
1311 case Pending{asks}:
1312 any.upload(asks)
1313 case Stop{_why}:
1314 False{}
1315
1316# whether `ez publish` gets as far as the upload on a World
1317def sends(+world: PW.World) -> Bool:
1318 sends.v(decide(world))