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 `@` 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. 41 import Base 42 import ./world.bend as A 43 import ./hub.bend as H 44 import ../lock/plan.bend as P 45 import ../lock/world.bend as W 46 import ../lock/up.bend as Up 47 import ../ledger/manifest.bend as M 48 import ../ledger/render.bend as Rend 49 import ../ledger/ignore.bend as I 50 import ../pkg/pkg.bend as K 51 import ../pkg/path.bend as Path 52 import ../git/git.bend as Git 53 import ../ez/named.bend as Named 54 import ../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 61 type Q is Data: 62 QTags{} 63 QHead{} 64 QRefs{ref: String} 65 QClone{rev: String} 66 67 # a question about a source 68 def 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 80 def 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 88 def 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 92 def 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 102 def 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) 111 def 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 117 def 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 125 def 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 129 type 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 135 def 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/`, its peeled commit first, 139 # then `refs/heads/` 140 def 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 152 def 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 158 def 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 168 def 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 176 def 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 180 def 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 190 def 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 198 def 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 206 def tip(+world: A.World) -> Tip: 207 tip.of(Git.is_rev(A.ref(world)), String.is_empty(A.ref(world)), world) 208 209 def 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 219 def 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 227 type Co is Data: 228 Co{nar: String, files: List<&2, K.Source>} 229 CoWait{} 230 CoNo{why: String} 231 232 def 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 241 def 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 249 def 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 253 def co(+world: A.World) -> Co: 254 co.at(world, rev(world)) 255 256 def 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 265 def 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 275 def files(+world: A.World) -> List<&2, K.Source>: 276 co.files(co(world)) 277 278 # the checkout's NAR hash 279 def nar(+world: A.World) -> String: 280 co.nar(co(world)) 281 282 # the checkout's own ez.toml, None when it has none 283 def 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 287 def 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) 292 def 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. 301 def 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 309 def 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) 313 def 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 317 def 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 325 def 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. 333 def 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 342 type Verdict is Data: 343 Go{} 344 Wait{qs: List<&2, Q>} 345 Stop{why: String} 346 347 # the checkout answered, still to ask, or not to be had 348 def decide.co(checkout: Co, +rev: String) -> Verdict: 349 match checkout: 350 case Co{_nar, _fs}: 351 Go{} 352 case CoWait{}: 353 Wait{[QClone{rev}]} 354 case CoNo{why}: 355 Stop{why} 356 357 # what the ref names, then the checkout at it 358 def 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 Wait{[q]} 364 case TipNo{why}: 365 Stop{why} 366 367 # a name refused before anything is asked 368 def 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 376 def 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 389 def 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 397 def 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 404 def 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). 411 def 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 419 def 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 427 def 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 438 def 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 447 def 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 455 def 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 465 def added(+world: A.World) -> M.Dep: 466 added.k(walked(world), world) 467 468 # the ledger `ez add` leaves on a World 469 def 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 473 def 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 478 def 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 488 def 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 496 def 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 504 def 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` 512 def char.ok(+ch: Char) -> Bool: 513 Bool.not(Bool.or(Char.is_eq(ch, '/'), Char.is_eq(ch, '\\'))) 514 515 def 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/` removes one tree for, as `ez remove` checks 524 # it: a `0x` name with no path separator in it 525 def 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 530 def 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 538 def 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 548 def 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. 554 def 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 562 def 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 581 def 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 595 def 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. 604 def 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. 609 def plan.v(verdict: Verdict, +world: A.World) -> P.Plan: 610 match verdict: 611 case Go{}: 612 made(walked(world), world) 613 case Wait{_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 620 def plan(+world: A.World) -> P.Plan: 621 plan.v(decide(world), world) 622 623 # whether a plan ends in a refusal 624 def 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 633 def refuses(+world: A.World) -> Bool: 634 refuses.plan(plan(world)) 635 636 # the questions still open where the World stands 637 def qs.v(verdict: Verdict) -> List<&2, Q>: 638 match verdict: 639 case Go{}: 640 [] 641 case Wait{qs}: 642 qs 643 case Stop{_why}: 644 [] 645 646 # the questions a World still leaves open, each about the anchored source 647 def 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 651 def 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 655 type Step is Data: 656 Asking{asks: List<&2, Up.Ask>} 657 Run{plan: P.Plan} 658 659 def step.v(verdict: Verdict, +world: A.World) -> Step: 660 match verdict: 661 case Go{}: 662 Run{plan.v(Go{}, world)} 663 case Wait{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 669 def 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. 677 def 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 689 def 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 698 def 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 712 def 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 721 def 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 729 def 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 739 def 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 749 def 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 754 def 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 758 def 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 766 def 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 773 def 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 781 def command(+world: A.World) -> P.Plan: 782 command.of(A.named(world), world) 783 784 # whether `ez add` refuses on a World 785 def 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 790 type Next is Data: 791 AskGit{asks: List<&2, Up.Ask>} 792 AskHub{asks: List<&2, W.Ask>} 793 Ready{plan: P.Plan} 794 795 # the git planner's step 796 def next.git(st: Step) -> Next: 797 match st: 798 case Asking{asks}: 799 AskGit{asks} 800 case Run{pl}: 801 Ready{pl} 802 803 # the hub planner's step: its questions, or its plan once there are none 804 def next.hub(asks: List<&2, W.Ask>, +world: A.World) -> Next: 805 match asks: 806 case []: 807 Ready{H.plan(world)} 808 case h <> t: 809 AskHub{h <> t} 810 811 def 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 819 def next(+world: A.World) -> Next: 820 next.of(A.named(world), world) 821 822 # whether a step still asks anything 823 def next.asks(nx: Next) -> Bool: 824 match nx: 825 case AskGit{_asks}: 826 True{} 827 case AskHub{_asks}: 828 True{} 829 case Ready{_pl}: 830 False{}