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