src/lint/plan.bend
bolt@1.19.0.0 · 531 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.19.0.0/src/lazy/lazy.bend as Lazy
23import bolt@1.19.0.0/src/lint/world.bend as W
24import bolt@1.19.0.0/src/finding.bend as F
25import bolt@1.19.0.0/src/src.bend as Src
26import bolt@1.19.0.0/src/rules.bend as Rules
27import bolt@1.19.0.0/src/config.bend as Config
28import bolt@1.19.0.0/src/noqa.bend as Noqa
29import bolt@1.19.0.0/src/rules/style/noqa.bend as NoqaRule
30import bolt@1.19.0.0/src/rules/correctness/setting.bend as Setting
31
32# what `bolt` prints, in order, and the status it exits with
33type Plan is Data:
34 Plan{lines: List<&2, String>, exit: U32}
35
36# the lines a plan prints
37def lines.of(pl: Plan) -> List<&2, String>:
38 Plan{lines, _exit} = pl
39 lines
40
41# the status a plan exits with
42def 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
54def 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
58def 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
62def walk.under(+dir: String, +name: String) -> String:
63 dir ++ "/" ++ name
64
65# the sub-directories of a listing worth descending into, as paths
66def 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 "/")
76def 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
86type Walk is Data:
87 Walk{found: List<&2, String>, read: List<&2, String>}
88
89# the files a walk found
90def 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
95def 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.
103def 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.
127def limit() -> Nat:
128 100000n
129
130# the walk of the tree under `.`, reading at most `limit()` directories
131def 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
138def 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
147def files(+world: W.World) -> List<&2, String>:
148 files.named(W.paths.of(world), world)
149
150# a bolt.bend is configuration, not source
151def 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
155def 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
164def 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
171def 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
179def 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
189def 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
198def 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
204def 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
208def 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
216def 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
224def 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
233def 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
241def 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
246def 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
252def 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
256def 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
265def 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.
274def 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
281type Got is Data:
282 Got{path: String, text: Maybe<&2, String>}
283
284# each source with what the World answers for it
285def 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
293def 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
303def 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
313def 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
322def 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
334def 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
342def 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
346def 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)
357def 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
365def 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
370def 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
378type Conf is Data:
379 Conf{dir: String, config: Config.Config}
380
381# what the World holds at each path
382def 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
391def 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
395def 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
403def 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)
409def 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
417def 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
422def 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
435def 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
444def 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.
455def 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
466def 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
476def 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
484def 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
494def 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
502def 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`
511def 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
515def 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
520def 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
529def 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)}