1 import Base 2 import bend-kit-files@0.1.0.0/files.bend as Fs 3 import bend-net-json@0.3.0.0/json.bend as Json 4 import ./effs/io.bend as X 5 import ../type.bend as A 6 import ../ops.bend as O 7 import ./container/effects.bend as CtE 8 import ./deployment/type.bend as D 9 import ./deployment/ops.bend as DO 10 import ./deployment/azure/effects.bend as AzE 11 import ./deployment/nix_darwin/type.bend as ND 12 import ./deployment/nix_darwin/ops.bend as NDO 13 import ./deployment/nix_darwin/effects.bend as NDE 14 import ./deployment/nix_darwin/setting/type.bend as St 15 import ./deployment/nix_darwin/setting/ops.bend as StO 16 import ./deployment/nix_darwin/brew/type.bend as Br 17 import ./deployment/nix_darwin/setting/effects.bend as StE 18 import ./deployment/nix_darwin/brew/effects.bend as BrE 19 import ./deployment/nix_darwin/service/ops.bend as SvO 20 import ./release/type.bend as Rl 21 import ./release/ops.bend as RlO 22 import ./plan/type.bend as P 23 import ./plan/ops.bend as PO 24 import ./ops.bend as SO 25 26 # The system's effects: reading the repository, the Bend hub and GitHub (a snapshot of the world), and 27 # changing them (a deploy). Programs run with their arguments, never through a shell; files are read with 28 # Base's File API. 29 30 # ---- the repository's files ---- 31 def files(+root: String) -> IO(List<&2, String>): 32 do IO>: 33 out : String <- X.run("git", ["-C", root, "ls-files", "-co", "--exclude-standard"]) 34 return X.nonempty(String.lines(out)) 35 36 # ---- what an entry reaches: it and every file it imports, followed in Bend ---- 37 def second_word(+t: String) -> String: 38 Maybe.default(&2, String, List.get(&2, String, X.nonempty(String.split(t, ' ')), 1n), "") 39 40 def unquote(+w: String) -> String: 41 Bool.pick(String, String.starts_with(w, "\""), Maybe.default(&2, String, List.get(&2, String, String.split(w, '"'), 1n), ""), w) 42 43 # The file a line imports, relative to the repository, or "" when it imports none of the repository's. 44 def import_of(+line: String, +dir: String) -> String: 45 +w = unquote(second_word(String.trim(line))) 46 Bool.pick(String, String.starts_with(String.trim(line), "import ") && (String.starts_with(w, "./") || String.starts_with(w, "../")), 47 Fs.path.normalize(Fs.path.join(dir, w)), "") 48 49 def imports(ls: List<&2, String>, +dir: String) -> List<&2, String>: 50 match ls: 51 case []: 52 [] 53 case l <> rest: 54 X.nonempty(import_of(l, dir) <> imports(rest, dir)) 55 56 # Each step reads one file; the number of files squared bounds the walk. 57 def reach(fuel: Nat, +todo: List<&2, String>, +seen: List<&2, String>, +root: String) -> IO(List<&2, String>): 58 match fuel: 59 case 0n: 60 IO.pure(List<&2, String>, seen) 61 case 1n+k: 62 match todo: 63 case []: 64 IO.pure(List<&2, String>, seen) 65 case +f <> rest: 66 do IO>: 67 text : String <- X.read_head(root ++ "/" ++ f, 4096) 68 +known : Bool = List.contains(~String, ~String.eq, seen, f) 69 reach(k, List.append(&2, String, rest, Bool.pick(List<&2, String>, known, [], imports(String.lines(text), Fs.path.parent(f)))), Bool.pick(List<&2, String>, known, seen, f <> seen), root) 70 71 # ---- the Bend hub: has it published this package, with these exact files? ---- 72 def differing(fs: List<&2, String>, +local: String, +hub: String) -> IO(String): 73 match fs: 74 case []: 75 IO.pure(String, "") 76 case +f <> rest: 77 do IO: 78 same : Bool <- X.succeeds("cmp", ["-s", local ++ "/" ++ f, hub ++ "/" ++ f]) 79 more : String <- differing(rest, local, hub) 80 return Bool.pick(String, same, more, f) 81 82 def hub_of(+published: String, +f: String) -> P.Hub: 83 Bool.pick(P.Hub, String.is_empty(published), P.Unpublished{}, Bool.pick(P.Hub, String.is_empty(f), P.Same{}, P.Differs{f})) 84 85 # The package's hash, as Bend's local cache names it. A package not cached yet is fetched first by 86 # checking a file that imports it: the hub has it exactly when that succeeds. 87 def fetch_if(missing: Bool, +home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String): 88 match missing: 89 case False{}: 90 IO.pure(String, cached) 91 case True{}: 92 do IO: 93 X.write("/tmp/v-probe.bend", "import Base\nimport " ++ n ++ "@" ++ v ++ "/" ++ Fs.path.file_name(e) ++ " as P\n") 94 _ : Bool <- X.succeeds(home ++ "/.bend/bin/bend", ["/tmp/v-probe.bend", "--check-only"]) 95 X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v) 96 97 def fetched(+home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String): 98 fetch_if(String.is_empty(String.trim(cached)), home, n, v, e, cached) 99 100 def hub_state(+home: String, +root: String, p: Rl.Package) -> IO(P.Hub): 101 match p: 102 case Rl.Package{+n, +v, +e, _}: 103 do IO: 104 cached : String <- X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v) 105 +hash : String <- fetched(home, n, v, e, cached) 106 +files : List<&2, String> <- reach(1000n, [e], [], root) 107 f : String <- differing(files, root, home ++ "/.bend/lib/" ++ String.trim(hash)) 108 return hub_of(String.trim(hash), f) 109 110 def hub_states(+home: String, +root: String, ps: List<&2, Rl.Package>) -> IO(List<&2, P.Hub>): 111 match ps: 112 case []: 113 IO.pure(List<&2, P.Hub>, []) 114 case p <> rest: 115 do IO>: 116 h : P.Hub <- hub_state(home, root, p) 117 hs : List<&2, P.Hub> <- hub_states(home, root, rest) 118 return h <> hs 119 120 # ---- GitHub ---- 121 def first_word(+t: String) -> String: 122 Maybe.default(&2, String, List.head(&2, String, X.nonempty(String.split(t, '\t'))), "") 123 124 def pushed(+root: String, +sha: String) -> IO(Bool): 125 Bool.pick(IO(Bool), String.is_empty(sha), IO.pure(Bool, False{}), X.succeeds("git", ["-C", root, "merge-base", "--is-ancestor", "HEAD", sha])) 126 127 def remote(+root: String, r: Rl.Repository) -> IO(P.Remote): 128 match r: 129 case Rl.Repository{+name, _}: 130 do IO: 131 origin : String <- X.run("git", ["-C", root, "remote", "get-url", "origin"]) 132 vis : String <- X.run("gh", ["repo", "view", name, "--json", "visibility", "--jq", ".visibility"]) 133 status : String <- X.run("git", ["-C", root, "status", "--porcelain"]) 134 head : String <- X.run("git", ["-C", root, "ls-remote", "origin", "refs/heads/main"]) 135 up : Bool <- pushed(root, first_word(head)) 136 return P.Remote{String.trim(origin), String.to_lower(String.trim(vis)), String.is_empty(String.trim(status)), up} 137 138 # ---- a plugin: a fresh build of it, next to what its marketplace has tagged for its version ---- 139 # Built under .git/v-plugin/, never committed: build/ is what a release would hold at 140 # plugins/, tag/ is the marketplace at the tag, repo/ the marketplace a release writes. 141 def plugin_dir(+root: String, +n: String) -> String: 142 root ++ "/.git/v-plugin/" ++ n 143 144 def tag_of(+n: String, +v: String) -> String: 145 n ++ "-v" ++ v 146 147 def last_segment(xs: List<&2, String>) -> String: 148 Maybe.default(&2, String, List.last(&2, String, xs), "") 149 150 def owner_of(+r: String) -> String: 151 Maybe.default(&2, String, List.head(&2, String, String.split(r, '/')), "") 152 153 def str_of(m: Maybe<&2, Json.Val>) -> String: 154 match m: 155 case Some{Json.Str{s}}: 156 s 157 case _: 158 "" 159 160 def version_in(m: Maybe<&2, Json.Val>) -> String: 161 match m: 162 case Some{v}: 163 str_of(Json.get(v, "version")) 164 case None{}: 165 "" 166 167 def obj.go(kvs: List<&2, Sigma<&2, &2, String, _ => Json.Val>>, m: Map<&2, Json.Val>) -> Map<&2, Json.Val>: 168 match kvs: 169 case []: 170 m 171 case (k, v) <> rest: 172 obj.go(rest, Map.set(&2, Json.Val, m, k, v)) 173 174 def obj(kvs: List<&2, Sigma<&2, &2, String, _ => Json.Val>>) -> Json.Val: 175 Json.Obj{obj.go(kvs, Map.new(&2, Json.Val))} 176 177 # The marketplace's other plugins, as listed now. 178 def others(vs: List<&2, Json.Val>, +n: String) -> List<&2, Json.Val>: 179 match vs: 180 case []: 181 [] 182 case +v <> rest: 183 +kept = others(rest, n) 184 Bool.pick(List<&2, Json.Val>, String.eq(str_of(Json.get(v, "name")), n), kept, v <> kept) 185 186 def listed(m: Maybe<&2, Json.Val>) -> List<&2, Json.Val>: 187 match m: 188 case Some{Json.Arr{vs}}: 189 vs 190 case _: 191 [] 192 193 # The marketplace's listing with this plugin at this version, every other plugin as it was. 194 def marketplace(+now: String, +n: String, +v: String, +r: String) -> String: 195 +m = Json.parse(now) 196 Json.encode(obj([ 197 ("name", Json.Str{last_segment(String.split(r, '/'))}), 198 ("owner", obj([("name", Json.Str{owner_of(r)})])), 199 ("plugins", Json.Arr{List.append(&2, Json.Val, others(listed(Bool.pick(Maybe<&2, Json.Val>, Maybe.is_some(&2, Json.Val, m), Json.get(Maybe.default(&2, Json.Val, m, Json.Null{}), "plugins"), None{})), n), 200 [obj([("name", Json.Str{n}), ("source", Json.Str{"./plugins/" ++ n}), ("version", Json.Str{v})])])})])) ++ "\n" 201 202 def build_plugin(+root: String, +bend: String, +n: String, +e: String) -> IO(String): 203 do IO: 204 +build : String = plugin_dir(root, n) ++ "/build" 205 _ : Bool <- X.succeeds("rm", ["-rf", plugin_dir(root, n)]) 206 _ : Bool <- X.succeeds("mkdir", ["-p", build]) 207 ok : Bool <- X.succeeds(bend, [root ++ "/" ++ e, "plugin", build]) 208 manifest : String <- X.read(build ++ "/.claude-plugin/plugin.json") 209 return Bool.pick(String, ok, version_in(Json.parse(manifest)), "") 210 211 def tag_differs(tagged: Bool, +root: String, +n: String, +v: String, +r: String) -> IO(Bool): 212 match tagged: 213 case False{}: 214 IO.pure(Bool, False{}) 215 case True{}: 216 do IO: 217 _ : Bool <- X.succeeds("git", ["clone", "-q", "--depth", "1", "--branch", tag_of(n, v), "https://github.com/" ++ r, plugin_dir(root, n) ++ "/tag"]) 218 same : Bool <- X.succeeds("diff", ["-rq", plugin_dir(root, n) ++ "/build", plugin_dir(root, n) ++ "/tag/plugins/" ++ n]) 219 return Bool.not(same) 220 221 def tag_state(+home: String, +root: String, g: Rl.Plugin) -> IO(P.Tag): 222 match g: 223 case Rl.Plugin{+n, +v, +e, _, Rl.Repository{+r, _}}: 224 do IO: 225 built : String <- build_plugin(root, home ++ "/.bend/bin/bend", n, e) 226 +repo : Bool <- X.succeeds("gh", ["repo", "view", r, "--json", "name"]) 227 tags : String <- X.run("git", ["ls-remote", "--tags", "https://github.com/" ++ r, "refs/tags/" ++ tag_of(n, v)]) 228 +tagged : Bool = repo && Bool.not(String.is_empty(String.trim(tags))) 229 differs : Bool <- tag_differs(tagged, root, n, v, r) 230 return P.Tag{repo, tagged, built, differs} 231 232 def tag_states(+home: String, +root: String, gs: List<&2, Rl.Plugin>) -> IO(List<&2, P.Tag>): 233 match gs: 234 case []: 235 IO.pure(List<&2, P.Tag>, []) 236 case g <> rest: 237 do IO>: 238 t : P.Tag <- tag_state(home, root, g) 239 ts : List<&2, P.Tag> <- tag_states(home, root, rest) 240 return t <> ts 241 242 # A release: the build into plugins/, the listing updated, committed, tagged and pushed. 243 def release_plugin(+root: String, +n: String, +v: String, +r: String, create: Bool) -> IO(Bool): 244 do IO: 245 +repo : String = plugin_dir(root, n) ++ "/repo" 246 _ : Bool <- Bool.pick(IO(Bool), create, X.succeeds("gh", ["repo", "create", r, "--public", "--description", "Claude Code plugins, built and released by V."]), IO.pure(Bool, True{})) 247 _ : Bool <- X.succeeds("rm", ["-rf", repo]) 248 _ : Bool <- X.succeeds("git", ["clone", "-q", "https://github.com/" ++ r, repo]) 249 _ : Bool <- X.succeeds("mkdir", ["-p", repo ++ "/plugins/" ++ n, repo ++ "/.claude-plugin"]) 250 _ : Bool <- X.succeeds("rsync", ["-a", "--delete", plugin_dir(root, n) ++ "/build/", repo ++ "/plugins/" ++ n ++ "/"]) 251 now : String <- X.read(repo ++ "/.claude-plugin/marketplace.json") 252 X.write(repo ++ "/.claude-plugin/marketplace.json", marketplace(now, n, v, r)) 253 _ : Bool <- X.succeeds("git", ["-C", repo, "add", "-A"]) 254 _ : Bool <- X.succeeds("git", ["-C", repo, "commit", "-q", "-m", n ++ " " ++ v]) 255 _ : Bool <- X.succeeds("git", ["-C", repo, "tag", tag_of(n, v)]) 256 X.succeeds("git", ["-C", repo, "push", "-q", "origin", "HEAD:main", tag_of(n, v)]) 257 258 # ---- a snapshot: everything a plan needs ---- 259 def root() -> IO(String): 260 do IO: 261 t : String <- X.run("git", ["rev-parse", "--show-toplevel"]) 262 return String.trim(t) 263 264 # The files, what the packages' entries and the system's own entry reach, the hub, and GitHub. 265 def observe(+root: String, +entry: String, r: Rl.Release) -> IO(P.Observed): 266 match r: 267 case Rl.Release{repo, +ps, +gs}: 268 do IO: 269 +h : String <- X.home() 270 +fs : List<&2, String> <- files(root) 271 reached : List<&2, String> <- reach(Nat.mul(1n+List.length(&2, String, fs), 1n+List.length(&2, String, fs)), entry <> List.append(&2, String, RlO.entries(ps), RlO.plugin_entries(gs)), [], root) 272 hs : List<&2, P.Hub> <- hub_states(h, root, ps) 273 rm : P.Remote <- remote(root, repo) 274 ts : List<&2, P.Tag> <- tag_states(h, root, gs) 275 return P.Observed{fs, reached, hs, rm, ts} 276 277 # ---- a deploy: carry out a plan with nothing blocked ---- 278 def apply(+root: String, +bend: String, s: P.Step) -> IO(Unit): 279 match s: 280 case P.Current{_}: 281 IO.print(PO.show(s)) 282 case P.Publish{+n, +v, +e}: 283 do IO: 284 out : String <- X.run(bend, [root ++ "/" ++ e, "--publish", n ++ "@" ++ v]) 285 IO.print(Bool.pick(String, String.contains(out, "published"), "published " ++ n ++ "@" ++ v, "failed publish " ++ n ++ "@" ++ v)) 286 case P.Push{+r}: 287 do IO: 288 ok : Bool <- X.succeeds("git", ["-C", root, "push", "-q", "origin", "HEAD:main"]) 289 IO.print(Bool.pick(String, ok, "pushed github " ++ r, "failed push github " ++ r)) 290 case P.ReleasePlugin{+n, +v, +r, create}: 291 do IO: 292 ok : Bool <- release_plugin(root, n, v, r, create) 293 IO.print(Bool.pick(String, ok, "released plugin " ++ n ++ "@" ++ v, "failed release plugin " ++ n ++ "@" ++ v)) 294 case P.Rebuild{+h, +fl, +mac}: 295 do IO: 296 _ : Result<&1, &1, U32 & String, Unit> <- Fs.mkdir(root ++ "/nix") 297 X.write(root ++ "/nix/flake.nix", fl) 298 X.write(root ++ "/nix/mac.nix", mac) 299 ok : Bool <- X.succeeds("sudo", ["-n", "/run/current-system/sw/bin/darwin-rebuild", "switch", "--flake", root ++ "/nix#" ++ h]) 300 IO.print(Bool.pick(String, ok, "rebuilt nix-darwin " ++ h, "failed darwin-rebuild switch " ++ h)) 301 case P.ServePort{+p, +t, ts}: 302 do IO: 303 ok : Bool <- X.succeeds(ts, ["serve", "--bg", "--yes", "--https=" ++ U32.show(p), t]) 304 IO.print(Bool.pick(String, ok, "served :" ++ U32.show(p) ++ " -> " ++ t, "failed serve :" ++ U32.show(p))) 305 case P.Change{_, _, _}: 306 IO.pure(Unit, Unit{}) 307 case P.Blocked{_, _}: 308 IO.print(PO.show(s)) 309 310 def apply_all(+root: String, +bend: String, ss: List<&2, P.Step>) -> IO(Unit): 311 match ss: 312 case []: 313 IO.pure(Unit, Unit{}) 314 case s <> rest: 315 do IO: 316 apply(root, bend, s) 317 apply_all(root, bend, rest) 318 319 def deploy_if(blocked: Bool, +root: String, +ss: List<&2, P.Step>) -> IO(Unit): 320 match blocked: 321 case True{}: 322 IO.die(Unit, 1, PO.show_all(ss) ++ "nothing deployed: the plan is blocked") 323 case False{}: 324 do IO: 325 +h : String <- X.home() 326 apply_all(root, h ++ "/.bend/bin/bend", ss) 327 328 def deploy(+root: String, +ss: List<&2, P.Step>) -> IO(Unit): 329 deploy_if(PO.any_blocked(ss), root, ss) 330 331 # ---- the whole plan: the release, the stack, each deployment node, and what the deploy would break ---- 332 # ---- a nix-darwin node: its checks, what a rebuild would change, and the rebuild when anything would ---- 333 def has_change(ss: List<&2, P.Step>) -> Bool: 334 match ss: 335 case []: 336 False{} 337 case s <> rest: 338 match s: 339 case P.Change{_, _, _}: 340 True{} 341 case _: 342 has_change(rest) 343 344 def mac_of(nd: ND.NixDarwin) -> String: 345 match nd: 346 case ND.NixDarwin{_, _, _, _, _, st, fw, br, _, _, _, _, _, _}: 347 StO.mac_nix(st, fw, br) 348 349 def host_of(nd: ND.NixDarwin) -> String: 350 match nd: 351 case ND.NixDarwin{h, _, _, _, _, _, _, _, _, _, _, _, _, _}: 352 h 353 354 def rebuild_if(c: Bool, +h: String, +fl: String, +mac: String) -> List<&2, P.Step>: 355 match c: 356 case True{}: 357 [P.Rebuild{h, fl, mac}] 358 case False{}: 359 [P.Current{"nix-darwin " ++ h}] 360 361 # Whether a generated file differs from the one in the repository (compared by cmp, not in memory). 362 def differs(+path: String, +text: String) -> IO(Bool): 363 do IO: 364 X.write("/tmp/v-generated", text) 365 same : Bool <- X.succeeds("cmp", ["-s", "/tmp/v-generated", path]) 366 return Bool.not(same) 367 368 def darwin_steps(+root: String, +k: String, +nd: ND.NixDarwin) -> IO(List<&2, P.Step>): 369 match nd: 370 case ND.NixDarwin{_, _, _, _, _, +st, +fw, +br, _, _, _, _, _, _}: 371 do IO>: 372 checks : List<&2, P.Step> <- NDE.checks(k, nd) 373 +settings : List<&2, P.Step> <- StE.changes(st, fw) 374 +brews : List<&2, P.Step> <- BrE.changes(br) 375 flake_new : Bool <- differs(root ++ "/nix/flake.nix", NDO.flake(nd)) 376 mac_new : Bool <- differs(root ++ "/nix/mac.nix", mac_of(nd)) 377 return List.append(&2, P.Step, checks, List.append(&2, P.Step, settings, List.append(&2, P.Step, brews, 378 rebuild_if(flake_new || mac_new || has_change(settings) || has_change(brews), host_of(nd), NDO.flake(nd), mac_of(nd))))) 379 380 def platform_steps(p: D.Platform, +root: String, +k: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 381 match p: 382 case D.Unmanaged{}: 383 IO.pure(List<&2, P.Step>, []) 384 case D.Darwin{+nd}: 385 darwin_steps(root, k, nd) 386 case D.AzureCloud{az}: 387 AzE.checks(az, O.agreement(a)) 388 389 def node_steps(ns: List<&2, D.Node>, +root: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 390 match ns: 391 case []: 392 IO.pure(List<&2, P.Step>, []) 393 case n <> rest: 394 match n: 395 case D.Node{+k, _, _, _, _, pf, kids, _}: 396 do IO>: 397 here : List<&2, P.Step> <- platform_steps(pf, root, k, a) 398 below : List<&2, P.Step> <- node_steps(kids, root, a) 399 after : List<&2, P.Step> <- node_steps(rest, root, a) 400 return List.append(&2, P.Step, here, List.append(&2, P.Step, below, after)) 401 402 def deployed_path(+root: String) -> String: 403 root ++ "/.git/v-deployed" 404 405 def breaks_step(+bs: List<&2, D.Break>, +d: D.Deployment) -> List<&2, P.Step>: 406 Bool.pick(List<&2, P.Step>, DO.breaks_eq(bs, DO.accepted(d)), [], 407 [P.Blocked{"deployment", DO.show_breaks(bs) ++ " (declare it as accepted to deploy)"}]) 408 409 # What the deploy would break, against what this repository last deployed (recorded in .git, never committed). 410 def deployed_steps(+root: String, +d: D.Deployment) -> IO(List<&2, P.Step>): 411 do IO>: 412 +text : String <- X.read(deployed_path(root)) 413 return breaks_step(DO.breaks(DO.recorded_address(text), DO.recorded_stores(text), d), d) 414 415 def plan_all(+root: String, +entry: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 416 do IO>: 417 o : P.Observed <- observe(root, entry, SO.release(O.system(a))) 418 stack : List<&2, P.Step> <- CtE.checks(root, O.containers(a), O.externals(a)) 419 nodes : List<&2, P.Step> <- node_steps(O.nodes(a), root, a) 420 breaks : List<&2, P.Step> <- deployed_steps(root, O.deployment(a)) 421 return List.append(&2, P.Step, stack, List.append(&2, P.Step, nodes, List.append(&2, P.Step, breaks, PO.plan(SO.release(O.system(a)), o)))) 422 423 # A deploy of the whole plan; afterwards it records the deployment, so the next plan compares against it. 424 def deploy_all(+root: String, +entry: String, +a: A.Architecture) -> IO(Unit): 425 do IO: 426 +steps : List<&2, P.Step> <- plan_all(root, entry, a) 427 deploy(root, steps) 428 X.write(deployed_path(root), DO.record(O.deployment(a))) 429 430 # ---- import: each nix-darwin node's Mac as it is now, as Bend to declare it with ---- 431 def import_node(p: D.Platform, +root: String) -> IO(Unit): 432 match p: 433 case D.Darwin{nd}: 434 match nd: 435 case ND.NixDarwin{h, _, _, _, _, _, _, _, svs, _, _, _, _, _}: 436 do IO: 437 st : List<&2, St.Setting> <- StE.observed(root ++ "/nix/options.tsv", SvO.labels(svs)) 438 fw : St.Firewall <- StE.firewall_now() 439 br : List<&2, Br.Brew> <- BrE.observed() 440 IO.print("# " ++ h ++ "\n" ++ StO.import_text(st, fw, br)) 441 case _: 442 IO.pure(Unit, Unit{}) 443 444 def import_nodes(ns: List<&2, D.Node>, +root: String) -> IO(Unit): 445 match ns: 446 case []: 447 IO.pure(Unit, Unit{}) 448 case n <> rest: 449 match n: 450 case D.Node{_, _, _, _, _, pf, kids, _}: 451 do IO: 452 import_node(pf, root) 453 import_nodes(kids, root) 454 import_nodes(rest, root) 455 456 def import_all(+root: String, a: A.Architecture) -> IO(Unit): 457 import_nodes(O.nodes(a), root)