ledger/manifest.bend

ezx@1.3.0.0 · 462 lines · 0 definitions

1# ezx: ez's ledger library for Bend 2: it parses an ez.toml into its package, dependencies and pinned tools.
2#
3# ledger/manifest: the dependency ledger. Bend's import lines carry a bare
4# `0x<hash>`, which says nothing about what the package is or where it came
5# from, and nothing in a repo lists them. ez.toml is that list: every package
6# the repo imports, by name, with its hash and its origin.
7import Base
8import ezx@1.3.0.0/toml/toml.bend as T
9
10# where a package's bytes come from. A hub package needs nothing else, though
11# one added by its `<name>@<version>` keeps it, which is what its import lines
12# say and what the hub named the hash by; a git one pins a commit, and
13# remembers the tag that was asked for so an upgrade knows what to
14# re-resolve. It also carries the two facts nothing can work out again
15# without the checkout: the directory inside it the package's paths are written
16# from, and the NAR hash nix rebuilds it by. They live here, in the file that is
17# committed, because a clone gets the ledger and not the vendoring cache.
18# `vend` is the committed tree: the package is allowlisted under `.ez/lib` and
19# an upgrade that moves it lays the new hash there. Absent, it is not.
20type Source is Data:
21  Hub{named: String}
22  Git{url: String, rev: String, tag: String, root: String, nar: String, vend: Bool}
23
24# one tracked dependency
25type Dep is Data:
26  Dep{name: String, hash: String, entry: String, source: Source}
27
28# one pinned CLI. The git fields are a dependency's. It is not a package:
29# it has no `0x` name, it is not imported, and it is not put on BEND_LIB.
30# `entry` and `bin` name the file to build when the tool's own ledger should
31# not be the one.
32type Tool is Data:
33  Tool{name: String, entry: String, bin: String, source: Source}
34
35# the ledger: this package, the file `ez build` and `ez run` take, the file
36# `ez tool run`, `install` and `upgrade` build when one is named, the hub its
37# packages come from ("" for bend's own), the hub name and the version `ez
38# publish` names the package by ("" for none, which publishes it by its hash
39# alone), everything the package depends on, and every CLI it pins
40type Manifest is Data:
41  Manifest{name: String, entry: String, bin: String, hub: String,
42    publish: String, version: String,
43    deps: List<&2, Dep>, tools: List<&2, Tool>}
44
45# a manifest, or why it could not be read
46type Read is Data:
47  Bad{why: String}
48  Good{m: Manifest}
49
50# the value written for a key in a section, or "" when it is absent
51def value(pairs: List<&2, T.Kv>, key: String) -> String:
52  T.value(pairs, key)
53
54# this section's pairs when its name matches, otherwise the rest's
55def pairs.at(sect: T.Sect, rest: List<&2, T.Kv>, name: String) -> List<&2, T.Kv>:
56  T.Sect{n, ps} = sect
57  Bool.pick(List<&2, T.Kv>, String.eq(n, name), ps, rest)
58
59# the pairs of the section with this name, or none
60def pairs_of(ss: List<&2, T.Sect>, +name: String) -> List<&2, T.Kv>:
61  match ss:
62    case []:
63      []
64    case h <> t:
65      pairs.at(h, pairs_of(t, name), name)
66
67# `vendor = true`, bare or quoted, is the only spelling that commits the tree
68def flag(+word: String) -> Bool:
69  String.eq(word, "true")
70
71# no `git` key means the package lives on the hub
72def source.go(hub: Bool, url: String, rev: String, tag: String, root: String, nar: String, vend: Bool) -> Source:
73  match hub:
74    case True{}:
75      Hub{""}
76    case False{}:
77      Git{url, rev, tag, root, nar, vend}
78
79# a hub source, with the `<name>@<version>` a dependency's section records
80# under `hub`, "" for none. A git source keeps no name, and neither does a
81# tool: a tool is not imported, so nothing names it.
82def source.named(named: String, src: Source) -> Source:
83  match src:
84    case Hub{_old}:
85      Hub{named}
86    case Git{url, rev, tag, root, nar, vend}:
87      Git{url, rev, tag, root, nar, vend}
88
89# a git section that names no root was written from the top of its checkout,
90# which is the root a lock already spells out as "." and the only one a ledger
91# written before this key existed can have meant
92def root.or(+dir: String) -> String:
93  Bool.pick(String, String.is_empty(dir), ".", dir)
94
95# where a dependency's section says its bytes come from
96def source_of(+pairs: List<&2, T.Kv>) -> Source:
97  source.go(String.is_empty(value(pairs, "git")), value(pairs, "git"),
98    value(pairs, "rev"), value(pairs, "tag"), root.or(value(pairs, "root")),
99    value(pairs, "narHash"), flag(value(pairs, "vendor")))
100
101# a `[deps.<name>]` section as a dependency. A hub dependency added by its
102# `<name>@<version>` records it as `hub`, beside the hash it named when it
103# was added, as a git dependency records its tag beside its rev.
104def dep_of(+full: String, +pairs: List<&2, T.Kv>) -> Dep:
105  Dep{String.drop(full, 5n), value(pairs, "hash"), value(pairs, "entry"),
106    source.named(value(pairs, "hub"), source_of(pairs))}
107
108# a dependency's own name, for finding or replacing it
109def dep.name(dependency: Dep) -> String:
110  Dep{n, _h, _e, _s} = dependency
111  n
112
113# a dependency's hash, which is the `0x` name its import line carries
114def dep.hash(dependency: Dep) -> String:
115  Dep{_n, h, _e, _s} = dependency
116  h
117
118# the file inside the package a dependency was added by, which is the file its
119# hash was computed from
120def dep.entry(dependency: Dep) -> String:
121  Dep{_n, _h, e, _s} = dependency
122  e
123
124# a section joins the dependencies only when it is a `[deps.<name>]`
125def deps.put(is_dep: Bool, sect: T.Sect, rest: List<&2, Dep>) -> List<&2, Dep>:
126  match is_dep:
127    case True{}:
128      T.Sect{n, ps} = sect
129      dep_of(n, ps) <> rest
130    case False{}:
131      rest
132
133# a section's name, for deciding what it is
134def name_of(sect: T.Sect) -> String:
135  T.Sect{n, _ps} = sect
136  n
137
138# every `[deps.<name>]` section, as dependencies, in the order written
139def deps(ss: List<&2, T.Sect>) -> List<&2, Dep>:
140  match ss:
141    case +h <> t:
142      deps.put(String.starts_with(name_of(h), "deps."), h, deps(t))
143    case []:
144      []
145
146# a `[tools.<name>]` section as a pinned CLI. The prefix is `tools.`
147def tool_of(+full: String, +pairs: List<&2, T.Kv>) -> Tool:
148  Tool{String.drop(full, 6n), value(pairs, "entry"), value(pairs, "bin"),
149    source_of(pairs)}
150
151# a tool's own name
152def tool.name(pin: Tool) -> String:
153  Tool{n, _e, _b, _s} = pin
154  n
155
156# the file a pin names as its entry, or "" when the tool's ledger names it
157def tool.entry(pin: Tool) -> String:
158  Tool{_n, e, _b, _s} = pin
159  e
160
161# the file a pin names as its binary, or "" when the tool's ledger names it
162def tool.bin(pin: Tool) -> String:
163  Tool{_n, _e, b, _s} = pin
164  b
165
166# where a pinned CLI's bytes come from
167def tool.source(pin: Tool) -> Source:
168  Tool{_n, _e, _b, s} = pin
169  s
170
171# a section joins the tools only when it is a `[tools.<name>]`
172def tools.put(is_tool: Bool, sect: T.Sect, rest: List<&2, Tool>) -> List<&2, Tool>:
173  match is_tool:
174    case True{}:
175      T.Sect{n, ps} = sect
176      tool_of(n, ps) <> rest
177    case False{}:
178      rest
179
180# every `[tools.<name>]` section, as pinned CLIs, in the order written
181def tools(ss: List<&2, T.Sect>) -> List<&2, Tool>:
182  match ss:
183    case +h <> t:
184      tools.put(String.starts_with(name_of(h), "tools."), h, tools(t))
185    case []:
186      []
187
188# a dependency with no hash cannot be resolved, so it is reported by name
189def missing.at(bad: Bool, name: String, rest: String) -> String:
190  match bad:
191    case True{}:
192      name
193    case False{}:
194      rest
195
196# a `[deps.<name>]` section is missing its hash when the key is absent
197def missing.is(sect: T.Sect) -> Bool:
198  T.Sect{n, ps} = sect
199  Bool.and(String.starts_with(n, "deps."), String.is_empty(value(ps, "hash")))
200
201# the first dependency section with no hash, or "" when every one has one
202def missing(ss: List<&2, T.Sect>) -> String:
203  match ss:
204    case +h <> t:
205      missing.at(missing.is(h), name_of(h), missing(t))
206    case []:
207      ""
208
209# the first problem with the document, or "" when there is none. A syntax error
210# comes first, since a section that did not parse cannot be judged.
211def problem(+bad: String, +miss: String) -> String:
212  Bool.pick(String, String.is_empty(bad),
213    Bool.pick(String, String.is_empty(miss), "", miss ++ " has no hash"),
214    bad)
215
216# the ledger, once the document is known to be sound
217def build(+ss: List<&2, T.Sect>) -> Read:
218  Good{Manifest{value(pairs_of(ss, "package"), "name"),
219    value(pairs_of(ss, "package"), "entry"),
220    value(pairs_of(ss, "package"), "bin"),
221    value(pairs_of(ss, "package"), "hub"),
222    value(pairs_of(ss, "package"), "publish-as"),
223    value(pairs_of(ss, "package"), "version"), deps(ss), tools(ss)}}
224
225# the ledger, or the problem that stopped it
226def read.go(ok: Bool, why: String, ss: List<&2, T.Sect>) -> Read:
227  match ok:
228    case True{}:
229      build(ss)
230    case False{}:
231      Bad{why}
232
233# the sections judged, then read
234def read(+bad: String, +ss: List<&2, T.Sect>) -> Read:
235  read.go(String.is_empty(problem(bad, missing(ss))),
236    problem(bad, missing(ss)), ss)
237
238# the parsed document, taken apart
239def parse.go(toml: T.Toml) -> Read:
240  T.Toml{bad, _name, _pairs, sects} = toml
241  read(bad, sects)
242
243# the ledger written in an ez.toml
244def parse(text: String) -> Read:
245  parse.go(T.parse(text))
246
247# how a dependency reads on one line
248def show.source(src: Source) -> String:
249  match src:
250    case Hub{_named}:
251      "hub"
252    case Git{_url, _rev, _tag, _root, _nar, _vend}:
253      "git"
254
255# `<name>=<hash>@<hub|git>`
256def show.dep(dependency: Dep) -> String:
257  Dep{n, h, _entry, src} = dependency
258  n ++ "=" ++ h ++ "@" ++ show.source(src)
259
260# every dependency shown, in the order written. Base's List.map is over a
261# one-use list, and these are two-use, so the walk is written out.
262def show.deps(ds: List<&2, Dep>) -> List<&2, String>:
263  match ds:
264    case []:
265      []
266    case h <> t:
267      show.dep(h) <> show.deps(t)
268
269# the ledger on one line, for a test or a report
270def show.m(manifest: Manifest) -> String:
271  Manifest{n, e, _b, _h, _pa, _pv, ds, _ts} = manifest
272  n ++ " " ++ e ++ " " ++ String.join(show.deps(ds), " ")
273
274# the ledger, or the error, on one line
275def show(ledger: Read) -> String:
276  match ledger:
277    case Bad{why}:
278      "error: " ++ why
279    case Good{m}:
280      show.m(m)
281
282# this dependency when its name matches, otherwise whatever the rest gave
283def find.pick(hit: Bool, dependency: Dep, rest: Dep) -> Dep:
284  match hit:
285    case True{}:
286      dependency
287    case False{}:
288      rest
289
290# the dependency of this name, or a blank one
291def find.at(+dependency: Dep, rest: Dep, name: String) -> Dep:
292  Dep{n, _h, _e, _s} = dependency
293  find.pick(String.eq(n, name), dependency, rest)
294
295# the dependency of this name, or one with empty fields
296def find(ds: List<&2, Dep>, +name: String) -> Dep:
297  match ds:
298    case []:
299      Dep{"", "", "", Hub{""}}
300    case h <> t:
301      find.at(h, find(t, name), name)
302
303# the named dependency of a ledger that was read
304def dep(ledger: Read, name: String) -> Dep:
305  match ledger:
306    case Bad{_why}:
307      Dep{"", "", "", Hub{""}}
308    case Good{m}:
309      Manifest{_n, _e, _b, _h, _pa, _pv, ds, _ts} = m
310      find(ds, name)
311
312# the commit a dependency is pinned to, or "" when it is a hub package
313def source.rev(src: Source) -> String:
314  match src:
315    case Hub{_named}:
316      ""
317    case Git{_url, rev, _tag, _root, _nar, _vend}:
318      rev
319
320# the tag a dependency was asked for, or "" when it was pinned by commit
321def source.tag(src: Source) -> String:
322  match src:
323    case Hub{_named}:
324      ""
325    case Git{_url, _rev, tag, _root, _nar, _vend}:
326      tag
327
328# the repo a dependency was vendored from, or "" when it is a hub package
329def source.url(src: Source) -> String:
330  match src:
331    case Hub{_named}:
332      ""
333    case Git{url, _rev, _tag, _root, _nar, _vend}:
334      url
335
336# the directory inside the checkout a dependency's paths are written from
337def source.root(src: Source) -> String:
338  match src:
339    case Hub{_named}:
340      ""
341    case Git{_url, _rev, _tag, root, _nar, _vend}:
342      root
343
344# the NAR hash nix rebuilds a dependency's checkout by
345def source.nar(src: Source) -> String:
346  match src:
347    case Hub{_named}:
348      ""
349    case Git{_url, _rev, _tag, _root, nar, _vend}:
350      nar
351
352# the `<name>@<version>` a hub dependency was added by, or "" for one added
353# by its hash, and for a git one
354def source.nv(src: Source) -> String:
355  match src:
356    case Hub{named}:
357      named
358    case Git{_url, _rev, _tag, _root, _nar, _vend}:
359      ""
360
361# whether a dependency names a repo rather than the hub
362def source.is_git(src: Source) -> Bool:
363  match src:
364    case Hub{_named}:
365      False{}
366    case Git{_url, _rev, _tag, _root, _nar, _vend}:
367      True{}
368
369# whether an upgrade that moves this dependency also commits the new tree
370def source.vendor(src: Source) -> Bool:
371  match src:
372    case Hub{_named}:
373      False{}
374    case Git{_url, _rev, _tag, _root, _nar, vend}:
375      vend
376
377# the source of a dependency that was found
378def source.dep(dependency: Dep) -> Source:
379  Dep{_n, _h, _e, s} = dependency
380  s
381
382# the commit the named dependency is pinned to
383def rev_of(ledger: Read, name: String) -> String:
384  source.rev(source.dep(dep(ledger, name)))
385
386# the tag the named dependency was asked for
387def tag_of(ledger: Read, name: String) -> String:
388  source.tag(source.dep(dep(ledger, name)))
389
390# the hub a ledger's packages come from: the `hub` its `[package]` table
391# names, or bend's own when it names none. It is read from the committed
392# ledger and not from the environment, so two checkouts of one commit lock
393# against the same hub whatever their shells say.
394def hub.or(+url: String) -> String:
395  Bool.pick(String, String.is_empty(url), "https://hub.bend-lang.com", url)
396
397# the hub of a ledger that was read. One that would not parse names none, and
398# so names bend's.
399def hub_of(ledger: Read) -> String:
400  match ledger:
401    case Bad{_why}:
402      hub.or("")
403    case Good{m}:
404      Manifest{_n, _e, _b, h, pa, pv, _ds, _ts} = m
405      hub.or(h)
406
407# the pinned CLIs of a ledger that was read; one that would not parse has none
408def tools_of(ledger: Read) -> List<&2, Tool>:
409  match ledger:
410    case Bad{_why}:
411      []
412    case Good{m}:
413      Manifest{_n, _e, _b, _h, _pa, _pv, _ds, ts} = m
414      ts
415
416# this tool when its name matches, otherwise whatever the rest gave
417def tool.find.pick(hit: Bool, pin: Tool, rest: Tool) -> Tool:
418  match hit:
419    case True{}:
420      pin
421    case False{}:
422      rest
423
424# the tool of this name, or a blank one
425def tool.find.at(+pin: Tool, rest: Tool, +name: String) -> Tool:
426  tool.find.pick(String.eq(tool.name(pin), name), pin, rest)
427
428# the tool of this name, or one with empty fields
429def tool.find(ts: List<&2, Tool>, +name: String) -> Tool:
430  match ts:
431    case []:
432      Tool{"", "", "", Hub{""}}
433    case h <> t:
434      tool.find.at(h, tool.find(t, name), name)
435
436# the rest of the scan only when this one was not it
437def tool.step(hit: Bool, rest: Unit -> Bool) -> Bool:
438  match hit:
439    case True{}:
440      True{}
441    case False{}:
442      rest(Unit{})
443
444# whether the ledger pins a CLI of this name
445def tool.has(ts: List<&2, Tool>, +name: String) -> Bool:
446  match ts:
447    case []:
448      False{}
449    case +h <> t:
450      tool.step(String.eq(tool.name(h), name), _u => tool.has(t, name))
451
452# an empty `--package` is every tool; a name is that one
453def tool.known.go(all: Bool, ts: List<&2, Tool>, +name: String) -> Bool:
454  match all:
455    case True{}:
456      True{}
457    case False{}:
458      tool.has(ts, name)
459
460# whether `--package` names a pinned CLI, or names nothing and so names all
461def tool.known(+only: String, ts: List<&2, Tool>) -> Bool:
462  tool.known.go(String.is_empty(only), ts, only)