src/lint/plan.bend

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