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`, 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. 7 import Base 8 import ../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 `@` 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. 20 type 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 25 type 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. 32 type 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 40 type 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 46 type 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 51 def 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 55 def 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 60 def 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 68 def flag(+word: String) -> Bool: 69 String.eq(word, "true") 70 71 # no `git` key means the package lives on the hub 72 def 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 `@` 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. 82 def 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 92 def 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 96 def 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.]` section as a dependency. A hub dependency added by its 102 # `@` records it as `hub`, beside the hash it named when it 103 # was added, as a git dependency records its tag beside its rev. 104 def 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 109 def 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 114 def 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 120 def 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.]` 125 def 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 134 def name_of(sect: T.Sect) -> String: 135 T.Sect{n, _ps} = sect 136 n 137 138 # every `[deps.]` section, as dependencies, in the order written 139 def 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.]` section as a pinned CLI. The prefix is `tools.` 147 def 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 152 def 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 157 def 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 162 def tool.bin(pin: Tool) -> String: 163 Tool{_n, _e, b, _s} = pin 164 b 165 166 # where a pinned CLI's bytes come from 167 def 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.]` 172 def 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.]` section, as pinned CLIs, in the order written 181 def 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 189 def 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.]` section is missing its hash when the key is absent 197 def 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 202 def 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. 211 def 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 217 def 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 226 def 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 234 def 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 239 def 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 244 def parse(text: String) -> Read: 245 parse.go(T.parse(text)) 246 247 # how a dependency reads on one line 248 def 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 # `=@` 256 def 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. 262 def 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 270 def 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 275 def 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 283 def 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 291 def 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 296 def 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 304 def 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 313 def 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 321 def 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 329 def 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 337 def 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 345 def 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 `@` a hub dependency was added by, or "" for one added 353 # by its hash, and for a git one 354 def 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 362 def 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 370 def 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 378 def source.dep(dependency: Dep) -> Source: 379 Dep{_n, _h, _e, s} = dependency 380 s 381 382 # the commit the named dependency is pinned to 383 def 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 387 def 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. 394 def 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. 399 def 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 408 def 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 417 def 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 425 def 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 429 def 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 437 def 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 445 def 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 453 def 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 461 def tool.known(+only: String, ts: List<&2, Tool>) -> Bool: 462 tool.known.go(String.is_empty(only), ts, only)