~/bend-docscommunity

src/lint/plan.bend source

src/lint/plan.bend on the hub · documented module

# src/lint/plan: `bolt` (the lint) as a pure planner. It reads a World# (src/lint/world.bend) and returns either the questions it still needs# answered (`wants`) or the Plan: the lines to print and the exit status# (`plan`). The interpreter (src/lint.bend) loops on `wants`, answering each# question by IO, until nothing is wanted, then prints `plan`'s lines and# exits with its status. The design is docs/rfc/bolt-lint-planner.md.## With no files named, the planner walks the tree under `.` as a fold over the# World's listings: a directory not listed yet reads as empty and is asked# for, so each round of the loop lists one more level of the tree. Once every# directory the walk reads is listed, it asks for the text of each source, of# SPEC.md, and of every bolt.bend candidate of their directories, each once.# A directory's candidates are taken once it is resolved against the World's# working directory (Config.home), so a relative path is graded by the same# bolt.bend wherever bolt runs from.# `wants` never parses a file; `plan` parses and lints once. Once every rule# has run and each finding is graded, the noqa comments (src/noqa.bend)# silence what they name, and then the `noqa` rule reports the comments that# silenced nothing.import Baseimport ../lazy/lazy.bend as Lazyimport ./world.bend as Wimport ../finding.bend as Fimport ../src.bend as Srcimport ../rules.bend as Rulesimport ../config.bend as Configimport ../noqa.bend as Noqaimport ../rules/style/noqa.bend as NoqaRule# what `bolt` prints, in order, and the status it exits withtype Plan is Data:  Plan{lines: List<&2, String>, exit: U32}# the lines a plan printsdef lines.of(pl: Plan) -> List<&2, String>:  Plan{lines, _exit} = pl  lines# the status a plan exits withdef exit.of(pl: Plan) -> U32:  Plan{_lines, exit} = pl  exit# ---------------------------------------------------------------------------# the walk: every `.bend` file under `.`, sorted. A hidden name (`.git`,# `.gate`, `.claude`) and `node_modules` are not descended into. The walk keeps# a queue of directories rather than nesting, as Bend has no mutual recursion:# one def shrinks one argument, the fuel, which bounds how many directories are# read.# a directory the walk never descends intodef walk.skipped(+name: String) -> Bool:  Bool.or(String.starts_with(name, "."), String.eq(name, "node_modules"))# an entry's name without the "/" that marks it a directorydef walk.bare(+name: String) -> String:  String.take(name, Nat.sub(String.length(name), 1n))# a name directly under a directory, as a pathdef walk.under(+dir: String, +name: String) -> String:  dir ++ "/" ++ name# the sub-directories of a listing worth descending into, as pathsdef walk.dirs(es: List<&2, String>, +dir: String) -> List<&2, String>:  match es:    case Nil{}:      Nil{}    case Con{+e, rest}:      +more = walk.dirs(rest, dir)      +keep = Bool.and(String.ends_with(e, "/"), Bool.not(walk.skipped(walk.bare(e))))      Bool.pick(List<&2, String>, keep, walk.under(dir, walk.bare(e)) <> more, more)# the `.bend` files of a listing, as paths (a directory's name ends in "/")def walk.bends(es: List<&2, String>, +dir: String) -> List<&2, String>:  match es:    case Nil{}:      Nil{}    case Con{+e, rest}:      +more = walk.bends(rest, dir)      Bool.pick(List<&2, String>, String.ends_with(e, ".bend"), walk.under(dir, e) <> more, more)# a walk once it stops: the `.bend` files it found, and every directory it# read, the last one read firsttype Walk is Data:  Walk{found: List<&2, String>, read: List<&2, String>}# the files a walk founddef walk.found(wk: Walk) -> List<&2, String>:  Walk{found, _read} = wk  found# the directories a walk read, in the order it read themdef walk.read(wk: Walk) -> List<&2, String>:  Walk{_found, read} = wk  List.reverse(&2, String, read)# every `.bend` file under the directories still queued, the fuel bounding how# many directories are read, each read through the World's listing. What is# found is put in front of what came before, not after it, so the walk stays# linear; `files` sorts at the end regardless.def walk(  fuel: Nat,  +world: W.World,  queue: List<&2, String>,  acc: List<&2, String>,  seen: List<&2, String>) -> Walk:  match fuel:    case 0n:      Walk{acc, seen}    case 1n+f:      match queue:        case Nil{}:          Walk{acc, seen}        case Con{+dir, rest}:          +es = W.listing(world, dir)          walk(f, world, List.append(&2, String, rest, walk.dirs(es, dir)),            List.append(&2, String, walk.bends(es, dir), acc), dir <> seen)# how many directories the walk reads at most; past it the walk stops,# silently. A literal, not `U32.to_nat(100000)`: the checker keeps a literal# whole and steps it one at a time, so BOLT-SCOPE-1's law can name the bound,# where it would expand the call in unary and overflow. The compiler emits# the same `U32.to_nat` for either.def limit() -> Nat:  100000n# the walk of the tree under `.`, reading at most `limit()` directoriesdef tree(+world: W.World) -> Walk:  walk(limit(), world, ["."], [], [])# ---------------------------------------------------------------------------# the files of the run# the files named, or every `.bend` file the walk finds when none weredef files.named(paths: List<&2, String>, +world: W.World) -> List<&2, String>:  match paths:    case Nil{}:      +found = walk.found(tree(world))      List.sort(~String, ~String.is_le, found)    case Con{p, rest}:      p <> rest# the files the lint runs overdef files(+world: W.World) -> List<&2, String>:  files.named(W.paths.of(world), world)# a bolt.bend is configuration, not sourcedef is_config(+pp: String) -> Bool:  Bool.or(String.eq(pp, "bolt.bend"), String.ends_with(pp, "/bolt.bend"))# the paths that are sourcedef sources(ps: List<&2, String>) -> List<&2, String>:  match ps:    case Nil{}:      Nil{}    case Con{+p, rest}:      +more = sources(rest)      Bool.pick(List<&2, String>, is_config(p), more, p <> more)# the source files of the run, in file-list orderdef inputs(+world: W.World) -> List<&2, String>:  sources(files(world))# ---------------------------------------------------------------------------# what the plan reads# whether a path is among the pathsdef seen.has(ps: List<&2, String>, +pp: String) -> Bool:  match ps:    case Nil{}:      False{}    case Con{p, rest}:      Lazy.or_else(String.eq(p, pp), _u => seen.has(rest, pp))# each path once, where it first appearsdef uniq(ps: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:  match ps:    case Nil{}:      Nil{}    case Con{+p, rest}:      +dup = seen.has(seen, p)      +more = uniq(rest, p <> seen)      Bool.pick(List<&2, String>, dup, more, p <> more)# the directory of each pathdef dirs.of(ps: List<&2, String>) -> List<&2, String>:  match ps:    case Nil{}:      Nil{}    case Con{p, rest}:      Config.dir_of(p) <> dirs.of(rest)# the directory of every source and of SPEC.md, each once: every finding's# path is a source's or SPEC.md, so these are the directories grading needsdef conf.dirs(+ps: List<&2, String>) -> List<&2, String>:  uniq(dirs.of(List.append(&2, String, ps, ["SPEC.md"])), [])# the bolt.bend candidates of a directory as a path names it, nearest first:# the directory resolved against the working directory, then each parent up to# the rootdef conf.chain(+world: W.World, dir: String) -> List<&2, String>:  Config.chain(Config.home(W.cwd.of(world), dir))# the bolt.bend candidates of each directory, nearest firstdef conf.cands(ds: List<&2, String>, +world: W.World) -> List<&2, String>:  match ds:    case Nil{}:      Nil{}    case Con{d, rest}:      List.append(&2, String, conf.chain(world, d), conf.cands(rest, world))# the listing of each directory, as a questiondef asks.entries(ds: List<&2, String>) -> List<&2, W.Ask>:  match ds:    case Nil{}:      Nil{}    case Con{d, rest}:      W.Entries{d} <> asks.entries(rest)# the text of each path, as a questiondef asks.texts(ps: List<&2, String>) -> List<&2, W.Ask>:  match ps:    case Nil{}:      Nil{}    case Con{p, rest}:      W.Text{p} <> asks.texts(rest)# the directories the walk lists: every one it reads, none when files were# nameddef lists.named(paths: List<&2, String>, +world: W.World) -> List<&2, W.Ask>:  match paths:    case Nil{}:      asks.entries(walk.read(tree(world)))    case Con{_p, _rest}:      Nil{}# the listings the plan readsdef lists(+world: W.World) -> List<&2, W.Ask>:  lists.named(W.paths.of(world), world)# the texts the plan reads: each source, SPEC.md, and every bolt.bend# candidate of their directories, each oncedef texts(+world: W.World) -> List<&2, W.Ask>:  +ps = inputs(world)  asks.texts(uniq(List.concat(&2, String, [ps, ["SPEC.md"], conf.cands(conf.dirs(ps), world)]), []))# every question the plan reads the World through: the listings, then the# textsdef looks(+world: W.World) -> List<&2, W.Ask>:  List.append(&2, W.Ask, lists(world), texts(world))# the questions a World does not answer yetdef unanswered(asks: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>:  match asks:    case Nil{}:      Nil{}    case Con{+a, rest}:      +more = unanswered(rest, world)      Bool.pick(List<&2, W.Ask>, W.answered(world, a), more, a <> more)# the listings still open, or, once there are none, the texts still opendef wants.pick(ls: List<&2, W.Ask>, +world: W.World) -> List<&2, W.Ask>:  match ls:    case Nil{}:      unanswered(texts(world), world)    case Con{a, rest}:      a <> rest# the questions a World still leaves open. The file list is known only once# every directory the walk reads is listed, so the texts wait for the walk.def wants(+world: W.World) -> List<&2, W.Ask>:  wants.pick(unanswered(lists(world), world), world)# ---------------------------------------------------------------------------# the findings# a source path and the text the World holds for ittype Got is Data:  Got{path: String, text: Maybe<&2, String>}# each source with what the World answers for itdef gots(ps: List<&2, String>, +world: W.World) -> List<&2, Got>:  match ps:    case Nil{}:      Nil{}    case Con{+p, rest}:      Got{p, W.text(world, p)} <> gots(rest, world)# one finding for each source that could not be readdef unread(gs: List<&2, Got>) -> List<&2, F.Finding>:  match gs:    case Nil{}:      Nil{}    case Con{Got{+path, None{}}, rest}:      F.Finding{path, 0, 0, 0, "read", "Cannot read this file."} <> unread(rest)    case Con{Got{_path, Some{_text}}, rest}:      unread(rest)# every source that was read, parsed once for every ruledef srcs_of(gs: List<&2, Got>) -> List<&2, Src.Src>:  match gs:    case Nil{}:      Nil{}    case Con{Got{_path, None{}}, rest}:      srcs_of(rest)    case Con{Got{path, Some{text}}, rest}:      Src.of(path, text) <> srcs_of(rest)# every source's findings from the per-file rulesdef per_file(ss: List<&2, Src.Src>) -> List<&2, F.Finding>:  match ss:    case Nil{}:      Nil{}    case Con{s, rest}:      List.concat(&2, F.Finding, [Rules.on(s), per_file(rest)])# every finding of every rule, ungraded, from the sources and what the World# answers for them: the read failures, the per-file findings of each source,# then the project rules' over every source, told whether the run is the# whole tree, and SPEC.md's textdef found.at(+world: W.World, +gs: List<&2, Got>, +ss: List<&2, Src.Src>) -> List<&2, F.Finding>:  List.concat(&2, F.Finding, [unread(gs), per_file(ss),    Rules.project(ss, List.is_empty(&2, String, W.paths.of(world)), W.text(world, "SPEC.md"))])# every finding of every rule, ungradeddef found(+world: W.World) -> List<&2, F.Finding>:  +gs = gots(inputs(world), world)  found.at(world, gs, srcs_of(gs))# ---------------------------------------------------------------------------# grading# a directory and the config its findings are graded bytype Conf is Data:  Conf{dir: String, config: Config.Config}# what the World holds at each pathdef texts.at(ps: List<&2, String>, +world: W.World) -> List<&2, Maybe<&2, String>>:  match ps:    case Nil{}:      Nil{}    case Con{p, rest}:      W.text(world, p) <> texts.at(rest, world)# a directory's config: the nearest bolt.bend the World could read among its# candidates, or the defaultsdef conf.near(+world: W.World, dir: String) -> Config.Config:  Config.nearest(texts.at(conf.chain(world, dir), world))# each directory's configdef confs.at(ds: List<&2, String>, +world: W.World) -> List<&2, Conf>:  match ds:    case Nil{}:      Nil{}    case Con{+d, rest}:      Conf{d, conf.near(world, d)} <> confs.at(rest, world)# the config of every directory grading needsdef confs(+world: W.World) -> List<&2, Conf>:  confs.at(conf.dirs(inputs(world)), world)# the config of a directory, from the table; one the table does not hold is# worked out the same way (every finding's directory is in the table, so this# never happens in a run)def conf.find(cs: List<&2, Conf>, +world: W.World, +dir: String) -> Config.Config:  match cs:    case Nil{}:      conf.near(world, dir)    case Con{Conf{d, cfg}, rest}:      Lazy.stop(Config.Config, String.eq(d, dir), cfg, _u => conf.find(rest, world, dir))# the config a finding at a path is graded by: its directory'sdef config.at(+cs: List<&2, Conf>, +world: W.World, path: String) -> Config.Config:  conf.find(cs, world, Config.dir_of(path))# every finding graded by the config of its directory, in order, the ones at# off droppeddef grade(+cs: List<&2, Conf>, +world: W.World, fs: List<&2, F.Finding>) -> List<&2, F.Graded>:  match fs:    case Nil{}:      Nil{}    case Con{F.Finding{+path, line, col, len, rule, msg}, rest}:      List.append(&2, F.Graded, Rules.graded(config.at(cs, world, path), [F.Finding{path, line, col, len, rule, msg}]),        grade(cs, world, rest))# ---------------------------------------------------------------------------# silencing, then the `noqa` rule# the `noqa` rule's findings on each file's marks, against the graded# findings, each put at its file as a per-file rule's aredef noqa(ms: List<&2, Noqa.Marks>, +whole: Bool, +gs: List<&2, F.Graded>) -> List<&2, F.Finding>:  match ms:    case Nil{}:      Nil{}    case Con{Noqa.Marks{+path, mks}, rest}:      List.append(&2, F.Finding, Rules.on.at(path, NoqaRule.check(mks, whole, path, gs)), noqa(rest, whole, gs))# the graded findings no mark silences, in order, then the `noqa` rule's,# graded by the grader given: nothing silences what `noqa` reportsdef after(  +ms: List<&2, Noqa.Marks>,  +whole: Bool,  +gs: List<&2, F.Graded>,  grader: List<&2, F.Finding> -> List<&2, F.Graded>) -> List<&2, F.Graded>:  List.append(&2, F.Graded, Noqa.keep(ms, gs), grader(noqa(ms, whole, gs)))# every finding of the run, graded, then silenced by the marks of the# sources, then `noqa`'s own, which judges a project rule's code only when# the run is the whole tree. The sources are parsed once for both.def final(+world: W.World) -> List<&2, F.Graded>:  +gs = gots(inputs(world), world)  +ss = srcs_of(gs)  +cs = confs(world)  after(Noqa.of_srcs(ss), List.is_empty(&2, String, W.paths.of(world)), grade(cs, world, found.at(world, gs, ss)),    fs => grade(cs, world, fs))# ---------------------------------------------------------------------------# the plan# one more at error, or the count as it wasdef errors.at(ll: Config.Level, more: U32) -> U32:  match ll:    case Config.Off{}:      more    case Config.Warn{}:      more    case Config.Error{}:      U32.inc(more)# how many are errorsdef errors(gs: List<&2, F.Graded>) -> U32:  match gs:    case Nil{}:      0    case Con{F.Graded{ll, _ff}, rest}:      errors.at(ll, errors(rest))# one more below error, or the count as it wasdef warnings.at(ll: Config.Level, more: U32) -> U32:  match ll:    case Config.Off{}:      U32.inc(more)    case Config.Warn{}:      U32.inc(more)    case Config.Error{}:      more# how many are not errorsdef warnings(gs: List<&2, F.Graded>) -> U32:  match gs:    case Nil{}:      0    case Con{F.Graded{ll, _ff}, rest}:      warnings.at(ll, warnings(rest))# whether any is an errordef has_error(gs: List<&2, F.Graded>) -> Bool:  match gs:    case Nil{}:      False{}    case Con{F.Graded{ll, _ff}, rest}:      +more = has_error(rest)      Bool.or(Config.is_error(ll), more)# `1 error`, `2 errors`def count(+nn: U32, +what: String) -> String:  U32.show(nn) ++ " " ++ what ++ Bool.pick(String, U32.is_eq(nn, 1), "", "s")# `clean`, or the countsdef summary(+gs: List<&2, F.Graded>) -> String:  Lazy.stop(String, List.is_empty(&2, F.Graded, gs), "clean",    _u => count(errors(gs), "error") ++ ", " ++ count(warnings(gs), "warning"))# every graded finding as a linedef show_all(gs: List<&2, F.Graded>) -> List<&2, String>:  match gs:    case Nil{}:      Nil{}    case Con{g, rest}:      F.show_graded(g) <> show_all(rest)# `bolt` over a World: every graded finding no mark silences as a line, then# `noqa`'s, then the summary of those, and exit 1 exactly when one is an errordef plan(+world: W.World) -> Plan:  +gs = final(world)  Plan{List.append(&2, String, show_all(gs), [summary(gs)]), Bool.pick(U32, has_error(gs), 1, 0)}