src/tool/plan.bend
ezx@1.4.0.0 · 1,332 lines · 0 definitions
1# tool/plan: `ez tool run`, `install` and `upgrade` as a pure planner, and
2# `ez tool sync` as the list of installs it runs. The planner reads a World
3# (tool/world.bend) and returns either the questions it still needs answered
4# (`step`) or a Plan. The interpreter (tool/run.bend) loops on `step`,
5# answering each question by IO, then executes the plan.
6#
7# Before it asks anything the planner refuses what needs no answer: an
8# install or upgrade with nowhere to link (EZ-TOOL-1), no cache root, a
9# project ledger that does not parse, a target that names nothing or a cache
10# slug that could leave the cache (EZ-TOOL-8), a name the ledger pins that
11# the lock does not, or pins at no commit. Then it resolves the commit
12# (EZ-TOOL-4): a `[tools.*]` pin is the lock's rev, url, entry and bin; a
13# remote is what its HEAD names; a path is its clean HEAD, and a path with
14# uncommitted or untracked changes, or one that is not the top of a
15# checkout, is no commit (EZ-TOOL-3). Then the checkout: a remote's is
16# cached per commit and read from there when its record says it finished,
17# otherwise asked of git with every file's text; a path is read where it is.
18# Then its ledger, which names the file built (EZ-TOOL-7) and the link, and
19# a name that is not a TOML bare key is refused. A checkout with no ez.toml
20# is a plain Bend repository: it builds `main.bend`, or the file `--entry`
21# names, needs no lock, and is linked under that file's stem, or under the
22# repository's name when the stem is `main`. A file to build that is not in
23# the checkout is refused. Then the binary's record:
24# the binary is reused only when it is there and its record is this commit,
25# this file and this bend (EZ-TOOL-2). Every binary lives in a directory
26# named by its record, so no two records share one, and a link made by an
27# install is never rebuilt under it by a run of another commit or file.
28#
29# The plan of a command that goes on, in order: the checkout laid under the
30# cache when it was cloned, with its record written last; the lock fetched,
31# for an ez project, and the binary built with its record, unless it is
32# reused; then the
33# program started, for `run`, or the link written, for `install` and
34# `upgrade`. A refusal has no effect at all. The program is started only as
35# the last thing, so its status is the command's (EZ-TOOL-5), and `install`
36# and `upgrade` never start it (EZ-TOOL-6).
37import Base
38import ezx@1.4.0.0/src/tool/world.bend as TW
39import ezx@1.4.0.0/src/lock/plan.bend as P
40import ezx@1.4.0.0/src/lock/up.bend as Up
41import ezx@1.4.0.0/src/ledger/manifest.bend as M
42import ezx@1.4.0.0/src/toml/toml.bend as T
43import ezx@1.4.0.0/src/pkg/pkg.bend as K
44import ezx@1.4.0.0/src/pkg/path.bend as Path
45import ezx@1.4.0.0/src/git/git.bend as Git
46import ezx@1.4.0.0/src/ez/target.bend as Tgt
47import ezx@1.4.0.0/src/ez/named.bend as Named
48import ezx@1.4.0.0/src/ez/key.bend as Key
49import ezx@1.4.0.0/src/share/say.bend as Say
50import ezx@1.4.0.0/src/share/sha.bend as Sha
51
52# ---------------------------------------------------------------------------
53# small pieces
54
55# whether a Maybe holds nothing
56def none(got: Maybe<&2, String>) -> Bool:
57 match got:
58 case None{}:
59 True{}
60 case Some{_t}:
61 False{}
62
63# whether an act links the binary: `install` and `upgrade` do, `run` does not
64def links.act(act: TW.Act) -> Bool:
65 match act:
66 case TW.Run{_args}:
67 False{}
68 case TW.Install{}:
69 True{}
70 case TW.Upgrade{}:
71 True{}
72
73# the tools of a ledger that was read
74def tools.of(manifest: M.Manifest) -> List<&2, M.Tool>:
75 M.Manifest{_n, _e, _b, _h, _pa, _pv, _ds, ts} = manifest
76 ts
77
78# the pins a lock's text records
79def locked(+body: String) -> List<&2, M.Tool>:
80 M.tools(T.sects(T.parse(body)))
81
82# ---------------------------------------------------------------------------
83# what the target names
84
85# a target resolved as far as the project can say: a `[tools.*]` pin, with
86# the lock's url and rev and the file its `bin` or `entry` names; a free
87# target, with the place its word names; or why there is none
88type Src is Data:
89 Pin{name: String, url: String, rev: String, over: String}
90 Free{place: Tgt.Place}
91 NoSrc{why: String}
92
93# the file a pin names: its `bin`, else its `entry`, else "" so the
94# checkout's own ledger chooses
95def over.of(+binary: String, +entry: String) -> String:
96 Bool.pick(String, String.is_empty(binary), entry, binary)
97
98# a pin the lock has, or a refusal when it has none by that name
99def pin.go(empty: Bool, +tool: M.Tool, +name: String) -> Src:
100 match empty:
101 case True{}:
102 NoSrc{"ez: " ++ name ++ " is not pinned in ez.lock.toml"}
103 case False{}:
104 Pin{name, M.source.url(M.tool.source(tool)), M.source.rev(M.tool.source(tool)),
105 over.of(M.tool.bin(tool), M.tool.entry(tool))}
106
107# the pin the lock has for a name, or a refusal
108def pin.of(+tool: M.Tool, +name: String) -> Src:
109 pin.go(String.is_empty(M.tool.name(tool)), tool, name)
110
111# the pin the lock records under a name, one with empty fields for none
112def pin.in(+body: String, +name: String) -> M.Tool:
113 M.tool.find(locked(body), name)
114
115# a name the ledger pins is built as the lock pins it
116def pinned(got: Maybe<&2, String>, +name: String) -> Src:
117 match got:
118 case None{}:
119 NoSrc{"ez: ez.lock.toml is not there"}
120 case Some{body}:
121 pin.of(pin.in(body, name), name)
122
123# a name the ledger pins, or a free target
124def src.pick(free: Bool, +world: TW.World) -> Src:
125 match free:
126 case True{}:
127 Free{Tgt.classify(TW.target(world))}
128 case False{}:
129 pinned(TW.lock(world), TW.target(world))
130
131# whether a ledger pins nothing under this name
132def unpinned(+manifest: M.Manifest, +name: String) -> Bool:
133 String.is_empty(M.tool.name(M.tool.find(tools.of(manifest), name)))
134
135# a project ledger that does not parse is refused
136def src.read(led: M.Read, +world: TW.World) -> Src:
137 match led:
138 case M.Bad{why}:
139 NoSrc{"ez: error: " ++ why}
140 case M.Good{m}:
141 src.pick(unpinned(m, TW.target(world)), world)
142
143# what the target names (EZ-TOOL-4)
144def src(+world: TW.World) -> Src:
145 src.read(TW.project(world), world)
146
147# a pin names a git repository; a path is no pin
148def place.pin(pl: Tgt.Place) -> Tgt.Place:
149 match pl:
150 case Tgt.Bad{why}:
151 Tgt.Bad{why}
152 case Tgt.Local{_p}:
153 Tgt.Bad{"a pinned tool names a git repo"}
154 case Tgt.Remote{url, slug}:
155 Tgt.Remote{url, slug}
156
157# where a resolved target is
158def place.of(found: Src) -> Tgt.Place:
159 match found:
160 case Pin{_n, url, _r, _o}:
161 place.pin(Tgt.classify(url))
162 case Free{pl}:
163 pl
164 case NoSrc{why}:
165 Tgt.Bad{why}
166
167# where the target is: a remote with its cache slug, a path, or nowhere
168def place(+world: TW.World) -> Tgt.Place:
169 place.of(src(world))
170
171# the file a pin names, "" for a free target
172def over.src(found: Src) -> String:
173 match found:
174 case Pin{_n, _u, _r, o}:
175 o
176 case Free{_pl}:
177 ""
178 case NoSrc{_why}:
179 ""
180
181# the file the target names for itself, "" when the checkout chooses
182def over(+world: TW.World) -> String:
183 over.src(src(world))
184
185# ---------------------------------------------------------------------------
186# the commit
187
188# what git said of a local path, still to ask, or why there is nothing
189type Loc is Data:
190 Loc{abs: String, top: String, head: String, ok: Bool, status: String}
191 LocWait{ask: TW.Ask}
192 LocNo{why: String}
193
194# what git said of a path, looked up
195def loc.of(got: TW.Heard, ask: TW.Ask) -> Loc:
196 match got:
197 case TW.Open{}:
198 LocWait{ask}
199 case TW.Heard{answer}:
200 match answer:
201 case TW.Located{abs, top, head, ok, status}:
202 Loc{abs, top, head, ok, status}
203 case TW.Miss{why}:
204 LocNo{why}
205 case _:
206 LocNo{"ez: a path was not answered with what git says of it"}
207
208# the question about a local path, `~` expanded
209def locate(+path: String, +world: TW.World) -> TW.Ask:
210 TW.Locate{Tgt.expand(TW.home(world), path)}
211
212# what git said of a local path
213def loc(+path: String, +world: TW.World) -> Loc:
214 loc.of(TW.heard(world, locate(path, world)), locate(path, world))
215
216# the real path of a local target, "" while it is not known
217def loc.abs(spot: Loc) -> String:
218 match spot:
219 case Loc{abs, _t, _h, _ok, _s}:
220 abs
221 case LocWait{_a}:
222 ""
223 case LocNo{_why}:
224 ""
225
226# the commit a local path builds: HEAD, when `git status` answered and named
227# nothing, and the path is the top of its checkout. A path in a checkout
228# whose top is elsewhere may be ignored there, or hold files the commit does
229# not; either way it is no commit (EZ-TOOL-3).
230def local.same(same: Bool, +head: String) -> String:
231 match same:
232 case True{}:
233 head
234 case False{}:
235 ""
236
237def local.clean(clean: Bool, same: Bool, +head: String) -> String:
238 match clean:
239 case True{}:
240 local.same(same, head)
241 case False{}:
242 ""
243
244def local.go(ok: Bool, clean: Bool, same: Bool, +head: String) -> String:
245 match ok:
246 case True{}:
247 local.clean(clean, same, head)
248 case False{}:
249 ""
250
251def local.rev(+abs: String, +top: String, +head: String, ok: Bool, +status: String) -> String:
252 local.go(ok, String.is_empty(status), String.eq(top, abs), head)
253
254# the commit of a local target, "" while it is not known
255def loc.rev(spot: Loc) -> String:
256 match spot:
257 case Loc{abs, top, head, ok, status}:
258 local.rev(abs, top, head, ok, status)
259 case LocWait{_a}:
260 ""
261 case LocNo{_why}:
262 ""
263
264# what a remote's HEAD printed
265def head.said(+world: TW.World, +url: String) -> Up.Said:
266 Up.printed(TW.ups(world), Up.Head{url}, "ls-remote " ++ url)
267
268# the commit HEAD names, "" while it is not known
269def head.rev(said: Up.Said) -> String:
270 match said:
271 case Up.Said{text}:
272 Git.tip.rev(Git.rows(String.lines(text)))
273 case Up.Unsaid{_why}:
274 ""
275 case Up.Unasked{_ask}:
276 ""
277
278# the commit of a free target
279def rev.free(pl: Tgt.Place, +world: TW.World) -> String:
280 match pl:
281 case Tgt.Bad{_why}:
282 ""
283 case Tgt.Local{p}:
284 loc.rev(loc(p, world))
285 case Tgt.Remote{url, _slug}:
286 head.rev(head.said(world, url))
287
288# the commit of a resolved target
289def rev.of(found: Src, +world: TW.World) -> String:
290 match found:
291 case Pin{_n, _u, r, _o}:
292 r
293 case Free{pl}:
294 rev.free(pl, world)
295 case NoSrc{_why}:
296 ""
297
298# the commit built, "" for none (EZ-TOOL-4)
299def rev(+world: TW.World) -> String:
300 rev.of(src(world), world)
301
302# ---------------------------------------------------------------------------
303# where things are
304
305# the cache root, "" for none
306def root(+world: TW.World) -> String:
307 TW.text.or(TW.cache.of(world))
308
309# the cache directory of a target: `<root>/<slug>` for a remote, and
310# `<root>/local<abs>` for a path
311def dir.of(pl: Tgt.Place, +root: String, +world: TW.World) -> String:
312 match pl:
313 case Tgt.Bad{_why}:
314 root
315 case Tgt.Local{p}:
316 root ++ "/local" ++ loc.abs(loc(p, world))
317 case Tgt.Remote{_url, slug}:
318 root ++ "/" ++ slug
319
320# the target's cache directory
321def dir(+world: TW.World) -> String:
322 dir.of(place(world), root(world), world)
323
324# a remote's checkout of a commit lives in its own directory, so a pin and a
325# free run at other commits never clone over each other
326def revdir(+world: TW.World) -> String:
327 dir(world) ++ "/" ++ rev(world)
328
329# the checkout's record, written once every file of it is laid
330def stamp.at(+world: TW.World) -> String:
331 revdir(world) ++ "/rev"
332
333# the checkout: under the cache for a remote, the path itself for a path
334def top.of(pl: Tgt.Place, +world: TW.World) -> String:
335 match pl:
336 case Tgt.Bad{_why}:
337 ""
338 case Tgt.Local{p}:
339 loc.abs(loc(p, world))
340 case Tgt.Remote{_url, _slug}:
341 revdir(world) ++ "/src"
342
343# where the checkout is
344def top(+world: TW.World) -> String:
345 top.of(place(world), world)
346
347# the library the checkout's lock is fetched into: the checkout's own for a
348# remote, and one under the cache for a path, which is never written into
349def lib.of(pl: Tgt.Place, +world: TW.World) -> String:
350 match pl:
351 case Tgt.Bad{_why}:
352 ""
353 case Tgt.Local{_p}:
354 dir(world) ++ "/lib"
355 case Tgt.Remote{_url, _slug}:
356 top(world) ++ "/.ez/lib"
357
358# ---------------------------------------------------------------------------
359# answers read
360
361# a file's text as it was answered, None when it is missing or not answered
362def text.of(got: TW.Heard) -> Maybe<&2, String>:
363 match got:
364 case TW.Open{}:
365 None{}
366 case TW.Heard{answer}:
367 match answer:
368 case TW.Text{m}:
369 m
370 case _:
371 None{}
372
373def read.got(+world: TW.World, +path: String) -> Maybe<&2, String>:
374 text.of(TW.heard(world, TW.Read{path}))
375
376# whether a question is still open
377def open.of(got: TW.Heard) -> Bool:
378 match got:
379 case TW.Open{}:
380 True{}
381 case TW.Heard{_a}:
382 False{}
383
384# whether a question of ours is still open
385def open(+world: TW.World, +ask: TW.Ask) -> Bool:
386 open.of(TW.heard(world, ask))
387
388# a question kept when it is still open
389def opens.step(open: Bool, ask: TW.Ask, rest: List<&2, TW.Ask>) -> List<&2, TW.Ask>:
390 match open:
391 case True{}:
392 ask <> rest
393 case False{}:
394 rest
395
396# the questions of these still open
397def opens(+world: TW.World, asks: List<&2, TW.Ask>) -> List<&2, TW.Ask>:
398 match asks:
399 case []:
400 []
401 case +h <> t:
402 opens.step(open(world, h), h, opens(world, t))
403
404# whether an answer said yes
405def yes.of(got: TW.Heard) -> Bool:
406 match got:
407 case TW.Open{}:
408 False{}
409 case TW.Heard{answer}:
410 match answer:
411 case TW.Is{yes}:
412 yes
413 case _:
414 False{}
415
416# ---------------------------------------------------------------------------
417# the checkout
418
419# the checkout's ledger and lock, read where it is; the files of one cloned
420# now; the questions still open; or why there is none
421type Co is Data:
422 Kept{toml: Maybe<&2, String>, lock: Maybe<&2, String>}
423 Laid{files: List<&2, K.Source>}
424 CoWait{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
425 CoNo{why: String}
426
427# the files a checkout at a rev is made of, asked of git
428def clone.ask(+world: TW.World, +url: String) -> Up.Ask:
429 Up.Clone{url, rev(world)}
430
431# the checkout git gave, still to ask, or why there is none
432def co.clone(got: Up.Heard, ask: Up.Ask) -> Co:
433 match got:
434 case Up.Open{}:
435 CoWait{[], [ask]}
436 case Up.Heard{answer}:
437 match answer:
438 case Up.Cloned{_nar, fs}:
439 Laid{fs}
440 case Up.Miss{why}:
441 CoNo{why}
442 case _:
443 CoNo{"ez: a checkout was not answered with one"}
444
445# the three files of a cached checkout
446def reads(+world: TW.World) -> List<&2, TW.Ask>:
447 [TW.Read{stamp.at(world)}, TW.Read{top(world) ++ "/ez.toml"},
448 TW.Read{top(world) ++ "/ez.lock.toml"}]
449
450# the cached checkout is this commit and finished, or it is cloned again
451def co.fresh(fresh: Bool, +url: String, +world: TW.World) -> Co:
452 match fresh:
453 case True{}:
454 Kept{read.got(world, top(world) ++ "/ez.toml"), read.got(world, top(world) ++ "/ez.lock.toml")}
455 case False{}:
456 co.clone(Up.heard(TW.ups(world), clone.ask(world, url)), clone.ask(world, url))
457
458# whether the cached checkout's record names this commit. The record is
459# written after every file, so a checkout it names is whole, with or without
460# a ledger.
461def fresh(+world: TW.World) -> Bool:
462 Key.checkout(String.trim(TW.text.or(read.got(world, stamp.at(world)))), rev(world))
463
464# a remote's cached checkout, once its three files are read
465def co.remote(open: List<&2, TW.Ask>, +url: String, +world: TW.World) -> Co:
466 match open:
467 case []:
468 co.fresh(fresh(world), url, world)
469 case h <> t:
470 CoWait{h <> t, []}
471
472# a path's ledger and lock, once they are read
473def co.local(open: List<&2, TW.Ask>, +world: TW.World) -> Co:
474 match open:
475 case []:
476 Kept{read.got(world, top(world) ++ "/ez.toml"), read.got(world, top(world) ++ "/ez.lock.toml")}
477 case h <> t:
478 CoWait{h <> t, []}
479
480# the checkout of a place
481def co.of(pl: Tgt.Place, +world: TW.World) -> Co:
482 match pl:
483 case Tgt.Bad{why}:
484 CoNo{"ez: " ++ why}
485 case Tgt.Local{_p}:
486 co.local(opens(world, [TW.Read{top(world) ++ "/ez.toml"},
487 TW.Read{top(world) ++ "/ez.lock.toml"}]), world)
488 case Tgt.Remote{url, _slug}:
489 co.remote(opens(world, reads(world)), url, world)
490
491# the checkout of the target
492def co(+world: TW.World) -> Co:
493 co.of(place(world), world)
494
495# the checkout's ez.toml
496def co.toml(checkout: Co) -> Maybe<&2, String>:
497 match checkout:
498 case Kept{t, _l}:
499 t
500 case Laid{fs}:
501 K.look(fs, "ez.toml")
502 case CoWait{_as, _us}:
503 None{}
504 case CoNo{_why}:
505 None{}
506
507# the checkout's ez.lock.toml
508def co.lock(checkout: Co) -> Maybe<&2, String>:
509 match checkout:
510 case Kept{_t, l}:
511 l
512 case Laid{fs}:
513 K.look(fs, "ez.lock.toml")
514 case CoWait{_as, _us}:
515 None{}
516 case CoNo{_why}:
517 None{}
518
519# ---------------------------------------------------------------------------
520# the checkout's ledger, and the binary
521
522# the checkout, as a person knows it: a remote at its commit, or a path
523def label.of(pl: Tgt.Place, +world: TW.World) -> String:
524 match pl:
525 case Tgt.Bad{why}:
526 why
527 case Tgt.Local{_p}:
528 top(world)
529 case Tgt.Remote{url, _slug}:
530 url ++ " at " ++ rev(world)
531
532# the checkout, as a person knows it
533def label(+world: TW.World) -> String:
534 label.of(place(world), world)
535
536# the checkout's ledger as read. A checkout with no ez.toml is a plain Bend
537# repository, whose ledger names nothing: no package, no entry, no deps.
538def led.of(got: Maybe<&2, String>) -> M.Read:
539 match got:
540 case None{}:
541 M.Good{M.Manifest{"", "", "", "", "", "", [], []}}
542 case Some{text}:
543 M.parse(text)
544
545# the checkout's ledger, read
546def led(+world: TW.World) -> M.Read:
547 led.of(co.toml(co(world)))
548
549# whether the checkout is a plain Bend repository: it has no ez.toml, so no
550# git dependencies and no lock. Its program's hub imports are bend's to
551# fetch, into a library of its own under the cache.
552def plain(+world: TW.World) -> Bool:
553 none(co.toml(co(world)))
554
555# where the checkout's lock is fetched, or, for a plain repository, where
556# bend fetches its hub imports: a library of the target's own under the
557# cache, beside its binaries
558def lib(+world: TW.World) -> String:
559 Bool.pick(String, plain(world), dir(world) ++ "/lib", lib.of(place(world), world))
560
561# the model of a ledger that read, an empty one otherwise
562def man.of(parsed: M.Read) -> M.Manifest:
563 match parsed:
564 case M.Bad{_why}:
565 M.Manifest{"", "", "", "", "", "", [], []}
566 case M.Good{m}:
567 m
568
569# the checkout's ledger, empty when it did not read
570def man(+world: TW.World) -> M.Manifest:
571 man.of(led(world))
572
573# the checkout's `[package] name`, `bin` and `entry`
574def man.name(manifest: M.Manifest) -> String:
575 M.Manifest{n, _e, _b, _h, _pa, _pv, _ds, _ts} = manifest
576 n
577
578# a ledger's `bin`
579def man.bin(manifest: M.Manifest) -> String:
580 M.Manifest{_n, _e, b, _h, _pa, _pv, _ds, _ts} = manifest
581 b
582
583# a ledger's `entry`
584def man.entry(manifest: M.Manifest) -> String:
585 M.Manifest{_n, e, _b, _h, _pa, _pv, _ds, _ts} = manifest
586 e
587
588# a file's last component without its `.bend`: `src/cli.bend` is `cli`
589def stem(+file: String) -> String:
590 Path.unbend(Path.base(file))
591
592# a repository's name: the last component of its URL or path, a trailing
593# slash and `.git` taken off, so `https://github.com/o/bolt.git` is `bolt`
594def repo.name(+url: String) -> String:
595 Path.base(Tgt.ungit(Tgt.unslash(url)))
596
597# the link's name for a plain repository: the stem of the file built, or the
598# repository's name when that is `main`, since every plain repository has one
599def plain.name(+file: String, +url: String) -> String:
600 Bool.pick(String, String.eq(stem(file), "main"), repo.name(url), stem(file))
601
602# where the checkout came from, as a person named it: a remote's URL, or a
603# path's real path
604def origin.of(pl: Tgt.Place, +world: TW.World) -> String:
605 match pl:
606 case Tgt.Bad{_why}:
607 ""
608 case Tgt.Local{_p}:
609 top(world)
610 case Tgt.Remote{url, _slug}:
611 url
612
613# where the checkout came from
614def origin(+world: TW.World) -> String:
615 origin.of(place(world), world)
616
617# the checkout's `bin`
618def bin(+world: TW.World) -> String:
619 man.bin(man(world))
620
621# the checkout's `entry`
622def entry(+world: TW.World) -> String:
623 man.entry(man(world))
624
625# whether a package name can name the link and the binary: none at all, or a
626# TOML bare key, so it holds no `/` and cannot be `..`
627def name.ok(+name: String) -> Bool:
628 Bool.or(String.is_empty(name), Named.bare(name))
629
630# the file built: the pin's `bin`, then its `entry`, then the checkout's
631# `bin`, then its `entry`, then `main.bend` (EZ-TOOL-7)
632def chosen(+over: String, +binary: String, +entry: String) -> String:
633 Bool.pick(String, String.is_empty(over), Tgt.file.of(binary, entry), over)
634
635# the file asked for ahead of the checkout's own: the pin's `bin` or
636# `entry`, else the file `--entry` names, else "" so the checkout chooses. A
637# pin is what the project decided, so a flag does not move it; over a
638# checkout's ledger the flag wins, as cargo's `--bin` does.
639def wish(+over: String, +want: String) -> String:
640 Bool.pick(String, String.is_empty(over), want, over)
641
642# the file built, relative to the checkout: `main.bend` for a plain
643# repository unless `--entry` names another (EZ-TOOL-7)
644def choice(+world: TW.World) -> String:
645 chosen(wish(over(world), TW.want(world)), bin(world), entry(world))
646
647# the file built, in the checkout
648def file(+world: TW.World) -> String:
649 Path.join(top(world), choice(world))
650
651# a path's part under a directory, "" when it is not under it
652def inside(+dir: String, +path: String) -> String:
653 Bool.pick(String, String.starts_with(path, dir ++ "/"),
654 String.drop(path, Nat.add(String.length(dir), 1n)), "")
655
656# whether the file built is in the checkout: among the files git gave, or
657# read where the checkout is. A file outside the checkout is not in it.
658def present.of(checkout: Co, +world: TW.World) -> Bool:
659 match checkout:
660 case Laid{fs}:
661 Bool.not(none(K.look(fs, inside(top(world), file(world)))))
662 case Kept{_t, _l}:
663 Bool.and(Bool.not(String.is_empty(inside(top(world), file(world)))),
664 Bool.not(none(read.got(world, file(world)))))
665 case CoWait{_as, _us}:
666 False{}
667 case CoNo{_why}:
668 False{}
669
670# whether the file built is there
671def present(+world: TW.World) -> Bool:
672 present.of(co(world), world)
673
674# the question whether the file built is there, for a checkout read where it
675# is; git gave every file of one cloned now
676def file.asks.of(checkout: Co, +world: TW.World) -> List<&2, TW.Ask>:
677 match checkout:
678 case Kept{_t, _l}:
679 opens(world, [TW.Read{file(world)}])
680 case Laid{_fs}:
681 []
682 case CoWait{_as, _us}:
683 []
684 case CoNo{_why}:
685 []
686
687def file.asks(+world: TW.World) -> List<&2, TW.Ask>:
688 file.asks.of(co(world), world)
689
690# the name the link and the binary are given: for a plain repository the
691# one `plain.name` gives it, and the package's for an ez project
692def name.of(bare: Bool, +world: TW.World) -> String:
693 match bare:
694 case True{}:
695 plain.name(choice(world), origin(world))
696 case False{}:
697 man.name(man(world))
698
699# the name the link and the binary are given
700def name.raw(+world: TW.World) -> String:
701 name.of(plain(world), world)
702
703# the link's name: the package's, or `app` (EZ-TOOL-7)
704def name(+world: TW.World) -> String:
705 Tgt.out.name(name.raw(world))
706
707# what the binary is made from: the commit, the file and the bend (EZ-TOOL-2)
708def key(+world: TW.World) -> Key.Stamp:
709 Key.Stamp{rev(world), file(world), TW.bend(world)}
710
711# the directory a record's binary lives in, named by the record
712def stamp.of(key: Key.Stamp) -> String:
713 String.take(Sha.hex(Key.show(key)), 16n)
714
715# the directory of this record's binary
716def bdir(+world: TW.World) -> String:
717 dir(world) ++ "/bin/" ++ stamp.of(key(world))
718
719# the binary
720def out(+world: TW.World) -> String:
721 bdir(world) ++ "/" ++ name(world) ++ ".out"
722
723def key.at(+world: TW.World) -> String:
724 bdir(world) ++ "/key"
725
726# the binary's record as it was written, none when there is none
727def was(+world: TW.World) -> Key.Stamp:
728 Key.read(String.trim(TW.text.or(read.got(world, key.at(world)))))
729
730# whether the binary is there
731def there(+world: TW.World) -> Bool:
732 yes.of(TW.heard(world, TW.Exec{out(world)}))
733
734# the binary is reused when it is there and its record is this one
735def reuse(+world: TW.World) -> Bool:
736 Bool.and(there(world), Key.reuse(was(world), key(world)))
737
738# ---------------------------------------------------------------------------
739# the plan
740
741# the checkout laid under the cache, its record last, or nothing laid
742type Lay is Data:
743 Lay{dir: String, writes: List<&2, P.Effect>}
744 Still{}
745
746# the binary: the lock of the checkout at `top` fetched into the library
747# when `fetch` says it has one, the file built into `out` with that library
748# as BEND_LIB, and the record written at `at`; or the one there reused; or
749# nothing, for a command that refuses
750type Make is Data:
751 Make{top: String, lib: String, fetch: Bool, file: String, out: String, at: String,
752 key: String, name: String}
753 Reuse{name: String}
754 Skip{}
755
756# what is done with the binary: started with these words, as the last thing;
757# linked into `dir` under `name`, with the lines a person is told; or nothing
758type Finish is Data:
759 Launch{bin: String, args: List<&2, String>}
760 Link{bin: String, dir: String, name: String, says: List<&2, String>}
761 Idle{}
762
763# everything a tool command does, in order, and how it ends
764type Plan is Data:
765 Plan{lay: Lay, make: Make, finish: Finish, outcome: P.Outcome}
766
767# every file of a checkout written under its top
768def writes.of(+top: String, fs: List<&2, K.Source>) -> List<&2, P.Effect>:
769 match fs:
770 case []:
771 []
772 case K.Source{at, text} <> t:
773 P.Write{top ++ "/" ++ at, text} <> writes.of(top, t)
774
775# the checkout laid, when git gave it now
776def lay.of(checkout: Co, +world: TW.World) -> Lay:
777 match checkout:
778 case Laid{fs}:
779 Lay{revdir(world), List.append(&2, P.Effect, writes.of(top(world), fs),
780 [P.Write{stamp.at(world), rev(world) ++ "\n"}])}
781 case Kept{_t, _l}:
782 Still{}
783 case CoWait{_as, _us}:
784 Still{}
785 case CoNo{_why}:
786 Still{}
787
788# the checkout laid under the cache, when it was cloned now
789def lay(+world: TW.World) -> Lay:
790 lay.of(co(world), world)
791
792# the binary reused, or built with its record
793def make.of(reused: Bool, +world: TW.World) -> Make:
794 match reused:
795 case True{}:
796 Reuse{name(world)}
797 case False{}:
798 Make{top(world), lib(world), Bool.not(plain(world)), file(world), out(world),
799 key.at(world), Key.show(key(world)), name(world)}
800
801# the binary built, or reused
802def make(+world: TW.World) -> Make:
803 make.of(reuse(world), world)
804
805# one step of a scan that may stop at the first hit
806def seen.step(here: Bool, rest: Unit -> Bool) -> Bool:
807 match here:
808 case True{}:
809 True{}
810 case False{}:
811 rest(Unit{})
812
813# a directory with no trailing slash
814def bare(+dir: String) -> String:
815 Bool.pick(String, String.ends_with(dir, "/"),
816 String.take(dir, Nat.sub(String.length(dir), 1n)), dir)
817
818# whether this directory is one of the entries
819def seen(+dir: String, ps: List<&2, String>) -> Bool:
820 match ps:
821 case []:
822 False{}
823 case +h <> t:
824 seen.step(String.eq(bare(h), bare(dir)), _u => seen(dir, t))
825
826# whether a directory is an entry of `PATH`
827def on.path(+dir: String, +path: String) -> Bool:
828 seen(dir, String.split(path, ':'))
829
830# the link directory, "" for none
831def bin.at(+world: TW.World) -> String:
832 TW.text.or(TW.bin.of(TW.env(world)))
833
834# what a person is told once the link is written: that the directory is not
835# on PATH, when it is not, and what was linked where
836def says(install: Bool, +world: TW.World) -> List<&2, String>:
837 +called = name(world)
838 +at = bin.at(world) ++ "/" ++ called
839 +done = Bool.pick(String, install, Say.installed(called, at), Say.linked(called, at))
840 Bool.pick(List<&2, String>, on.path(bin.at(world), TW.env.path(TW.env(world))), [done],
841 ["warning: " ++ bin.at(world) ++ " is not on PATH", done])
842
843# the program started, or the link written
844def finish.of(act: TW.Act, +world: TW.World) -> Finish:
845 match act:
846 case TW.Run{args}:
847 Launch{out(world), args}
848 case TW.Install{}:
849 Link{out(world), bin.at(world), name(world), says(True{}, world)}
850 case TW.Upgrade{}:
851 Link{out(world), bin.at(world), name(world), says(False{}, world)}
852
853# what is done with the binary
854def finish(+world: TW.World) -> Finish:
855 finish.of(TW.act(world), world)
856
857# the plan once every answer is in and nothing refused
858def made(+world: TW.World) -> Plan:
859 Plan{lay(world), make(world), finish(world), P.Success{}}
860
861# ---------------------------------------------------------------------------
862# where a World stands
863
864# every answer is in, a question is still open, or the command refuses
865type Verdict is Data:
866 Go{}
867 Wait{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
868 Stop{why: String}
869
870# a lock is needed only for a binary that is built, and only by an ez
871# project: a plain repository has none to have
872def decide.lock(missing: Bool, +world: TW.World) -> Verdict:
873 match missing:
874 case True{}:
875 Stop{"ez: " ++ label(world) ++ " has an ez.toml but no ez.lock.toml; its author " ++
876 "runs ez lock to write one"}
877 case False{}:
878 Go{}
879
880# whether the binary is to be built from an ez project with no lock
881def unlocked(+world: TW.World) -> Bool:
882 Bool.and(Bool.not(reuse(world)),
883 Bool.and(Bool.not(plain(world)), none(co.lock(co(world)))))
884
885# the binary's record and whether it is there, asked together
886def cache.asks(+world: TW.World) -> List<&2, TW.Ask>:
887 opens(world, [TW.Read{key.at(world)}, TW.Exec{out(world)}])
888
889# the binary's record and whether it is there, still to ask
890def decide.cache(open: List<&2, TW.Ask>, +world: TW.World) -> Verdict:
891 match open:
892 case []:
893 decide.lock(unlocked(world), world)
894 case h <> t:
895 Wait{h <> t, []}
896
897# why a missing file is refused: a file a pin names is the project's to fix,
898# and any other can be named with `--entry`
899def missing.why(+world: TW.World) -> String:
900 +why = "ez: " ++ label(world) ++ " has no " ++ choice(world)
901 Bool.pick(String, String.is_empty(over(world)),
902 why ++ "; name the file to build with --entry <path.bend>", why)
903
904# a file to build that is not in the checkout is refused
905def decide.present(ok: Bool, +world: TW.World) -> Verdict:
906 match ok:
907 case True{}:
908 decide.cache(cache.asks(world), world)
909 case False{}:
910 Stop{missing.why(world)}
911
912# whether the file to build is there, still to ask
913def decide.file(open: List<&2, TW.Ask>, +world: TW.World) -> Verdict:
914 match open:
915 case []:
916 decide.present(present(world), world)
917 case h <> t:
918 Wait{h <> t, []}
919
920# what a name that could not name a file is: a package's, or the one a
921# plain repository would be linked as
922def name.what(+world: TW.World) -> String:
923 Bool.pick(String, plain(world), " would be linked as ", " names its package ")
924
925# a name that could not name a file is refused
926def decide.name(ok: Bool, +world: TW.World) -> Verdict:
927 match ok:
928 case True{}:
929 decide.file(file.asks(world), world)
930 case False{}:
931 Stop{"ez: " ++ label(world) ++ name.what(world) ++ name.raw(world) ++
932 ", which is not a TOML bare key"}
933
934# a checkout ledger that does not parse is refused
935def decide.led(parsed: M.Read, +world: TW.World) -> Verdict:
936 match parsed:
937 case M.Bad{why}:
938 Stop{"ez: error: " ++ why}
939 case M.Good{_m}:
940 decide.name(name.ok(name.raw(world)), world)
941
942# the checkout answered, still to ask, or not to be had
943def decide.co(checkout: Co, +world: TW.World) -> Verdict:
944 match checkout:
945 case Kept{_t, _l}:
946 decide.led(led(world), world)
947 case Laid{_fs}:
948 decide.led(led(world), world)
949 case CoWait{as, us}:
950 Wait{as, us}
951 case CoNo{why}:
952 Stop{why}
953
954# what git says of a path, still to ask, or not to be had
955def decide.loc(spot: Loc, +world: TW.World) -> Verdict:
956 match spot:
957 case Loc{_a, _t, _h, _ok, _s}:
958 decide.co(co(world), world)
959 case LocWait{ask}:
960 Wait{[ask], []}
961 case LocNo{why}:
962 Stop{why}
963
964# a remote's HEAD has to name a commit
965def decide.tip(ok: Bool, +url: String, +world: TW.World) -> Verdict:
966 match ok:
967 case True{}:
968 decide.co(co(world), world)
969 case False{}:
970 Stop{"ez: " ++ url ++ " has no HEAD"}
971
972def decide.said(said: Up.Said, +url: String, +world: TW.World) -> Verdict:
973 match said:
974 case Up.Said{_text}:
975 decide.tip(Git.is_rev(rev(world)), url, world)
976 case Up.Unsaid{why}:
977 Stop{why}
978 case Up.Unasked{ask}:
979 Wait{[], [ask]}
980
981# a pin's commit is the lock's; a free remote's is asked of its HEAD
982def decide.head(found: Src, +url: String, +world: TW.World) -> Verdict:
983 match found:
984 case Pin{_n, _u, _r, _o}:
985 decide.co(co(world), world)
986 case Free{_pl}:
987 decide.said(head.said(world, url), url, world)
988 case NoSrc{why}:
989 Stop{why}
990
991# a slug that could leave the cache is refused (EZ-TOOL-8)
992def decide.slug(ok: Bool, +url: String, +world: TW.World) -> Verdict:
993 match ok:
994 case True{}:
995 decide.head(src(world), url, world)
996 case False{}:
997 Stop{"ez: not a repository"}
998
999def decide.place(pl: Tgt.Place, +world: TW.World) -> Verdict:
1000 match pl:
1001 case Tgt.Bad{why}:
1002 Stop{"ez: " ++ why}
1003 case Tgt.Local{p}:
1004 decide.loc(loc(p, world), world)
1005 case Tgt.Remote{url, slug}:
1006 decide.slug(Tgt.slug.ok(slug), url, world)
1007
1008# a pin at no commit cannot be built; its rev is a directory name, so it has
1009# to be one
1010def decide.pin(ok: Bool, +name: String, +rev: String, +world: TW.World) -> Verdict:
1011 match ok:
1012 case True{}:
1013 decide.place(place(world), world)
1014 case False{}:
1015 Stop{"ez: tools." ++ name ++ " in ez.lock.toml has no commit" ++
1016 Bool.pick(String, String.is_empty(rev), "", ": " ++ rev)}
1017
1018# what the target names, or why it names nothing
1019def decide.src(found: Src, +world: TW.World) -> Verdict:
1020 match found:
1021 case Pin{+n, _u, +r, _o}:
1022 decide.pin(Git.is_rev(r), n, r, world)
1023 case Free{_pl}:
1024 decide.place(place(world), world)
1025 case NoSrc{why}:
1026 Stop{why}
1027
1028# no cache root is refused
1029def decide.root(got: Maybe<&2, String>, +world: TW.World) -> Verdict:
1030 match got:
1031 case None{}:
1032 Stop{"ez: HOME is unset, so there is no cache"}
1033 case Some{_r}:
1034 decide.src(src(world), world)
1035
1036# nowhere to link is refused before anything is built
1037def decide.bin(nowhere: Bool, +world: TW.World) -> Verdict:
1038 match nowhere:
1039 case True{}:
1040 Stop{"ez: HOME is unset, so there is no bin directory"}
1041 case False{}:
1042 decide.root(TW.cache.of(world), world)
1043
1044# where a World stands
1045def decide(+world: TW.World) -> Verdict:
1046 decide.bin(Bool.and(links.act(TW.act(world)), none(TW.bin.of(TW.env(world)))), world)
1047
1048# the plan for where the World stands. One that still asks is a World the
1049# interpreter did not finish, and is refused rather than guessed at.
1050def plan.v(verdict: Verdict, +world: TW.World) -> Plan:
1051 match verdict:
1052 case Go{}:
1053 made(world)
1054 case Wait{_as, _us}:
1055 Plan{Still{}, Skip{}, Idle{}, P.Refused{"ez: " ++ TW.target(world) ++ " was never resolved"}}
1056 case Stop{why}:
1057 Plan{Still{}, Skip{}, Idle{}, P.Refused{why}}
1058
1059# `ez tool run`, `install` or `upgrade` over a World
1060def plan(+world: TW.World) -> Plan:
1061 plan.v(decide(world), world)
1062
1063# whether a plan ends in a refusal
1064def refuses.plan(pl: Plan) -> Bool:
1065 Plan{_l, _m, _f, outcome} = pl
1066 match outcome:
1067 case P.Success{}:
1068 False{}
1069 case P.Refused{_why}:
1070 True{}
1071
1072# whether a tool command refuses on a World
1073def refuses(+world: TW.World) -> Bool:
1074 refuses.plan(plan(world))
1075
1076# the questions still open, or the plan once there are none
1077type Step is Data:
1078 Asking{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
1079 Run{plan: Plan}
1080
1081# the step for where the World stands
1082def step.v(verdict: Verdict, +world: TW.World) -> Step:
1083 match verdict:
1084 case Go{}:
1085 Run{made(world)}
1086 case Wait{as, us}:
1087 Asking{as, us}
1088 case Stop{why}:
1089 Run{plan.v(Stop{why}, world)}
1090
1091# what the interpreter does next on a World
1092def step(+world: TW.World) -> Step:
1093 step.v(decide(world), world)
1094
1095# ---------------------------------------------------------------------------
1096# `ez tool sync`
1097
1098# the pins a sync installs, in the order the ledger names them, or why it
1099# installs none
1100type Sync is Data:
1101 Sync{names: List<&2, String>}
1102 SyncNo{why: String}
1103
1104# a pin the lock lacks, or has at no commit, stops the scan; any other goes
1105# on to the rest
1106def sync.step(bad: Bool, +name: String, rest: Unit -> String) -> String:
1107 match bad:
1108 case True{}:
1109 "ez: tools." ++ name ++ " is not pinned at a commit in ez.lock.toml"
1110 case False{}:
1111 rest(Unit{})
1112
1113# whether the lock pins a tool at a commit
1114def sync.pinned(+tool: M.Tool) -> Bool:
1115 Bool.and(Bool.not(String.is_empty(M.tool.name(tool))),
1116 Git.is_rev(M.source.rev(M.tool.source(tool))))
1117
1118# every tool the ledger names has to be pinned at a commit in the lock; why
1119# the first that is not is refused, "" when every one is
1120def sync.check(ts: List<&2, M.Tool>, +body: String) -> String:
1121 match ts:
1122 case []:
1123 ""
1124 case +h <> t:
1125 sync.step(Bool.not(sync.pinned(pin.in(body, M.tool.name(h)))), M.tool.name(h),
1126 _u => sync.check(t, body))
1127
1128# the names of the tools a ledger pins
1129def names.of(ts: List<&2, M.Tool>) -> List<&2, String>:
1130 match ts:
1131 case []:
1132 []
1133 case h <> t:
1134 M.tool.name(h) <> names.of(t)
1135
1136# every name installed, or the refusal the scan found
1137def sync.why(ok: Bool, +why: String, ts: List<&2, M.Tool>) -> Sync:
1138 match ok:
1139 case True{}:
1140 Sync{names.of(ts)}
1141 case False{}:
1142 SyncNo{why}
1143
1144# a ledger with pins needs a lock that pins each at a commit
1145def sync.lock(got: Maybe<&2, String>, +ts: List<&2, M.Tool>) -> Sync:
1146 match got:
1147 case None{}:
1148 Bool.pick(Sync, List.is_empty(&2, M.Tool, ts), Sync{[]},
1149 SyncNo{"ez: ez.lock.toml is not there"})
1150 case Some{body}:
1151 +why = sync.check(ts, body)
1152 sync.why(String.is_empty(why), why, ts)
1153
1154# a project ledger that does not parse is refused
1155def sync.read(led: M.Read, got: Maybe<&2, String>) -> Sync:
1156 match led:
1157 case M.Bad{why}:
1158 SyncNo{"ez: error: " ++ why}
1159 case M.Good{m}:
1160 sync.lock(got, tools.of(m))
1161
1162# `ez tool sync` over the project's ledger and lock. With no ledger there is
1163# no project to sync, and it is refused, as `ez lock` and `ez fetch` refuse
1164# one.
1165def sync(ledger: Maybe<&2, String>, lock: Maybe<&2, String>) -> Sync:
1166 match ledger:
1167 case None{}:
1168 SyncNo{"ez: no ez.toml here; ez tool sync installs the tools a project's " ++
1169 "ledger pins, and ez init makes one"}
1170 case Some{text}:
1171 sync.read(M.parse(text), lock)
1172
1173# how a sync ends once every install it runs has ended: 1 with the reason
1174# when it refused, and 0 when it did not (EZ-OUT-1). An install that refuses
1175# ends the sync there, with its own status.
1176def sync.outcome(synced: Sync) -> P.Outcome:
1177 match synced:
1178 case Sync{_ns}:
1179 P.Success{}
1180 case SyncNo{why}:
1181 P.Refused{why}
1182
1183# whether a sync refuses
1184def sync.refuses(synced: Sync) -> Bool:
1185 match synced:
1186 case Sync{_ns}:
1187 False{}
1188 case SyncNo{_why}:
1189 True{}
1190
1191# the names a sync installs, none when it refuses
1192def sync.names(synced: Sync) -> List<&2, String>:
1193 match synced:
1194 case Sync{ns}:
1195 ns
1196 case SyncNo{_why}:
1197 []
1198
1199# ---------------------------------------------------------------------------
1200# law vocabulary
1201
1202# whether a checkout is laid
1203def lays(layout: Lay) -> Bool:
1204 match layout:
1205 case Lay{_d, _ws}:
1206 True{}
1207 case Still{}:
1208 False{}
1209
1210# whether a binary is built
1211def makes(making: Make) -> Bool:
1212 match making:
1213 case Make{_lk, _lb, _fe, _f, _o, _at, _k, _n}:
1214 True{}
1215 case Reuse{_n}:
1216 False{}
1217 case Skip{}:
1218 False{}
1219
1220# whether a link is written
1221def linked(fin: Finish) -> Bool:
1222 match fin:
1223 case Launch{_b, _as}:
1224 False{}
1225 case Link{_b, _d, _n, _s}:
1226 True{}
1227 case Idle{}:
1228 False{}
1229
1230# whether a plan writes anything: a checkout laid, a binary built, a link
1231def writes(pl: Plan) -> Bool:
1232 Plan{l, m, f, _o} = pl
1233 Bool.or(lays(l), Bool.or(makes(m), linked(f)))
1234
1235# whether a plan builds the binary
1236def builds(pl: Plan) -> Bool:
1237 Plan{_l, m, _f, _o} = pl
1238 makes(m)
1239
1240# the file a plan builds, "" when it builds none
1241def built(pl: Plan) -> String:
1242 Plan{_l, m, _f, _o} = pl
1243 match m:
1244 case Make{_lk, _lb, _fe, f, _o, _at, _k, _n}:
1245 f
1246 case Reuse{_n}:
1247 ""
1248 case Skip{}:
1249 ""
1250
1251# whether a plan fetches a lock before it builds
1252def fetches(pl: Plan) -> Bool:
1253 Plan{_l, m, _f, _o} = pl
1254 match m:
1255 case Make{_lk, _lb, fe, _f, _o, _at, _k, _n}:
1256 fe
1257 case Reuse{_n}:
1258 False{}
1259 case Skip{}:
1260 False{}
1261
1262# the library a plan builds with, "" when it builds nothing
1263def built.lib(pl: Plan) -> String:
1264 Plan{_l, m, _f, _o} = pl
1265 match m:
1266 case Make{_lk, lb, _fe, _f, _o, _at, _k, _n}:
1267 lb
1268 case Reuse{_n}:
1269 ""
1270 case Skip{}:
1271 ""
1272
1273# whether a plan starts the program
1274def launches(pl: Plan) -> Bool:
1275 Plan{_l, _m, f, _o} = pl
1276 match f:
1277 case Launch{_b, _as}:
1278 True{}
1279 case Link{_b, _d, _n, _s}:
1280 False{}
1281 case Idle{}:
1282 False{}
1283
1284# the path a plan links, "" when it links none
1285def link.at(pl: Plan) -> String:
1286 Plan{_l, _m, f, _o} = pl
1287 match f:
1288 case Launch{_b, _as}:
1289 ""
1290 case Link{_b, d, n, _s}:
1291 d ++ "/" ++ n
1292 case Idle{}:
1293 ""
1294
1295# how a command ends: exit 1, having started nothing; exit 0; or with the
1296# status of this program, started with these words as the last thing
1297type Exit is Data:
1298 Refusal{}
1299 Finished{}
1300 Exits{bin: String, args: List<&2, String>}
1301
1302# how a plan that goes on ends
1303def exit.of(fin: Finish) -> Exit:
1304 match fin:
1305 case Launch{b, as}:
1306 Exits{b, as}
1307 case Link{_b, _d, _n, _s}:
1308 Finished{}
1309 case Idle{}:
1310 Finished{}
1311
1312# how a plan ends
1313def exit(pl: Plan) -> Exit:
1314 Plan{_l, _m, f, o} = pl
1315 match o:
1316 case P.Success{}:
1317 exit.of(f)
1318 case P.Refused{_why}:
1319 Refusal{}
1320
1321# the status a tool command exits with, given the status the program it
1322# started exited with (EZ-OUT-1): 1 for a refusal, 0 for a command that
1323# ends without starting one, and the program's own status for `ez tool run`.
1324# A command that starts nothing is handed 0 for it.
1325def code(ended: Exit, program: U32) -> U32:
1326 match ended:
1327 case Refusal{}:
1328 1
1329 case Finished{}:
1330 0
1331 case Exits{_b, _as}:
1332 program