src/lint/plan.bend
bolt@1.21.0.0 · 582 lines · 0 definitions
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.
21import Base
22import bolt@1.21.0.0/src/lazy/lazy.bend as Lazy
23import bolt@1.21.0.0/src/lint/world.bend as W
24import bolt@1.21.0.0/src/finding.bend as F
25import bolt@1.21.0.0/src/src.bend as Src
26import bolt@1.21.0.0/src/rules.bend as Rules
27import bolt@1.21.0.0/src/rules/imports.bend as Imports
28import bolt@1.21.0.0/src/config.bend as Config
29import bolt@1.21.0.0/src/noqa.bend as Noqa
30import bolt@1.21.0.0/src/rules/style/noqa.bend as NoqaRule
31import bolt@1.21.0.0/src/rules/correctness/setting.bend as Setting
32
33# what `bolt` prints, in order, and the status it exits with
34type Plan is Data:
35 Plan{lines: List<&2, String>, exit: U32}
36
37# the lines a plan prints
38def lines.of(pl: Plan) -> List<&2, String>:
39 Plan{lines, _exit} = pl
40 lines
41
42# the status a plan exits with
43def 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
55def 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
59def 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
63def walk.under(+dir: String, +name: String) -> String:
64 dir ++ "/" ++ name
65
66# the sub-directories of a listing worth descending into, as paths
67def 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 "/")
77def 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
87type Walk is Data:
88 Walk{found: List<&2, String>, read: List<&2, String>}
89
90# the files a walk found
91def 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
96def 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.
104def 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.
128def limit() -> Nat:
129 100000n
130
131# the walk of the tree under `.`, reading at most `limit()` directories
132def 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
139def 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
148def files(+world: W.World) -> List<&2, String>:
149 files.named(W.paths.of(world), world)
150
151# a bolt.bend is configuration, not source
152def 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
156def 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
165def 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
172def 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
180def 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
195def 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`)
205def 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 `.`
213def roots(+world: W.World) -> List<&2, String>:
214 roots.named(W.paths.of(world), world)
215
216# the SPEC.md of a package root
217def spec.at(+root: String) -> String:
218 root ++ "/SPEC.md"
219
220# the SPEC.md of each package root
221def 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
230def 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
238def 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
247def 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
253def 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
257def 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
265def 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
273def 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
282def 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
290def 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
295def 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
302def 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
306def 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
315def 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.
324def 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
331type Got is Data:
332 Got{path: String, text: Maybe<&2, String>}
333
334# each source with what the World answers for it
335def 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
343def 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
353def 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
363def 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
372def 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
384def 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
392def 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
396def 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)
407def 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
415def 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
421def 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
429type Conf is Data:
430 Conf{dir: String, config: Config.Config}
431
432# what the World holds at each path
433def 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
442def 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
446def 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
454def 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)
460def 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
468def 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
473def 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
486def 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
495def 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.
506def 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
517def 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
527def 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
535def 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
545def 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
553def 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`
562def 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
566def 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
571def 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
580def 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)}