1 # src/lint/plan: `bolt` (the lint) as a pure planner. It reads a World 2 # (src/lint/world.bend) and returns either the questions it still needs 3 # answered (`wants`) or the Plan: the lines to print and the exit status 4 # (`plan`). The interpreter (src/lint.bend) loops on `wants`, answering each 5 # question by IO, until nothing is wanted, then prints `plan`'s lines and 6 # exits with its status. The design is docs/rfc/bolt-lint-planner.md. 7 # 8 # With no files named, the planner walks the tree under `.` as a fold over the 9 # World's listings: a directory not listed yet reads as empty and is asked 10 # for, so each round of the loop lists one more level of the tree. Once every 11 # directory the walk reads is listed, it asks for the text of each source and 12 # of every bolt.bend candidate of their directories, each once. 13 # A directory's candidates are taken once it is resolved against the World's 14 # working directory (Config.home), so a relative path is graded by the same 15 # bolt.bend wherever bolt runs from. 16 # `wants` never parses a file; `plan` parses and lints once, and reads the 17 # settings of each bolt.bend that grades a directory for `setting` (C011). Once every rule 18 # has run and each finding is graded, the noqa comments (src/noqa.bend) 19 # silence what they name, and then the `noqa` rule reports the comments that 20 # silenced nothing. 21 import Base 22 import ../lazy/lazy.bend as Lazy 23 import ./world.bend as W 24 import ../finding.bend as F 25 import ../src.bend as Src 26 import ../role.bend as Role 27 import ../rules.bend as Rules 28 import ../rules/imports.bend as Imports 29 import ../config.bend as Config 30 import ../noqa.bend as Noqa 31 import ../rules/style/noqa.bend as NoqaRule 32 import ../rules/correctness/setting.bend as Setting 33 34 # what `bolt` prints, in order, and the status it exits with 35 type Plan is Data: 36 Plan{lines: List<&2, String>, exit: U32} 37 38 # the lines a plan prints 39 def lines.of(pl: Plan) -> List<&2, String>: 40 Plan{lines, _exit} = pl 41 lines 42 43 # the status a plan exits with 44 def exit.of(pl: Plan) -> U32: 45 Plan{_lines, exit} = pl 46 exit 47 48 # --------------------------------------------------------------------------- 49 # the walk: every `.bend` file under `.`, sorted. A hidden name (`.git`, 50 # `.gate`, `.claude`) and `node_modules` are not descended into. The walk keeps 51 # a queue of directories rather than nesting, as Bend has no mutual recursion: 52 # one def shrinks one argument, the fuel, which bounds how many directories are 53 # read. 54 55 # a directory the walk never descends into 56 def walk.skipped(+name: String) -> Bool: 57 Bool.or(String.starts_with(name, "."), String.eq(name, "node_modules")) 58 59 # an entry's name without the "/" that marks it a directory 60 def walk.bare(+name: String) -> String: 61 String.take(name, Nat.sub(String.length(name), 1n)) 62 63 # a name directly under a directory, as a path 64 def walk.under(+dir: String, +name: String) -> String: 65 dir ++ "/" ++ name 66 67 # the sub-directories of a listing worth descending into, as paths 68 def walk.dirs(es: List<&2, String>, +dir: String) -> List<&2, String>: 69 match es: 70 case Nil{}: 71 Nil{} 72 case Con{+e, rest}: 73 +more = walk.dirs(rest, dir) 74 +keep = Bool.and(String.ends_with(e, "/"), Bool.not(walk.skipped(walk.bare(e)))) 75 Bool.pick(List<&2, String>, keep, walk.under(dir, walk.bare(e)) <> more, more) 76 77 # the `.bend` files of a listing, as paths (a directory's name ends in "/") 78 def walk.bends(es: List<&2, String>, +dir: String) -> List<&2, String>: 79 match es: 80 case Nil{}: 81 Nil{} 82 case Con{+e, rest}: 83 +more = walk.bends(rest, dir) 84 Bool.pick(List<&2, String>, String.ends_with(e, ".bend"), walk.under(dir, e) <> more, more) 85 86 # a walk once it stops: the `.bend` files it found, and every directory it 87 # read, the last one read first 88 type Walk is Data: 89 Walk{found: List<&2, String>, read: List<&2, String>} 90 91 # the files a walk found 92 def walk.found(wk: Walk) -> List<&2, String>: 93 Walk{found, _read} = wk 94 found 95 96 # the directories a walk read, in the order it read them 97 def walk.read(wk: Walk) -> List<&2, String>: 98 Walk{_found, read} = wk 99 List.reverse(&2, String, read) 100 101 # every `.bend` file under the directories still queued, the fuel bounding how 102 # many directories are read, each read through the World's listing. What is 103 # found is put in front of what came before, not after it, so the walk stays 104 # linear; `files` sorts at the end regardless. 105 def walk( 106 fuel: Nat, 107 +world: W.World, 108 queue: List<&2, String>, 109 acc: List<&2, String>, 110 seen: List<&2, String> 111 ) -> Walk: 112 match fuel: 113 case 0n: 114 Walk{acc, seen} 115 case 1n+f: 116 match queue: 117 case Nil{}: 118 Walk{acc, seen} 119 case Con{+dir, rest}: 120 +es = W.listing(world, dir) 121 walk(f, world, List.append(&2, String, rest, walk.dirs(es, dir)), 122 List.append(&2, String, walk.bends(es, dir), acc), dir <> seen) 123 124 # how many directories the walk reads at most; past it the walk stops, 125 # silently. A literal, not `U32.to_nat(100000)`: the checker keeps a literal 126 # whole and steps it one at a time, so laws/ScopeWalksWholeTree.bend's law can name the bound, 127 # where it would expand the call in unary and overflow. The compiler emits 128 # the same `U32.to_nat` for either. 129 def limit() -> Nat: 130 100000n 131 132 # the walk of the tree under `.`, reading at most `limit()` directories 133 def tree(+world: W.World) -> Walk: 134 walk(limit(), world, ["."], [], []) 135 136 # --------------------------------------------------------------------------- 137 # the files of the run 138 139 # the files named, or every `.bend` file the walk finds when none were 140 def files.named(paths: List<&2, String>, +world: W.World) -> List<&2, String>: 141 match paths: 142 case Nil{}: 143 +found = walk.found(tree(world)) 144 List.sort(~String, ~String.is_le, found) 145 case Con{p, rest}: 146 p <> rest 147 148 # the files the lint runs over 149 def files(+world: W.World) -> List<&2, String>: 150 files.named(W.paths.of(world), world) 151 152 # a bolt.bend is configuration, not source 153 def is_config(+pp: String) -> Bool: 154 Bool.or(String.eq(pp, "bolt.bend"), String.ends_with(pp, "/bolt.bend")) 155 156 # the paths that are source 157 def sources(ps: List<&2, String>) -> List<&2, String>: 158 match ps: 159 case Nil{}: 160 Nil{} 161 case Con{+p, rest}: 162 +more = sources(rest) 163 Bool.pick(List<&2, String>, is_config(p), more, p <> more) 164 165 # the source files of the run, in file-list order 166 def inputs(+world: W.World) -> List<&2, String>: 167 sources(files(world)) 168 169 # a path as the one file it names wherever it is spelled from: resolved 170 # against the working directory when relative, then normalized 171 # (src/rules/imports.bend), so `a.bend`, `./a.bend` and `/w/a.bend` run 172 # from `/w` are one 173 def abs(+cwd: String, +path: String) -> String: 174 Imports.norm(Bool.pick(String, String.starts_with(path, "/"), path, cwd ++ "/" ++ path)) 175 176 # each path resolved (abs) 177 def norms(ps: List<&2, String>, +cwd: String) -> List<&2, String>: 178 match ps: 179 case Nil{}: 180 Nil{} 181 case Con{p, rest}: 182 abs(cwd, p) <> norms(rest, cwd) 183 184 # the paths that resolve to none of the paths seen 185 def unseen(ps: List<&2, String>, +cwd: String, +seen: List<&2, String>) -> List<&2, String>: 186 match ps: 187 case Nil{}: 188 Nil{} 189 case Con{+p, rest}: 190 +more = unseen(rest, cwd, seen) 191 Bool.pick(List<&2, String>, Imports.has(seen, abs(cwd, p)), more, p <> more) 192 193 # the files a run reads its roles from: every source the walk of the tree 194 # finds, then each source of the run the walk does not find (one named 195 # outside the working directory), so each file is read once. A file's role 196 # follows the imports, and the file that makes a named file code, a 197 # package's main.bend that imports it, may be named or not 198 def graph(+world: W.World) -> List<&2, String>: 199 +found = sources(walk.found(tree(world))) 200 +cwd = W.cwd.of(world) 201 List.append(&2, String, found, unseen(inputs(world), cwd, norms(found, cwd))) 202 203 # --------------------------------------------------------------------------- 204 # what the plan reads 205 206 # whether a path is among the paths 207 def seen.has(ps: List<&2, String>, +pp: String) -> Bool: 208 match ps: 209 case Nil{}: 210 False{} 211 case Con{p, rest}: 212 Lazy.or_else(String.eq(p, pp), _u => seen.has(rest, pp)) 213 214 # each path once, where it first appears 215 def uniq(ps: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>: 216 match ps: 217 case Nil{}: 218 Nil{} 219 case Con{+p, rest}: 220 +dup = seen.has(seen, p) 221 +more = uniq(rest, p <> seen) 222 Bool.pick(List<&2, String>, dup, more, p <> more) 223 224 # --------------------------------------------------------------------------- 225 # the packages (laws/ScopePackageRootsFound.bend): every directory the walk reads but `.` whose 226 # listing names a main.bend, in the order the walk reads them; none when files 227 # were named 228 229 # the directories the walk read that hold a main.bend, `.` left out 230 def roots.of(ds: List<&2, String>, +world: W.World) -> List<&2, String>: 231 match ds: 232 case Nil{}: 233 Nil{} 234 case Con{+d, rest}: 235 +more = roots.of(rest, world) 236 +root = Bool.and(Bool.not(String.eq(d, ".")), seen.has(W.listing(world, d), "main.bend")) 237 Bool.pick(List<&2, String>, root, d <> more, more) 238 239 # the package roots but `.`, as the walk names them (`./w3c`) 240 def roots.named(paths: List<&2, String>, +world: W.World) -> List<&2, String>: 241 match paths: 242 case Nil{}: 243 roots.of(walk.read(tree(world)), world) 244 case Con{_p, _rest}: 245 Nil{} 246 247 # the package roots but `.` 248 def roots(+world: W.World) -> List<&2, String>: 249 roots.named(W.paths.of(world), world) 250 251 # each package root as the project rules take it: its directory as a digest 252 # names one (`w3c/`) 253 def packages(rs: List<&2, String>) -> List<&2, String>: 254 match rs: 255 case Nil{}: 256 Nil{} 257 case Con{r, rest}: 258 Imports.dir_of(r ++ "/main.bend") <> packages(rest) 259 260 # the directory of each path 261 def dirs.of(ps: List<&2, String>) -> List<&2, String>: 262 match ps: 263 case Nil{}: 264 Nil{} 265 case Con{p, rest}: 266 Config.dir_of(p) <> dirs.of(rest) 267 268 # the directory of every source, each once: every finding's path is a 269 # source's, so these are the directories grading needs 270 def conf.dirs(+ps: List<&2, String>) -> List<&2, String>: 271 uniq(dirs.of(ps), []) 272 273 # the bolt.bend candidates of a directory as a path names it, nearest first: 274 # the directory resolved against the working directory, then each parent up to 275 # the root 276 def conf.chain(+world: W.World, dir: String) -> List<&2, String>: 277 Config.chain(Config.home(W.cwd.of(world), dir)) 278 279 # the bolt.bend candidates of each directory, nearest first 280 def conf.cands(ds: List<&2, String>, +world: W.World) -> List<&2, String>: 281 match ds: 282 case Nil{}: 283 Nil{} 284 case Con{d, rest}: 285 List.append(&2, String, conf.chain(world, d), conf.cands(rest, world)) 286 287 # the listing of each directory, as a question 288 def asks.entries(ds: List<&2, String>) -> List<&2, W.Ask>: 289 match ds: 290 case Nil{}: 291 Nil{} 292 case Con{d, rest}: 293 W.Entries{d} <> asks.entries(rest) 294 295 # the text of each path, as a question 296 def asks.texts(ps: List<&2, String>) -> List<&2, W.Ask>: 297 match ps: 298 case Nil{}: 299 Nil{} 300 case Con{p, rest}: 301 W.Contents{p} <> asks.texts(rest) 302 303 # the listings the plan reads: every directory the walk of the tree reads, 304 # whether files were named or not, since a named file's role is read from the 305 # imports of the whole tree (graph) 306 def lists(+world: W.World) -> List<&2, W.Ask>: 307 asks.entries(walk.read(tree(world))) 308 309 # the texts the plan reads: each source, every file of the graph, and every 310 # bolt.bend candidate of the sources' directories, each once 311 def texts(+world: W.World) -> List<&2, W.Ask>: 312 +ps = inputs(world) 313 asks.texts(uniq(List.concat(&2, String, [ps, graph(world), conf.cands(conf.dirs(ps), world)]), 314 [])) 315 316 # every question the plan reads the World through: the listings, then the 317 # texts 318 def looks(+world: W.World) -> List<&2, W.Ask>: 319 List.append(&2, W.Ask, lists(world), texts(world)) 320 321 # the questions a World does not answer yet 322 def unanswered(asks: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>: 323 match asks: 324 case Nil{}: 325 Nil{} 326 case Con{+a, rest}: 327 +more = unanswered(rest, world) 328 Bool.pick(List<&2, W.Ask>, W.answered(world, a), more, a <> more) 329 330 # the listings still open, or, once there are none, the texts still open 331 def wants.pick(ls: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>: 332 match ls: 333 case Nil{}: 334 unanswered(texts(world), world) 335 case Con{a, rest}: 336 a <> rest 337 338 # the questions a World still leaves open. The file list is known only once 339 # every directory the walk reads is listed, so the texts wait for the walk. 340 def wants(+world: W.World) -> List<&2, W.Ask>: 341 wants.pick(unanswered(lists(world), world), world) 342 343 # --------------------------------------------------------------------------- 344 # the findings 345 346 # a source path and the text the World holds for it 347 type Got is Data: 348 Got{path: String, text: Maybe<&2, String>} 349 350 # each source with what the World answers for it 351 def gots(ps: List<&2, String>, +world: W.World) -> List<&2, Got>: 352 match ps: 353 case Nil{}: 354 Nil{} 355 case Con{+p, rest}: 356 Got{p, W.text(world, p)} <> gots(rest, world) 357 358 # one finding for each source that could not be read 359 def unread(gs: List<&2, Got>) -> List<&2, F.Finding>: 360 match gs: 361 case Nil{}: 362 Nil{} 363 case Con{Got{+path, None{}}, rest}: 364 F.Finding{path, 0, 0, 0, "read", "Cannot read this file."} <> unread(rest) 365 case Con{Got{_path, Some{_text}}, rest}: 366 unread(rest) 367 368 # every source that was read, parsed once for every rule 369 def srcs_of(gs: List<&2, Got>) -> List<&2, Src.Src>: 370 match gs: 371 case Nil{}: 372 Nil{} 373 case Con{Got{_path, None{}}, rest}: 374 srcs_of(rest) 375 case Con{Got{path, Some{text}}, rest}: 376 Src.of(path, text) <> srcs_of(rest) 377 378 # every source's findings from the per-file rules, each told its role among 379 # the marks (src/role.bend) 380 def per_file.role(+ms: List<&2, Role.Mark>, ss: List<&2, Src.Src>) -> List<&2, F.Finding>: 381 match ss: 382 case Nil{}: 383 Nil{} 384 case Con{s, rest}: 385 Src.Src{+path, text, toks, tree, bound, items} = s 386 List.concat(&2, F.Finding, [Rules.on.role(Role.of(ms, path), Src.Src{path, text, toks, tree, bound, items}), 387 per_file.role(ms, rest)]) 388 389 # the facts (src/role.bend) of each file the World read, from its text, each 390 # at its path resolved against the working directory (abs) 391 def facts.of(gs: List<&2, Got>, +cwd: String) -> List<&2, Role.Fact>: 392 match gs: 393 case Nil{}: 394 Nil{} 395 case Con{Got{_path, None{}}, rest}: 396 facts.of(rest, cwd) 397 case Con{Got{path, Some{text}}, rest}: 398 Role.fact.text(abs(cwd, path), text) <> facts.of(rest, cwd) 399 400 # each path's mark as the path is spelled: its role among marks taken over 401 # resolved paths (abs), neither when it has none there 402 def marks.key(ps: List<&2, String>, +cwd: String, +ms: List<&2, Role.Mark>) -> List<&2, Role.Mark>: 403 match ps: 404 case Nil{}: 405 Nil{} 406 case Con{+p, rest}: 407 Role.Mark{Imports.norm(p), Role.of.norm(ms, abs(cwd, p))} <> marks.key(rest, cwd, ms) 408 409 # the marks of a run over files named on the line: every file of the graph 410 # read for its imports, the walk taken over their resolved paths, and each 411 # named file's role looked up there as it is named 412 def marks.named(+world: W.World) -> List<&2, Role.Mark>: 413 +cwd = W.cwd.of(world) 414 marks.key(inputs(world), cwd, Role.marks.facts(facts.of(gots(graph(world), world), cwd))) 415 416 # the marks a run's roles are read from: over the whole tree, the sources' 417 # own; over files named on the line, the graph's, every file of the tree 418 # read for its imports, so the main.bend that imports a named file makes it 419 # code even when it is not named 420 def marks.run(paths: List<&2, String>, +world: W.World, +ss: List<&2, Src.Src>) -> List<&2, Role.Mark>: 421 match paths: 422 case Nil{}: 423 Role.marks(ss) 424 case Con{_p, _rest}: 425 marks.named(world) 426 427 # the path of the first candidate the World holds a text for, as a list of 428 # one; none when it holds none 429 def conf.first(gs: List<&2, Got>) -> List<&2, String>: 430 match gs: 431 case Nil{}: 432 Nil{} 433 case Con{Got{path, Some{_text}}, _rest}: 434 [path] 435 case Con{Got{_path, None{}}, rest}: 436 conf.first(rest) 437 438 # the bolt.bend that grades each directory, in directory order: the first of 439 # its candidates the World read, as conf.near reads it; none for a directory 440 # graded by the defaults 441 def conf.used(ds: List<&2, String>, +world: W.World) -> List<&2, String>: 442 match ds: 443 case Nil{}: 444 Nil{} 445 case Con{d, rest}: 446 List.append(&2, String, conf.first(gots(conf.chain(world, d), world)), conf.used(rest, world)) 447 448 # every bolt.bend grading reads, each once, in the order its directories come 449 def graders(+world: W.World) -> List<&2, String>: 450 uniq(conf.used(conf.dirs(inputs(world)), world), []) 451 452 # the `setting` rule's findings on each bolt.bend the World read, in order 453 def settings.at(gs: List<&2, Got>) -> List<&2, F.Finding>: 454 match gs: 455 case Nil{}: 456 Nil{} 457 case Con{Got{+path, Some{text}}, rest}: 458 List.append(&2, F.Finding, Setting.check(path, text), settings.at(rest)) 459 case Con{Got{_path, None{}}, rest}: 460 settings.at(rest) 461 462 # the `setting` rule's findings: each unknown setting of every bolt.bend 463 # grading reads (src/rules/correctness/setting.bend) 464 def settings(+world: W.World) -> List<&2, F.Finding>: 465 settings.at(gots(graders(world), world)) 466 467 # every finding of every rule, ungraded, from the sources and what the World 468 # answers for them: the read failures, the per-file findings of each source, 469 # then `setting`'s over each bolt.bend grading reads, then the project rules' 470 # over every source and the other packages 471 def found.at(+world: W.World, +gs: List<&2, Got>, +ss: List<&2, Src.Src>) -> List<&2, F.Finding>: 472 List.concat(&2, F.Finding, [unread(gs), per_file.role(marks.run(W.paths.of(world), world, ss), ss), settings(world), 473 Rules.project(ss, packages(roots(world)))]) 474 475 # every finding of every rule, ungraded 476 def found(+world: W.World) -> List<&2, F.Finding>: 477 +gs = gots(inputs(world), world) 478 found.at(world, gs, srcs_of(gs)) 479 480 # --------------------------------------------------------------------------- 481 # grading 482 483 # a directory and the config its findings are graded by 484 type Conf is Data: 485 Conf{dir: String, config: Config.Config} 486 487 # what the World holds at each path 488 def texts.at(ps: List<&2, String>, +world: W.World) -> List<&2, Maybe<&2, String>>: 489 match ps: 490 case Nil{}: 491 Nil{} 492 case Con{p, rest}: 493 W.text(world, p) <> texts.at(rest, world) 494 495 # a directory's config: the nearest bolt.bend the World could read among its 496 # candidates, or the defaults 497 def conf.near(+world: W.World, dir: String) -> Config.Config: 498 Config.nearest(texts.at(conf.chain(world, dir), world)) 499 500 # each directory's config 501 def confs.at(ds: List<&2, String>, +world: W.World) -> List<&2, Conf>: 502 match ds: 503 case Nil{}: 504 Nil{} 505 case Con{+d, rest}: 506 Conf{d, conf.near(world, d)} <> confs.at(rest, world) 507 508 # the config of every directory grading needs 509 def confs(+world: W.World) -> List<&2, Conf>: 510 confs.at(conf.dirs(inputs(world)), world) 511 512 # the config of a directory, from the table; one the table does not hold is 513 # worked out the same way (every finding's directory is in the table, so this 514 # never happens in a run) 515 def conf.find(cs: List<&2, Conf>, +world: W.World, +dir: String) -> Config.Config: 516 match cs: 517 case Nil{}: 518 conf.near(world, dir) 519 case Con{Conf{d, cfg}, rest}: 520 Lazy.stop(Config.Config, String.eq(d, dir), cfg, _u => conf.find(rest, world, dir)) 521 522 # the config a finding at a path is graded by: its directory's 523 def config.at(+cs: List<&2, Conf>, +world: W.World, path: String) -> Config.Config: 524 conf.find(cs, world, Config.dir_of(path)) 525 526 # every finding graded by the config of its directory, in order, the ones at 527 # off dropped 528 def grade(+cs: List<&2, Conf>, +world: W.World, fs: List<&2, F.Finding>) -> List<&2, F.Graded>: 529 match fs: 530 case Nil{}: 531 Nil{} 532 case Con{F.Finding{+path, line, col, len, rule, msg}, rest}: 533 cfg = config.at(cs, world, path) 534 List.append(&2, F.Graded, Rules.graded(cfg, [F.Finding{path, line, col, len, rule, msg}]), grade(cs, world, rest)) 535 536 # --------------------------------------------------------------------------- 537 # silencing, then the `noqa` rule 538 539 # the `noqa` rule's findings on each file's marks, against the graded 540 # findings, each put at its file as a per-file rule's are 541 def noqa(ms: List<&2, Noqa.Marks>, +whole: Bool, +gs: List<&2, F.Graded>) -> List<&2, F.Finding>: 542 match ms: 543 case Nil{}: 544 Nil{} 545 case Con{Noqa.Marks{+path, mks}, rest}: 546 List.append(&2, F.Finding, Rules.on.at(path, NoqaRule.check(mks, whole, path, gs)), noqa(rest, whole, gs)) 547 548 # the graded findings no mark silences, in order, then the `noqa` rule's, 549 # graded by the grader given: nothing silences what `noqa` reports 550 def after( 551 +ms: List<&2, Noqa.Marks>, 552 +whole: Bool, 553 +gs: List<&2, F.Graded>, 554 grader: List<&2, F.Finding> -> List<&2, F.Graded> 555 ) -> List<&2, F.Graded>: 556 List.append(&2, F.Graded, Noqa.keep(ms, gs), grader(noqa(ms, whole, gs))) 557 558 # every finding of the run, graded, then silenced by the marks of the 559 # sources, then `noqa`'s own, which judges a project rule's code only when 560 # the run is the whole tree. The sources are parsed once for both. 561 def final(+world: W.World) -> List<&2, F.Graded>: 562 +gs = gots(inputs(world), world) 563 +ss = srcs_of(gs) 564 +cs = confs(world) 565 after(Noqa.of_srcs(ss), List.is_empty(&2, String, W.paths.of(world)), grade(cs, world, found.at(world, gs, ss)), 566 fs => grade(cs, world, fs)) 567 568 # --------------------------------------------------------------------------- 569 # the plan 570 571 # one more at error, or the count as it was 572 def errors.at(ll: Config.Level, more: U32) -> U32: 573 match ll: 574 case Config.Off{}: 575 more 576 case Config.Warn{}: 577 more 578 case Config.Error{}: 579 U32.inc(more) 580 581 # how many are errors 582 def errors(gs: List<&2, F.Graded>) -> U32: 583 match gs: 584 case Nil{}: 585 0 586 case Con{F.Graded{ll, _ff}, rest}: 587 errors.at(ll, errors(rest)) 588 589 # one more below error, or the count as it was 590 def warnings.at(ll: Config.Level, more: U32) -> U32: 591 match ll: 592 case Config.Off{}: 593 U32.inc(more) 594 case Config.Warn{}: 595 U32.inc(more) 596 case Config.Error{}: 597 more 598 599 # how many are not errors 600 def warnings(gs: List<&2, F.Graded>) -> U32: 601 match gs: 602 case Nil{}: 603 0 604 case Con{F.Graded{ll, _ff}, rest}: 605 warnings.at(ll, warnings(rest)) 606 607 # whether any is an error 608 def has_error(gs: List<&2, F.Graded>) -> Bool: 609 match gs: 610 case Nil{}: 611 False{} 612 case Con{F.Graded{ll, _ff}, rest}: 613 +more = has_error(rest) 614 Bool.or(Config.is_error(ll), more) 615 616 # `1 error`, `2 errors` 617 def count(+nn: U32, +what: String) -> String: 618 U32.show(nn) ++ " " ++ what ++ Bool.pick(String, U32.is_eq(nn, 1), "", "s") 619 620 # `clean`, or the counts 621 def summary(+gs: List<&2, F.Graded>) -> String: 622 Lazy.stop(String, List.is_empty(&2, F.Graded, gs), "clean", 623 _u => count(errors(gs), "error") ++ ", " ++ count(warnings(gs), "warning")) 624 625 # every graded finding as a line 626 def show_all(gs: List<&2, F.Graded>) -> List<&2, String>: 627 match gs: 628 case Nil{}: 629 Nil{} 630 case Con{g, rest}: 631 F.show_graded(g) <> show_all(rest) 632 633 # `bolt` over a World: every graded finding no mark silences as a line, then 634 # `noqa`'s, then the summary of those, and exit 1 exactly when one is an error 635 def plan(+world: W.World) -> Plan: 636 +gs = final(world) 637 Plan{List.append(&2, String, show_all(gs), [summary(gs)]), Bool.pick(U32, has_error(gs), 1, 0)}