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