1 # add/hub: `ez add @` as a pure planner, for a target bend 2 # reads as a hub package's name (`Tgt.aim`). It reads the same World as the 3 # git planner (add/world.bend), and asks the lock's own questions of the hub 4 # the ledger names (lock/world.bend): what the name names (`W.Name`), then 5 # that package's manifest and files (`W.Pkg`). Their answers are the World's 6 # `hubs`. 7 # 8 # Before it asks anything the planner refuses what needs no answer: no 9 # ledger, one that does not parse, a ref and an entry both given (a name pins 10 # its version, so the word after it is the entry), and a key the ledger 11 # alone refuses (`Named.hub.why`). Then it asks at most two rounds: the name, 12 # then the package it names. The hub's answer must be a `0x` name, and the 13 # package is judged as `ez lock` judges a hub package (`W.hub.how`): its 14 # manifest hashes to that name, no path leaves it, each file hashes to its 15 # sum, and the files hash to the name (EZ-HASH-3). 16 # 17 # With the package in hand, the entry is the one given, which the package 18 # must hold, else its `main.bend`, else its first top-level `.bend` file in 19 # manifest order, else none. The key is `Named.hub.key`'s: `--rename`, else 20 # the key the ledger gives this name part, else the name part itself. The 21 # plan of an add that succeeds, in order: the tree laid under `$BEND_LIB`; 22 # the name's file, `$BEND_LIB/names/@`, so bend reads the name 23 # without asking the hub; ez.toml written with `[deps.]` holding `hub` 24 # and `hash`; `.gitignore` when the allowlist changes; and the lines a person 25 # is told, the hash the name names and, when there is an entry, the import 26 # line. A refusal has no effect at all. `ez add` writes no lock, for a name 27 # as for a git package: the next `ez lock` reads the name from ez.toml and 28 # asks the hub nothing (EZ-HUB-2). 29 import Base 30 import ./world.bend as A 31 import ../lock/plan.bend as P 32 import ../lock/world.bend as W 33 import ../lock/lock.bend as L 34 import ../ledger/manifest.bend as M 35 import ../ledger/render.bend as Rend 36 import ../ledger/ignore.bend as I 37 import ../pkg/pkg.bend as K 38 import ../pkg/path.bend as Path 39 import ../git/git.bend as Git 40 import ../ez/named.bend as Named 41 42 # --------------------------------------------------------------------------- 43 # what the World says 44 45 # the `@` asked for 46 def nv(world: A.World) -> String: 47 A.target(world) 48 49 # its name part, which a re-add of another version finds its entry by 50 def part(+world: A.World) -> String: 51 Named.hub.part(nv(world)) 52 53 # the key the dependency is recorded under (EZ-LED-7) 54 def key(+world: A.World) -> String: 55 Named.hub.key(A.deps(world), part(world), A.rename(world)) 56 57 # the entry asked for: the word after the name. A name pins its version, so 58 # that word is the entry, whichever place it was given in. 59 def asked(+world: A.World) -> String: 60 Bool.pick(String, String.is_empty(A.entry.arg(world)), A.ref(world), A.entry.arg(world)) 61 62 # this reply's answer when it is about the key, else the rest's. The rest 63 # arrives as a thunk, so the first answer ends the scan. 64 def heard.at(hit: Bool, answer: W.Answer, rest: Unit -> Maybe<&2, W.Answer>) -> Maybe<&2, W.Answer>: 65 match hit: 66 case True{}: 67 Some{answer} 68 case False{}: 69 rest(Unit{}) 70 71 # the answer the World holds for a question about this key: a name, or a 72 # package's hash 73 def heard(rs: List<&2, W.Reply>, +key: String) -> Maybe<&2, W.Answer>: 74 match rs: 75 case []: 76 None{} 77 case W.Reply{ask, answer} <> t: 78 heard.at(String.eq(W.ask.hash(ask), key), answer, _u => heard(t, key)) 79 80 # --------------------------------------------------------------------------- 81 # where the World stands 82 83 # every answer is in, with the hash the hub named and the package it serves 84 # checked against it; a question is still open; or the add refuses 85 type Verdict is Data: 86 Go{hash: String, files: List<&2, K.Item>, srcs: List<&2, String>} 87 Pending{asks: List<&2, W.Ask>} 88 Stop{why: String} 89 90 # the package, judged as the lock judges a hub package 91 def decide.body(verdict: W.Verdict, +hash: String) -> Verdict: 92 match verdict: 93 case W.Ok{fs, ss}: 94 Go{hash, fs, ss} 95 case W.No{why}: 96 Stop{why} 97 case W.Named{_ns}: 98 Stop{"ez: " ++ hash ++ " was not answered with a package"} 99 100 # the package the hub served for the hash, or why it served none 101 def decide.got(answer: W.Answer, +hash: String, +world: A.World) -> Verdict: 102 match answer: 103 case W.Got{how, manifest, ss}: 104 decide.body(W.hub.how(how, hash, manifest, ss), hash) 105 case W.Miss{why}: 106 Stop{W.hub.why(True{}, hash, A.hub(world), why)} 107 case _: 108 Stop{"ez: " ++ hash ++ " was not answered with a package"} 109 110 # the package, once it is asked for 111 def decide.pkg(got: Maybe<&2, W.Answer>, +hash: String, +world: A.World) -> Verdict: 112 match got: 113 case None{}: 114 Pending{[W.Pkg{hash, L.Hub{}, A.hub(world)}]} 115 case Some{answer}: 116 decide.got(answer, hash, world) 117 118 # a `0x` name the hub answered, or an answer that names none 119 def decide.hash(ok: Bool, +hash: String, +world: A.World) -> Verdict: 120 match ok: 121 case True{}: 122 decide.pkg(heard(A.hubs(world), hash), hash, world) 123 case False{}: 124 Stop{"ez: the hub at " ++ A.hub(world) ++ " names no package " ++ nv(world) ++ 125 " (it answered `" ++ hash ++ "`)"} 126 127 # why the hub gave no answer, without the `ez: ` a miss is said with 128 def unsaid(ez: Bool, +why: String) -> String: 129 match ez: 130 case True{}: 131 String.drop(why, 4n) 132 case False{}: 133 why 134 135 # what the hub said the name names 136 def decide.said(answer: W.Answer, +world: A.World) -> Verdict: 137 match answer: 138 case W.Said{text}: 139 +hash = String.trim(text) 140 decide.hash(L.hash.ok(hash), hash, world) 141 case W.Miss{+why}: 142 Stop{"ez: the hub at " ++ A.hub(world) ++ " has no package " ++ nv(world) ++ " (" ++ 143 unsaid(String.starts_with(why, "ez: "), why) ++ ")"} 144 case _: 145 Stop{"ez: " ++ nv(world) ++ " was not answered with a hash"} 146 147 # the name, once it is asked of the hub 148 def decide.name(got: Maybe<&2, W.Answer>, +world: A.World) -> Verdict: 149 match got: 150 case None{}: 151 Pending{[W.Name{nv(world), A.hub(world)}]} 152 case Some{answer}: 153 decide.said(answer, world) 154 155 # why the add is refused before anything is asked, "" when it is not: a ref 156 # and an entry both given, or a key the ledger refuses 157 def early(+world: A.World) -> String: 158 Bool.pick(String, 159 Bool.or(String.is_empty(A.ref(world)), String.is_empty(A.entry.arg(world))), 160 Named.hub.why(A.deps(world), key(world), part(world), A.rename(world)), 161 "ez: " ++ nv(world) ++ " pins its version; the word after it is the entry, " ++ 162 "and there is only one") 163 164 # a refusal the ledger alone decides 165 def decide.early(ok: Bool, +why: String, +world: A.World) -> Verdict: 166 match ok: 167 case True{}: 168 decide.name(heard(A.hubs(world), nv(world)), world) 169 case False{}: 170 Stop{why} 171 172 # no ledger, or one that does not parse, is refused before anything else, 173 # with the reason every command that edits a ledger gives 174 def decide.read(read: M.Read, +world: A.World) -> Verdict: 175 match read: 176 case M.Bad{why}: 177 Stop{"ez: " ++ M.show(M.Bad{why})} 178 case M.Good{_m}: 179 +e = early(world) 180 decide.early(String.is_empty(e), e, world) 181 182 # where a World stands 183 def decide(+world: A.World) -> Verdict: 184 decide.read(A.read(world), world) 185 186 # --------------------------------------------------------------------------- 187 # the entry 188 189 # whether a package holds a file at this path 190 def holds(fs: List<&2, K.Item>, +at: String) -> Bool: 191 match fs: 192 case []: 193 False{} 194 case +h <> t: 195 +rest = holds(t, at) 196 Bool.or(String.eq(K.file.at(h), at), rest) 197 198 # a top-level `.bend` file: one with no directory in front of it 199 def top.one(+at: String) -> Bool: 200 Bool.and(String.ends_with(at, ".bend"), Bool.not(String.contains(at, "/"))) 201 202 # this file when it is a top-level `.bend` file, else the rest's. The rest 203 # arrives as a thunk, so the first one ends the scan. 204 def top.at(hit: Bool, +at: String, rest: Unit -> String) -> String: 205 match hit: 206 case True{}: 207 at 208 case False{}: 209 rest(Unit{}) 210 211 # the first top-level `.bend` file, in manifest order, "" for none 212 def top(fs: List<&2, K.Item>) -> String: 213 match fs: 214 case []: 215 "" 216 case +h <> t: 217 top.at(top.one(K.file.at(h)), K.file.at(h), _u => top(t)) 218 219 # `main.bend` when the package holds one, else its first top-level file 220 def entry.main(held: Bool, fs: List<&2, K.Item>) -> String: 221 match held: 222 case True{}: 223 "main.bend" 224 case False{}: 225 top(fs) 226 227 # the entry a package is recorded with when none was asked for: its 228 # `main.bend`, else its first top-level `.bend` file, else none 229 def entry.of(+fs: List<&2, K.Item>) -> String: 230 entry.main(holds(fs, "main.bend"), fs) 231 232 # the entry asked for, or, when none was, the package's own 233 def entry.pick(none: Bool, +fs: List<&2, K.Item>, +asked: String) -> String: 234 match none: 235 case True{}: 236 entry.of(fs) 237 case False{}: 238 asked 239 240 # the entry recorded: the one asked for, else the package's own 241 def entry(+fs: List<&2, K.Item>, +world: A.World) -> String: 242 entry.pick(String.is_empty(asked(world)), fs, asked(world)) 243 244 # --------------------------------------------------------------------------- 245 # the plan 246 247 # the dependency recorded for a package the hub named by this hash 248 def dep.of(+hash: String, +fs: List<&2, K.Item>, +world: A.World) -> M.Dep: 249 M.Dep{key(world), hash, entry(fs, world), M.Hub{nv(world)}} 250 251 # the ledger with the package added, once it is known to read back. A 252 # ledger that could not be written so that it reads back is refused 253 # (EZ-LED-4). 254 def outcome.fit(fit: Bool) -> P.Outcome: 255 match fit: 256 case True{}: 257 P.Success{} 258 case False{}: 259 P.Refused{Rend.unrenderable()} 260 261 # an entry asked for must be in the package 262 def outcome.entry(held: Bool, fit: Bool, +at: String, +world: A.World) -> P.Outcome: 263 match held: 264 case True{}: 265 outcome.fit(fit) 266 case False{}: 267 P.Refused{"ez: " ++ at ++ " is not in " ++ nv(world)} 268 269 # how an add ends once the package is in hand 270 def outcome.of(+hash: String, +fs: List<&2, K.Item>, +world: A.World) -> P.Outcome: 271 outcome.entry(Bool.or(String.is_empty(asked(world)), holds(fs, Path.norm(asked(world)))), 272 Rend.renderable(Rend.add.keep(A.manifest.of(A.read(world)), dep.of(hash, fs, world))), 273 asked(world), world) 274 275 # where the tree is laid: under `.ez/lib` when the entry it replaces is 276 # vendored, under BEND_LIB otherwise. A hub dependency is never vendored, so 277 # a name the ledger already records is laid under BEND_LIB like a new one. 278 def place.of(vend: Bool) -> P.Place: 279 match vend: 280 case True{}: 281 P.Committed{} 282 case False{}: 283 P.Cache{} 284 285 # `.gitignore` written ahead of the rest when the allowlist changes it 286 def ignore.put(same: Bool, +next: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: 287 match same: 288 case True{}: 289 rest 290 case False{}: 291 P.Write{A.ignore.file(), next} <> rest 292 293 # the lines a person is told: the hash the name names, then, when there is 294 # an entry, the import line for it, the line `ez publish` prints with the 295 # name in place of the hash 296 def says(+hash: String, +entry: String, +world: A.World) -> List<&2, P.Effect>: 297 Bool.pick(List<&2, P.Effect>, String.is_empty(entry), [P.Say{nv(world) ++ " is " ++ hash}], 298 [P.Say{nv(world) ++ " is " ++ hash}, P.Say{Git.import.line(nv(world), Path.norm(entry))}]) 299 300 # what an add of a package the hub served does: the tree laid, its name's 301 # file, ez.toml written, `.gitignore` when the allowlist changes, and what a 302 # person is told 303 def effects.of( 304 +dep: M.Dep, 305 +hash: String, 306 +ls: List<&2, W.Source>, 307 +manifest: M.Manifest, 308 +world: A.World 309 ) -> List<&2, P.Effect>: 310 +old = M.find(A.deps.of(manifest), M.dep.name(dep)) 311 +next = I.sync(A.deps.of(Rend.add.keep(manifest, dep)), A.ignore(world)) 312 P.Lay{place.of(M.source.vendor(M.source.dep(old))), hash, 313 List.append(&2, W.Source, ls, [W.Source{"manifest", W.manifest(ls)}])} <> 314 (P.Name{nv(world), hash} <> 315 (P.Write{A.toml(), Rend.show(Rend.add.keep(manifest, dep))} <> 316 ignore.put(String.eq(next, A.ignore(world)), next, 317 says(hash, M.dep.entry(dep), world)))) 318 319 # a plan that refuses has no effect at all; its reason is how it ends 320 def seal(es: List<&2, P.Effect>, outcome: P.Outcome) -> P.Plan: 321 match outcome: 322 case P.Success{}: 323 P.Plan{es, P.Success{}} 324 case P.Refused{why}: 325 P.Plan{[], P.Refused{why}} 326 327 # the plan once the package is in hand 328 def made(+hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>, +world: A.World) -> P.Plan: 329 seal(effects.of(dep.of(hash, fs, world), hash, W.laid(fs, ss), 330 A.manifest.of(A.read(world)), world), 331 outcome.of(hash, fs, world)) 332 333 # the plan for where the World stands. One that still asks is a World the 334 # interpreter did not finish, and is refused rather than guessed at. 335 def plan.v(verdict: Verdict, +world: A.World) -> P.Plan: 336 match verdict: 337 case Go{hash, fs, ss}: 338 made(hash, fs, ss, world) 339 case Pending{_asks}: 340 P.Plan{[], P.Refused{"ez: the hub was never asked what " ++ nv(world) ++ " names"}} 341 case Stop{why}: 342 P.Plan{[], P.Refused{why}} 343 344 # `ez add @` over a World 345 def plan(+world: A.World) -> P.Plan: 346 plan.v(decide(world), world) 347 348 # the questions still open where the World stands 349 def asks.v(verdict: Verdict) -> List<&2, W.Ask>: 350 match verdict: 351 case Go{_hash, _fs, _ss}: 352 [] 353 case Pending{asks}: 354 asks 355 case Stop{_why}: 356 [] 357 358 # the questions a World still leaves open 359 def wants(+world: A.World) -> List<&2, W.Ask>: 360 asks.v(decide(world)) 361 362 # --------------------------------------------------------------------------- 363 # law vocabulary 364 365 # whether a plan ends in a refusal 366 def refuses.plan(pl: P.Plan) -> Bool: 367 P.Plan{_es, outcome} = pl 368 match outcome: 369 case P.Success{}: 370 False{} 371 case P.Refused{_why}: 372 True{} 373 374 # whether `ez add @` refuses on a World 375 def refuses(+world: A.World) -> Bool: 376 refuses.plan(plan(world)) 377 378 # the hash and files a verdict carries, none when it carries no package 379 def verdict.hash(verdict: Verdict) -> String: 380 match verdict: 381 case Go{hash, _fs, _ss}: 382 hash 383 case Pending{_asks}: 384 "" 385 case Stop{_why}: 386 "" 387 388 def verdict.files(verdict: Verdict) -> List<&2, K.Item>: 389 match verdict: 390 case Go{_hash, fs, _ss}: 391 fs 392 case Pending{_asks}: 393 [] 394 case Stop{_why}: 395 [] 396 397 # the hash the hub named, as the World answered it 398 def hash(+world: A.World) -> String: 399 verdict.hash(decide(world)) 400 401 # the package's files, as the hub served them 402 def files(+world: A.World) -> List<&2, K.Item>: 403 verdict.files(decide(world)) 404 405 # the dependency `ez add @` records on a World 406 def added(+world: A.World) -> M.Dep: 407 dep.of(hash(world), files(world), world) 408 409 # the hub a question names 410 def ask.hub(ask: W.Ask) -> String: 411 match ask: 412 case W.Pkg{_hash, _src, hub}: 413 hub 414 case W.Name{_nv, hub}: 415 hub 416 case W.Lock{}: 417 "" 418 419 # whether every question names this hub 420 def asks.hub(as: List<&2, W.Ask>, +hub: String) -> Bool: 421 match as: 422 case []: 423 True{} 424 case h <> t: 425 +rest = asks.hub(t, hub) 426 Bool.and(String.eq(ask.hub(h), hub), rest) 427 428 # whether a question asks the hub what a name names 429 def names.one(ask: W.Ask) -> Bool: 430 match ask: 431 case W.Name{_nv, _hub}: 432 True{} 433 case _: 434 False{} 435 436 # whether any question does 437 def names(as: List<&2, W.Ask>) -> Bool: 438 match as: 439 case []: 440 False{} 441 case h <> t: 442 +rest = names(t) 443 Bool.or(names.one(h), rest) 444 445 # the last of some lines, `prev` when there are none after it 446 def last.go(ls: List<&2, String>, +prev: String) -> String: 447 match ls: 448 case []: 449 prev 450 case h <> t: 451 last.go(t, h) 452 453 # the last line a plan says, "" when it says none 454 def last(pl: P.Plan) -> String: 455 last.go(P.said(pl), "") 456 457 # every name's file a list of effects lays, in order 458 def names.laid(es: List<&2, P.Effect>) -> List<&2, L.Name>: 459 match es: 460 case []: 461 [] 462 case e <> t: 463 match e: 464 case P.Name{nv, hash}: 465 L.Name{nv, hash} <> names.laid(t) 466 case _: 467 names.laid(t)