src/add/plan.bend

ezx@1.7.0.0 · 830 lines · 0 definitions

1# add/plan: `ez add` as a pure planner. It reads a World (add/world.bend) and
2# returns either the questions it still needs answered (`step`) or a Plan:
3# the effects to run and how the command ends (`plan`). The interpreter
4# (add/run.bend) loops on `step`, answering each question by IO, until
5# nothing is left to ask, then executes the plan.
6#
7# Before it asks anything the planner refuses what needs no answer: no
8# ledger, one that does not parse, a target that names nothing, a `--rename`
9# that is no TOML bare key, and a name clash the ledger alone decides (a
10# `--rename`, or a source it already records). Then it asks at most three
11# rounds: what the ref names (the remote's tags, then its HEAD when no tag is
12# semver-ish; or the named ref), then the checkout at that rev, which carries
13# its NAR hash and the text of every file. A 40-hex ref is the commit, and
14# the first question is the checkout. Every question carries the source as
15# git is to read it, `P.anchor(here, url)`, which the planner computes.
16#
17# With the checkout answered, the package is walked from its entry by the
18# pure walk (`K.of`) over the checkout's files, so the hash, the texts laid
19# and the manifest come from one list. The entry is the one given, else the
20# checkout's `[package] entry`, `bin` or `main.bend`, and one the checkout
21# does not hold is refused. The name is `Named.as`'s, refused when
22# `Named.why.as` objects.
23#
24# The plan of an add that succeeds, in order: the tree laid under
25# `$BEND_LIB`, or under `.ez/lib` when the entry it replaces is vendored;
26# ez.toml written with the dependency added (`Rend.add.keep`, which keeps
27# that vendor bit); `.gitignore` written when the allowlist `I.sync` derives
28# from the new ledger changes it; the old committed tree removed when a
29# vendored dependency's hash moved and nothing else names it; and the lines a
30# person is told. A refusal has no effect at all: nothing laid, nothing
31# written, no cache fill.
32#
33# A target bend reads as a hub package's `<name>@<version>` is planned by
34# add/hub.bend instead (`H.plan`). `command` is the plan `ez add` runs, which
35# is `plan` for a git or path target and `H.plan` for a name, and `next` is
36# the interpreter's step over both.
37#
38# The Effect, Plan and Outcome types are the lock's (lock/plan.bend), as for
39# `ez remove` and `ez init`; see "Update (A1, A4)" and "Update (A2)" in
40# docs/rfc/ez-add-planner.md.
41import Base
42import ezx@1.7.0.0/src/add/world.bend as A
43import ezx@1.7.0.0/src/add/hub.bend as H
44import ezx@1.7.0.0/src/lock/plan.bend as P
45import ezx@1.7.0.0/src/lock/world.bend as W
46import ezx@1.7.0.0/src/lock/up.bend as Up
47import ezx@1.7.0.0/src/ledger/manifest.bend as M
48import ezx@1.7.0.0/src/ledger/render.bend as Rend
49import ezx@1.7.0.0/src/ledger/ignore.bend as I
50import ezx@1.7.0.0/src/pkg/pkg.bend as K
51import ezx@1.7.0.0/src/pkg/path.bend as Path
52import ezx@1.7.0.0/src/git/git.bend as Git
53import ezx@1.7.0.0/src/ez/named.bend as Named
54import ezx@1.7.0.0/src/ez/target.bend as Tgt
55
56# ---------------------------------------------------------------------------
57# the questions
58
59# a question, before it is told which source it is about: the remote's tags,
60# its HEAD, what a named ref names, or the checkout at a rev
61type Q is Data:
62  QTags{}
63  QHead{}
64  QRefs{ref: String}
65  QClone{rev: String}
66
67# a question about a source
68def ask.of(question: Q, +from: String) -> Up.Ask:
69  match question:
70    case QTags{}:
71      Up.Tags{from}
72    case QHead{}:
73      Up.Head{from}
74    case QRefs{ref}:
75      Up.Refs{from, ref}
76    case QClone{rev}:
77      Up.Clone{from, rev}
78
79# each question about a source
80def asks.of(qs: List<&2, Q>, +from: String) -> List<&2, Up.Ask>:
81  match qs:
82    case []:
83      []
84    case h <> t:
85      ask.of(h, from) <> asks.of(t, from)
86
87# what git printed for a question about the World's source
88def said(+world: A.World, question: Q) -> Up.Said:
89  Up.printed(A.replies(world), ask.of(question, A.from(world)), "ls-remote " ++ A.url(world))
90
91# what was printed, "" when nothing was
92def said.text(printed: Up.Said) -> String:
93  match printed:
94    case Up.Said{text}:
95      text
96    case Up.Unsaid{_why}:
97      ""
98    case Up.Unasked{_ask}:
99      ""
100
101# the rows git printed for a question, none when nothing was
102def rows(+world: A.World, question: Q) -> List<&2, Git.Row>:
103  Git.rows(String.lines(said.text(said(world, question))))
104
105# ---------------------------------------------------------------------------
106# what the ref names
107
108# the ref `ez add` pins when none was asked for: the tag `Git.choose` takes
109# from every tag on the remote, the greatest release, else the greatest
110# pre-release; else the branch HEAD's symref names (EZ-RES-1)
111def default.ref(+world: A.World) -> String:
112  +chosen = Git.choose(Git.tag.names(rows(world, QTags{})))
113  Bool.pick(String, String.is_empty(chosen), Git.tip.branch(rows(world, QHead{})), chosen)
114
115# the tag or branch recorded, as a ref was given: none for a commit, the
116# default one for none, and the ref itself otherwise
117def tag.of(rev: Bool, empty: Bool, +world: A.World) -> String:
118  match rev:
119    case True{}:
120      ""
121    case False{}:
122      Bool.pick(String, empty, default.ref(world), A.ref(world))
123
124# the tag or branch the ledger records
125def tag(+world: A.World) -> String:
126  tag.of(Git.is_rev(A.ref(world)), String.is_empty(A.ref(world)), world)
127
128# the commit a ref names, a question still to ask, or why there is none
129type Tip is Data:
130  At{rev: String}
131  TipAsk{q: Q}
132  TipNo{why: String}
133
134# a commit the rows named, or why they named none
135def tip.found(+rev: String, why: String) -> Tip:
136  Bool.pick(Tip, String.is_empty(rev), TipNo{why}, At{rev})
137
138# a named ref, read exactly as `refs/tags/<ref>`, its peeled commit first,
139# then `refs/heads/<ref>`
140def tip.refs(printed: Up.Said, +world: A.World) -> Tip:
141  match printed:
142    case Up.Said{text}:
143      tip.found(Git.exact(Git.rows(String.lines(text)), A.ref(world)),
144        "ez: " ++ A.url(world) ++ " has no ref named " ++ A.ref(world))
145    case Up.Unsaid{why}:
146      TipNo{why}
147    case Up.Unasked{_ask}:
148      TipAsk{QRefs{A.ref(world)}}
149
150# the default branch, when no tag is semver-ish: the commit HEAD names, and
151# the branch its symref names, which is recorded as the ref followed
152def tip.head.rows(+rows: List<&2, Git.Row>, +world: A.World) -> Tip:
153  Bool.pick(Tip, String.is_empty(Git.tip.branch(rows)),
154    TipNo{"ez: " ++ A.url(world) ++
155      " has no semver-ish tag and its HEAD names no branch; name a ref to pin"},
156    tip.found(Git.tip.rev(rows), "ez: " ++ A.url(world) ++ " has no HEAD"))
157
158def tip.head(printed: Up.Said, +world: A.World) -> Tip:
159  match printed:
160    case Up.Said{text}:
161      tip.head.rows(Git.rows(String.lines(text)), world)
162    case Up.Unsaid{why}:
163      TipNo{why}
164    case Up.Unasked{_ask}:
165      TipAsk{QHead{}}
166
167# the tag chosen, through its peeled commit, or HEAD when none was
168def tip.chosen(none: Bool, +chosen: String, +rows: List<&2, Git.Row>, +world: A.World) -> Tip:
169  match none:
170    case False{}:
171      tip.found(Git.exact(rows, chosen),
172        "ez: " ++ A.url(world) ++ " has no tag named " ++ chosen)
173    case True{}:
174      tip.head(said(world, QHead{}), world)
175
176def tip.tags.rows(+rows: List<&2, Git.Row>, +world: A.World) -> Tip:
177  +chosen = Git.choose(Git.tag.names(rows))
178  tip.chosen(String.is_empty(chosen), chosen, rows, world)
179
180def tip.tags(printed: Up.Said, +world: A.World) -> Tip:
181  match printed:
182    case Up.Said{text}:
183      tip.tags.rows(Git.rows(String.lines(text)), world)
184    case Up.Unsaid{why}:
185      TipNo{why}
186    case Up.Unasked{_ask}:
187      TipAsk{QTags{}}
188
189# a ref that is not a commit: the default one, or the one named
190def tip.named(empty: Bool, +world: A.World) -> Tip:
191  match empty:
192    case True{}:
193      tip.tags(said(world, QTags{}), world)
194    case False{}:
195      tip.refs(said(world, QRefs{A.ref(world)}), world)
196
197# a 40-hex ref is the commit, and asks the remote nothing
198def tip.of(rev: Bool, empty: Bool, +world: A.World) -> Tip:
199  match rev:
200    case True{}:
201      At{A.ref(world)}
202    case False{}:
203      tip.named(empty, world)
204
205# the commit the ref names
206def tip(+world: A.World) -> Tip:
207  tip.of(Git.is_rev(A.ref(world)), String.is_empty(A.ref(world)), world)
208
209def tip.rev(tip: Tip) -> String:
210  match tip:
211    case At{rev}:
212      rev
213    case TipAsk{_q}:
214      ""
215    case TipNo{_why}:
216      ""
217
218# the commit pinned, "" while it is not known
219def rev(+world: A.World) -> String:
220  tip.rev(tip(world))
221
222# ---------------------------------------------------------------------------
223# the checkout
224
225# the checkout at the rev: its NAR hash and every file's text, or the
226# question still open, or why there is none
227type Co is Data:
228  Co{nar: String, files: List<&2, K.Source>}
229  CoWait{}
230  CoNo{why: String}
231
232def co.of(answer: Up.Answer) -> Co:
233  match answer:
234    case Up.Cloned{nar, fs}:
235      Co{nar, fs}
236    case Up.Miss{why}:
237      CoNo{why}
238    case _:
239      CoNo{"ez: a checkout was not answered with one"}
240
241def co.heard(got: Up.Heard) -> Co:
242  match got:
243    case Up.Open{}:
244      CoWait{}
245    case Up.Heard{answer}:
246      co.of(answer)
247
248# the checkout at a rev, as the World answered it
249def co.at(+world: A.World, +rev: String) -> Co:
250  co.heard(Up.heard(A.replies(world), ask.of(QClone{rev}, A.from(world))))
251
252# the checkout at the rev the ref names
253def co(+world: A.World) -> Co:
254  co.at(world, rev(world))
255
256def co.files(checkout: Co) -> List<&2, K.Source>:
257  match checkout:
258    case Co{_nar, fs}:
259      fs
260    case CoWait{}:
261      []
262    case CoNo{_why}:
263      []
264
265def co.nar(checkout: Co) -> String:
266  match checkout:
267    case Co{nar, _fs}:
268      nar
269    case CoWait{}:
270      ""
271    case CoNo{_why}:
272      ""
273
274# every file of the checkout, none while it is not known
275def files(+world: A.World) -> List<&2, K.Source>:
276  co.files(co(world))
277
278# the checkout's NAR hash
279def nar(+world: A.World) -> String:
280  co.nar(co(world))
281
282# the checkout's own ez.toml, None when it has none
283def tree.toml(+world: A.World) -> Maybe<&2, String>:
284  K.look(files(world), "ez.toml")
285
286# the `[package] name` the checkout gives itself, "" for none
287def pkg(+world: A.World) -> String:
288  Git.called.maybe(tree.toml(world))
289
290# the entry the package is walked from: the one given, else the checkout's
291# `[package] entry`, else its `bin`, else `main.bend` (EZ-RES-2)
292def entry(+world: A.World) -> String:
293  Bool.pick(String, String.is_empty(A.entry.arg(world)),
294    Git.entry.maybe(tree.toml(world)), A.entry.arg(world))
295
296# fuel enough for a walk over these files: one step for the entry and one for
297# every import it can queue, and every import is a line of some file. So a
298# walk over a checkout never runs out, and the fuel is a function of the
299# checkout rather than a number, which keeps the walk from unrolling when a
300# proof reasons about a checkout it does not know.
301def fuel(fs: List<&2, K.Source>) -> Nat:
302  match fs:
303    case []:
304      1n
305    case K.Source{_at, text} <> t:
306      (List.length(&2, String, String.lines(text)) + fuel(t) : Nat)
307
308# the package the entry is published as, walked over the checkout's files
309def walked(+world: A.World) -> K.Walked:
310  K.of.fuel(fuel(files(world)), Path.norm(entry(world)), files(world))
311
312# the name the dependency is recorded under (EZ-LED-7)
313def name(+world: A.World) -> String:
314  Named.as(A.deps(world), A.url(world), A.rename(world), pkg(world), A.fresh(world))
315
316# why that name cannot be recorded, "" when it can
317def why(+world: A.World) -> String:
318  Named.why.as(A.deps(world), name(world), A.url(world), A.rename(world))
319
320# ---------------------------------------------------------------------------
321# what is decided before the walk
322
323# whether the ledger alone decides the name: a `--rename` was given, or the
324# ledger already records this source
325def decided(+world: A.World) -> Bool:
326  Bool.or(Bool.not(String.is_empty(A.rename(world))),
327    Bool.not(String.is_empty(Named.had(A.deps(world), A.url(world), ""))))
328
329# why the name is refused before anything is asked, "" when it is not. A
330# `--rename` that is no key is always refused here, and a clash only when
331# the ledger alone decides the name; one that turns on the checkout's
332# `[package] name` is decided after the walk.
333def early(+world: A.World) -> String:
334  Bool.pick(String, decided(world),
335    Named.why.as(A.deps(world),
336      Named.as(A.deps(world), A.url(world), A.rename(world), "", A.fresh(world)),
337      A.url(world), A.rename(world)),
338    "")
339
340# where the World stands before the walk: every answer it needs is in, a
341# question is still open, or the add refuses
342type Verdict is Data:
343  Go{}
344  Pending{qs: List<&2, Q>}
345  Stop{why: String}
346
347# the checkout answered, still to ask, or not to be had
348def decide.co(checkout: Co, +rev: String) -> Verdict:
349  match checkout:
350    case Co{_nar, _fs}:
351      Go{}
352    case CoWait{}:
353      Pending{[QClone{rev}]}
354    case CoNo{why}:
355      Stop{why}
356
357# what the ref names, then the checkout at it
358def decide.tip(tip: Tip, +world: A.World) -> Verdict:
359  match tip:
360    case At{+rev}:
361      decide.co(co.at(world, rev), rev)
362    case TipAsk{q}:
363      Pending{[q]}
364    case TipNo{why}:
365      Stop{why}
366
367# a name refused before anything is asked
368def decide.early(ok: Bool, +why: String, +world: A.World) -> Verdict:
369  match ok:
370    case True{}:
371      decide.tip(tip(world), world)
372    case False{}:
373      Stop{why}
374
375# a target that names nothing is refused before anything is asked
376def decide.place(place: Tgt.Place, +world: A.World) -> Verdict:
377  match place:
378    case Tgt.Bad{why}:
379      Stop{"ez: " ++ why}
380    case Tgt.Local{_path}:
381      +e = early(world)
382      decide.early(String.is_empty(e), e, world)
383    case Tgt.Remote{_url, _slug}:
384      +e = early(world)
385      decide.early(String.is_empty(e), e, world)
386
387# no ledger, or one that does not parse, is refused before anything else,
388# with the reason every command that edits a ledger gives
389def decide.read(read: M.Read, +world: A.World) -> Verdict:
390  match read:
391    case M.Bad{why}:
392      Stop{"ez: " ++ M.show(M.Bad{why})}
393    case M.Good{_m}:
394      decide.place(A.place(world), world)
395
396# where a World stands before the walk
397def decide(+world: A.World) -> Verdict:
398  decide.read(A.read(world), world)
399
400# ---------------------------------------------------------------------------
401# the walk and the name
402
403# the dependency recorded, from the walk's hash and root
404def dep.of(+hash: String, +root: String, +world: A.World) -> M.Dep:
405  M.Dep{name(world), hash, entry(world),
406    M.Git{A.url(world), rev(world), tag(world), root, nar(world), False{}}}
407
408# the ledger with the package added, once it is known to read back. A
409# ledger that could not be written so that it reads back, such as one whose
410# source or entry holds a quote, is refused (EZ-LED-4).
411def outcome.fit(fit: Bool) -> P.Outcome:
412  match fit:
413    case True{}:
414      P.Success{}
415    case False{}:
416      P.Refused{Rend.unrenderable()}
417
418# the name, once the walk made a package, then the ledger it leaves
419def outcome.name(ok: Bool, fit: Bool, +world: A.World) -> P.Outcome:
420  match ok:
421    case True{}:
422      outcome.fit(fit)
423    case False{}:
424      P.Refused{why(world)}
425
426# the walk's verdict: a package, or why there is none
427def outcome.walk(walk: K.Walked, +world: A.World) -> P.Outcome:
428  match walk:
429    case K.Walked{+h, +root, _fs, _ss}:
430      outcome.name(String.is_empty(why(world)),
431        Rend.renderable(Rend.add.keep(A.manifest.of(A.read(world)), dep.of(h, root, world))), world)
432    case K.Asks{at}:
433      P.Refused{"ez: " ++ at ++ " was never read"}
434    case K.Refused{why}:
435      P.Refused{why}
436
437# the entry has to be in the checkout for there to be a package to walk
438def outcome.entry(got: Maybe<&2, String>, walk: K.Walked, +world: A.World) -> P.Outcome:
439  match got:
440    case None{}:
441      P.Refused{"ez: " ++ entry(world) ++ " is not in " ++ A.url(world) ++ " at " ++
442        rev(world)}
443    case Some{_text}:
444      outcome.walk(walk, world)
445
446# how an add over an answered World ends, given its walk
447def outcome.k(walk: K.Walked, +world: A.World) -> P.Outcome:
448  outcome.entry(K.look(files(world), Path.norm(entry(world))), walk, world)
449
450# ---------------------------------------------------------------------------
451# the effects
452
453# the dependency the plan records: the one `dep.of` makes from the walk, or
454# an empty one when the walk made none, which no plan writes
455def added.k(walk: K.Walked, +world: A.World) -> M.Dep:
456  match walk:
457    case K.Walked{h, root, _fs, _ss}:
458      dep.of(h, root, world)
459    case K.Asks{_at}:
460      M.Dep{"", "", "", M.Hub{""}}
461    case K.Refused{_why}:
462      M.Dep{"", "", "", M.Hub{""}}
463
464# the dependency `ez add` records on a World
465def added(+world: A.World) -> M.Dep:
466  added.k(walked(world), world)
467
468# the ledger `ez add` leaves on a World
469def ledger.next(+world: A.World) -> M.Manifest:
470  Rend.add.keep(A.manifest.of(A.read(world)), added(world))
471
472# a found file as it is laid
473def src.of(item: K.Source) -> W.Source:
474  K.Source{at, text} = item
475  W.Source{at, text}
476
477# every found file as it is laid
478def srcs(fs: List<&2, K.Source>) -> List<&2, W.Source>:
479  match fs:
480    case []:
481      []
482    case h <> t:
483      src.of(h) <> srcs(t)
484
485# where the tree is laid: under `.ez/lib`, committed, when the entry it
486# replaces is vendored, since the re-added entry keeps that bit; under
487# BEND_LIB otherwise
488def place.of(vend: Bool) -> P.Place:
489  match vend:
490    case True{}:
491      P.Committed{}
492    case False{}:
493      P.Cache{}
494
495# the rest of a scan only when this step was not the hit
496def any.step(hit: Bool, rest: Unit -> Bool) -> Bool:
497  match hit:
498    case True{}:
499      True{}
500    case False{}:
501      rest(Unit{})
502
503# whether a dependency names this hash
504def holds(ds: List<&2, M.Dep>, +hash: String) -> Bool:
505  match ds:
506    case []:
507      False{}
508    case +h <> t:
509      any.step(String.eq(M.dep.hash(h), hash), _u => holds(t, hash))
510
511# whether a char may stand in a hash that names a directory under `.ez/lib`
512def char.ok(+ch: Char) -> Bool:
513  Bool.not(Bool.or(Char.is_eq(ch, '/'), Char.is_eq(ch, '\\')))
514
515def chars.ok(text: String) -> Bool:
516  match text:
517    case SNil{}:
518      True{}
519    case SCon{+h, t}:
520      +rest = chars.ok(t)
521      Bool.and(char.ok(h), rest)
522
523# a hash `rm -rf .ez/lib/<hash>` removes one tree for, as `ez remove` checks
524# it: a `0x` name with no path separator in it
525def safe(+hash: String) -> Bool:
526  Bool.and(String.starts_with(hash, "0x"), chars.ok(hash))
527
528# the committed tree a re-add moves off: the old hash of a vendored entry
529# when the hash moved, it is safe to remove, and nothing left names it
530def gone(+old: M.Dep, +hash: String, +left: List<&2, M.Dep>) -> List<&2, String>:
531  Bool.pick(List<&2, String>,
532    Bool.and(M.source.vendor(M.source.dep(old)),
533      Bool.and(Bool.not(String.eq(M.dep.hash(old), hash)),
534        Bool.and(safe(M.dep.hash(old)), Bool.not(holds(left, M.dep.hash(old)))))),
535    [M.dep.hash(old)], [])
536
537# `.gitignore` written ahead of the rest when the allowlist changes it
538def ignore.put(same: Bool, +next: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>:
539  match same:
540    case True{}:
541      rest
542    case False{}:
543      P.Write{A.ignore.file(), next} <> rest
544
545# the entry's path inside a package rooted at `root`, which is where the hub
546# serves it: its name alone, unless an import climbs out of its directory and
547# re-roots the package above it
548def at.of(+root: String, +world: A.World) -> String:
549  K.inside(root, Path.norm(entry(world)))
550
551# the lines a person is told: the commit a tag or branch turned out to be,
552# and the import line the package is now reachable by, for the entry at `at`
553# inside it. `Git.import.line` is the line `ez publish` prints too.
554def says(+hash: String, +at: String, +world: A.World) -> List<&2, P.Effect>:
555  +line = Git.import.line(hash, at)
556  Bool.pick(List<&2, P.Effect>, String.is_empty(tag(world)), [P.Say{line}],
557    [P.Say{tag(world) ++ " is " ++ rev(world)}, P.Say{line}])
558
559# what an add that walked a package does: the tree laid, ez.toml written,
560# `.gitignore` when the allowlist changes, the old committed tree removed, and
561# what a person is told
562def effects.of(
563  +dep: M.Dep,
564  +hash: String,
565  +at: String,
566  fs: List<&2, K.Source>,
567  ss: List<&2, K.Item>,
568  +manifest: M.Manifest,
569  +world: A.World
570) -> List<&2, P.Effect>:
571  +old = M.find(A.deps.of(manifest), M.dep.name(dep))
572  +left = A.deps.of(Rend.add.keep(manifest, dep))
573  +next = I.sync(left, A.ignore(world))
574  P.Lay{place.of(M.source.vendor(M.source.dep(old))), hash,
575    List.append(&2, W.Source, srcs(fs), [W.Source{"manifest", K.manifest_of(ss)}])} <>
576    (P.Write{A.toml(), Rend.show(Rend.add.keep(manifest, dep))} <>
577      ignore.put(String.eq(next, A.ignore(world)), next,
578        P.drops.of(gone(old, hash, left), says(hash, at, world))))
579
580# the effects of an add whose walk made a package; none otherwise
581def effects.k(walk: K.Walked, +world: A.World) -> List<&2, P.Effect>:
582  match walk:
583    case K.Walked{+h, +root, fs, ss}:
584      effects.of(dep.of(h, root, world), h, at.of(root, world), fs, ss,
585        A.manifest.of(A.read(world)), world)
586    case K.Asks{_at}:
587      []
588    case K.Refused{_why}:
589      []
590
591# ---------------------------------------------------------------------------
592# the plan
593
594# a plan that refuses has no effect at all; its reason is how it ends
595def seal(es: List<&2, P.Effect>, outcome: P.Outcome) -> P.Plan:
596  match outcome:
597    case P.Success{}:
598      P.Plan{es, P.Success{}}
599    case P.Refused{why}:
600      P.Plan{[], P.Refused{why}}
601
602# the plan once every answer is in, with `k` the walk. The walk is made once
603# and read by both halves.
604def made(+walk: K.Walked, +world: A.World) -> P.Plan:
605  seal(effects.k(walk, world), outcome.k(walk, world))
606
607# the plan for where the World stands. One that still asks is a World the
608# interpreter did not finish, and is refused rather than guessed at.
609def plan.v(verdict: Verdict, +world: A.World) -> P.Plan:
610  match verdict:
611    case Go{}:
612      made(walked(world), world)
613    case Pending{_qs}:
614      P.Plan{[], P.Refused{"ez: the remote was never asked what " ++ A.url(world) ++
615        " holds"}}
616    case Stop{why}:
617      P.Plan{[], P.Refused{why}}
618
619# `ez add` over a World
620def plan(+world: A.World) -> P.Plan:
621  plan.v(decide(world), world)
622
623# whether a plan ends in a refusal
624def refuses.plan(pl: P.Plan) -> Bool:
625  P.Plan{_es, outcome} = pl
626  match outcome:
627    case P.Success{}:
628      False{}
629    case P.Refused{_why}:
630      True{}
631
632# whether `ez add` refuses on a World
633def refuses(+world: A.World) -> Bool:
634  refuses.plan(plan(world))
635
636# the questions still open where the World stands
637def qs.v(verdict: Verdict) -> List<&2, Q>:
638  match verdict:
639    case Go{}:
640      []
641    case Pending{qs}:
642      qs
643    case Stop{_why}:
644      []
645
646# the questions a World still leaves open, each about the anchored source
647def wants(+world: A.World) -> List<&2, Up.Ask>:
648  asks.of(qs.v(decide(world)), A.from(world))
649
650# whether every answer the planner needs is in
651def answered(+world: A.World) -> Bool:
652  List.is_empty(&2, Up.Ask, wants(world))
653
654# the questions still open, or the plan once there are none
655type Step is Data:
656  Asking{asks: List<&2, Up.Ask>}
657  Run{plan: P.Plan}
658
659def step.v(verdict: Verdict, +world: A.World) -> Step:
660  match verdict:
661    case Go{}:
662      Run{plan.v(Go{}, world)}
663    case Pending{qs}:
664      Asking{asks.of(qs, A.from(world))}
665    case Stop{why}:
666      Run{plan.v(Stop{why}, world)}
667
668# what the interpreter does next on a World
669def step(+world: A.World) -> Step:
670  step.v(decide(world), world)
671
672# ---------------------------------------------------------------------------
673# law vocabulary
674
675# whether a question asks the remote to resolve a ref: its tags, its HEAD,
676# or a named ref. A checkout at a known commit is not resolving.
677def resolves.one(ask: Up.Ask) -> Bool:
678  match ask:
679    case Up.Tags{_url}:
680      True{}
681    case Up.Head{_url}:
682      True{}
683    case Up.Refs{_url, _ref}:
684      True{}
685    case _:
686      False{}
687
688# whether any question does
689def resolves(as: List<&2, Up.Ask>) -> Bool:
690  match as:
691    case []:
692      False{}
693    case h <> t:
694      +rest = resolves(t)
695      Bool.or(resolves.one(h), rest)
696
697# the source a git question names, "" for any other question
698def ask.url(ask: Up.Ask) -> String:
699  match ask:
700    case Up.Tags{url}:
701      url
702    case Up.Head{url}:
703      url
704    case Up.Refs{url, _ref}:
705      url
706    case Up.Clone{url, _rev}:
707      url
708    case _:
709      ""
710
711# whether every question names this source
712def asks.from(as: List<&2, Up.Ask>, +from: String) -> Bool:
713  match as:
714    case []:
715      True{}
716    case h <> t:
717      +rest = asks.from(t, from)
718      Bool.and(String.eq(ask.url(h), from), rest)
719
720# what a path holds once a plan has run, when it held `was` before
721def after(wrote: P.Wrote, +was: String) -> String:
722  match wrote:
723    case P.Kept{}:
724      was
725    case P.Put{text}:
726      text
727
728# the hash of the package the walk made, "" when it made none
729def hash.of(walk: K.Walked) -> String:
730  match walk:
731    case K.Walked{h, _root, _fs, _ss}:
732      h
733    case K.Asks{_at}:
734      ""
735    case K.Refused{_why}:
736      ""
737
738# the directory its paths are written from, "." when it made none
739def root.of(walk: K.Walked) -> String:
740  match walk:
741    case K.Walked{_h, root, _fs, _ss}:
742      root
743    case K.Asks{_at}:
744      "."
745    case K.Refused{_why}:
746      "."
747
748# the package's hash on a World
749def hash(+world: A.World) -> String:
750  hash.of(walked(world))
751
752# the entry's path inside the package on a World, which is where the hub
753# serves it
754def at(+world: A.World) -> String:
755  at.of(root.of(walked(world)), world)
756
757# the last of some lines, `prev` when there are none after it
758def last.go(ls: List<&2, String>, +prev: String) -> String:
759  match ls:
760    case []:
761      prev
762    case h <> t:
763      last.go(t, h)
764
765# the last line a plan says, "" when it says none
766def last(pl: P.Plan) -> String:
767  last.go(P.said(pl), "")
768
769# ---------------------------------------------------------------------------
770# the command: a git or path target, or a hub package's name
771
772# the plan `ez add` runs, once it is known whether the target is a name
773def command.of(named: Bool, +world: A.World) -> P.Plan:
774  match named:
775    case True{}:
776      H.plan(world)
777    case False{}:
778      plan(world)
779
780# the plan `ez add` runs on a World
781def command(+world: A.World) -> P.Plan:
782  command.of(A.named(world), world)
783
784# whether `ez add` refuses on a World
785def refuses.command(+world: A.World) -> Bool:
786  refuses.plan(command(world))
787
788# what the interpreter does next: answer git's questions, answer the hub's,
789# or run the plan
790type Next is Data:
791  AskGit{asks: List<&2, Up.Ask>}
792  AskHub{asks: List<&2, W.Ask>}
793  Planned{plan: P.Plan}
794
795# the git planner's step
796def next.git(st: Step) -> Next:
797  match st:
798    case Asking{asks}:
799      AskGit{asks}
800    case Run{pl}:
801      Planned{pl}
802
803# the hub planner's step: its questions, or its plan once there are none
804def next.hub(asks: List<&2, W.Ask>, +world: A.World) -> Next:
805  match asks:
806    case []:
807      Planned{H.plan(world)}
808    case h <> t:
809      AskHub{h <> t}
810
811def next.of(named: Bool, +world: A.World) -> Next:
812  match named:
813    case True{}:
814      next.hub(H.wants(world), world)
815    case False{}:
816      next.git(step(world))
817
818# what the interpreter does next on a World
819def next(+world: A.World) -> Next:
820  next.of(A.named(world), world)
821
822# whether a step still asks anything
823def next.asks(nx: Next) -> Bool:
824  match nx:
825    case AskGit{_asks}:
826      True{}
827    case AskHub{_asks}:
828      True{}
829    case Planned{_pl}:
830      False{}