src/add/hub.bend

ezx@1.9.0.0 · 467 lines · 0 definitions

1# add/hub: `ez add <name>@<version>` 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/<name>@<version>`, so bend reads the name
23# without asking the hub; ez.toml written with `[deps.<key>]` 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).
29import Base
30import ezx@1.9.0.0/src/add/world.bend as A
31import ezx@1.9.0.0/src/lock/plan.bend as P
32import ezx@1.9.0.0/src/lock/world.bend as W
33import ezx@1.9.0.0/src/lock/lock.bend as L
34import ezx@1.9.0.0/src/ledger/manifest.bend as M
35import ezx@1.9.0.0/src/ledger/render.bend as Rend
36import ezx@1.9.0.0/src/ledger/ignore.bend as I
37import ezx@1.9.0.0/src/pkg/pkg.bend as K
38import ezx@1.9.0.0/src/pkg/path.bend as Path
39import ezx@1.9.0.0/src/git/git.bend as Git
40import ezx@1.9.0.0/src/ez/named.bend as Named
41
42# ---------------------------------------------------------------------------
43# what the World says
44
45# the `<name>@<version>` asked for
46def 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
50def part(+world: A.World) -> String:
51  Named.hub.part(nv(world))
52
53# the key the dependency is recorded under (EZ-LED-7)
54def 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.
59def 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.
64def 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
73def 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
85type 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
91def 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
101def 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
111def 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
119def 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
128def 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
136def 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
148def 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
157def 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
165def 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
174def 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
183def 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
190def 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
199def 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.
204def 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
212def 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
220def 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
229def 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
233def 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
241def 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
248def 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).
254def 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
262def 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
270def 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.
278def 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
286def 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
296def 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
303def 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
320def 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
328def 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.
335def 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 <name>@<version>` over a World
345def plan(+world: A.World) -> P.Plan:
346  plan.v(decide(world), world)
347
348# the questions still open where the World stands
349def 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
359def 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
366def 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 <name>@<version>` refuses on a World
375def 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
379def 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
388def 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
398def hash(+world: A.World) -> String:
399  verdict.hash(decide(world))
400
401# the package's files, as the hub served them
402def files(+world: A.World) -> List<&2, K.Item>:
403  verdict.files(decide(world))
404
405# the dependency `ez add <name>@<version>` records on a World
406def added(+world: A.World) -> M.Dep:
407  dep.of(hash(world), files(world), world)
408
409# the hub a question names
410def 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
420def 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
429def 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
437def 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
446def 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
454def last(pl: P.Plan) -> String:
455  last.go(P.said(pl), "")
456
457# every name's file a list of effects lays, in order
458def 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)