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{}