src/ledger/manifest.bend

ezx@1.11.0.0 · 502 lines · 67 definitions

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