lib/linter/directives.bend

bend-lint-rules@0.1.1.0 · 1,111 lines · 0 definitions

1import Base
2import bend-lint-api@0.1.1.0/shared/lists.bend as Lists
3import bend-lint-api@0.1.1.0/shared/names.bend as Names
4import bend-lint-api@0.1.1.0/shared/strings.bend as Strings
5import bend-lint-rules@0.1.1.0/lib/text/main.bend as Text
6import bend-lint-api@0.1.1.0/linter/lint.bend as Lint
7
8# Scanning
9# ========
10
11# A comment: where it is (code points), its line (from 0), whether code comes before it on its line, and its text
12# after the #.
13type Comment is Data:
14  Comment{beg: U32, end: U32, line: U32, trailing: Bool, body: String}
15
16type Glyph is Data:
17  LineEnd{}
18  CarriageReturn{}
19  Hash{}
20  DoubleQuote{}
21  SingleQuote{}
22  Backslash{}
23  Blank{}
24  Other{}
25
26# Where the scan is: in code, a comment, or a string or char literal (`string`), maybe just after a \ in it.
27type Mode is Data:
28  InCode{}
29  InComment{}
30  InQuote{string: Bool, escaped: Bool}
31
32# The scan so far: where it is, its offset and line, whether its line holds code, the comment it is in (its body last
33# first), the comments before (last first), and whether each line before holds code (last first).
34type Scan is Data:
35  Scan{
36    mode: Mode,
37    at: U32,
38    line: U32,
39    code: Bool,
40    open: Comment,
41    comments: List<&2, Comment>,
42    lines: List<&2, Bool>
43  }
44
45type Scanned is Data:
46  Scanned{comments: List<&2, Comment>, lines: List<&2, Bool>}
47
48def Glyph.of(+c: Char) -> Glyph:
49  Bool.pick(
50    Glyph,
51    Char.is_eq(c, '\n'),
52    LineEnd{},
53    Bool.pick(
54      Glyph,
55      Char.is_eq(c, '#'),
56      Hash{},
57      Bool.pick(
58        Glyph,
59        Char.is_eq(c, '"'),
60        DoubleQuote{},
61        Bool.pick(
62          Glyph,
63          Char.is_eq(c, '\''),
64          SingleQuote{},
65          Bool.pick(
66            Glyph,
67            Char.is_eq(c, '\\'),
68            Backslash{},
69            Bool.pick(
70              Glyph,
71              Char.is_eq(c, '\r'),
72              CarriageReturn{},
73              Bool.pick(Glyph, Char.is_eq(c, ' ') || Char.is_eq(c, '\t'), Blank{}, Other{})
74            )
75          )
76        )
77      )
78    )
79  )
80
81# No comment: what the scan holds as its open comment outside one.
82def Comment.none() -> Comment:
83  Comment{0, 0, 0, False{}, ""}
84
85# The comment `open`, ended at `at`, its body in order.
86def Comment.closed(open: Comment, at: U32) -> Comment:
87  match open:
88    case Comment{beg, end, line, trailing, body}:
89      Comment{beg, at, line, trailing, String.reverse(body)}
90
91def Comment.push(open: Comment, c: Char) -> Comment:
92  match open:
93    case Comment{beg, end, line, trailing, body}:
94      Comment{beg, end, line, trailing, SCon{c, body}}
95
96def Scan.in_code(
97  glyph: Glyph,
98  +at: U32,
99  +line: U32,
100  +code: Bool,
101  open: Comment,
102  comments: List<&2, Comment>,
103  lines: List<&2, Bool>
104) -> Scan:
105  match glyph:
106    case LineEnd{}:
107      Scan{InCode{}, (at + 1: U32), (line + 1: U32), False{}, open, comments, code <> lines}
108    case Hash{}:
109      Scan{InComment{}, (at + 1: U32), line, code, Comment{at, at, line, code, ""}, comments, lines}
110    case DoubleQuote{}:
111      Scan{InQuote{True{}, False{}}, (at + 1: U32), line, True{}, open, comments, lines}
112    case SingleQuote{}:
113      Scan{InQuote{False{}, False{}}, (at + 1: U32), line, True{}, open, comments, lines}
114    case Blank{}:
115      Scan{InCode{}, (at + 1: U32), line, code, open, comments, lines}
116    case CarriageReturn{}:
117      Scan{InCode{}, (at + 1: U32), line, code, open, comments, lines}
118    case _:
119      Scan{InCode{}, (at + 1: U32), line, True{}, open, comments, lines}
120
121def Scan.in_comment(
122  glyph: Glyph,
123  c: Char,
124  +at: U32,
125  line: U32,
126  code: Bool,
127  open: Comment,
128  comments: List<&2, Comment>,
129  lines: List<&2, Bool>
130) -> Scan:
131  match glyph:
132    case LineEnd{}:
133      Scan{
134        InCode{},
135        (at + 1: U32),
136        (line + 1: U32),
137        False{},
138        Comment.none(),
139        Comment.closed(open, at) <> comments,
140        code <> lines
141      }
142    case CarriageReturn{}:
143      Scan{
144        InCode{},
145        (at + 1: U32),
146        line,
147        code,
148        Comment.none(),
149        Comment.closed(open, at) <> comments,
150        lines
151      }
152    case _:
153      Scan{InComment{}, (at + 1: U32), line, code, Comment.push(open, c), comments, lines}
154
155# Where a quote leaves the scan: a string's " and a char's ' close it; the other quote does not.
156def Scan.quote(string: Bool, double: Bool) -> Mode:
157  match string double:
158    case True{} True{}:
159      InCode{}
160    case False{} False{}:
161      InCode{}
162    case _ _:
163      InQuote{string, False{}}
164
165# A step in a string or a char literal (`string`): a \ escapes the next character, its closing quote ends it, and a
166# line it spans holds code.
167def Scan.in_quote(
168  string: Bool,
169  escaped: Bool,
170  glyph: Glyph,
171  at: U32,
172  line: U32,
173  code: Bool,
174  open: Comment,
175  comments: List<&2, Comment>,
176  lines: List<&2, Bool>
177) -> Scan:
178  match escaped glyph:
179    case _ LineEnd{}:
180      Scan{
181        InQuote{string, False{}},
182        (at + 1: U32),
183        (line + 1: U32),
184        True{},
185        open,
186        comments,
187        True{} <> lines
188      }
189    case True{} _:
190      Scan{InQuote{string, False{}}, (at + 1: U32), line, code, open, comments, lines}
191    case False{} Backslash{}:
192      Scan{InQuote{string, True{}}, (at + 1: U32), line, code, open, comments, lines}
193    case False{} DoubleQuote{}:
194      Scan{Scan.quote(string, True{}), (at + 1: U32), line, code, open, comments, lines}
195    case False{} SingleQuote{}:
196      Scan{Scan.quote(string, False{}), (at + 1: U32), line, code, open, comments, lines}
197    case False{} _:
198      Scan{InQuote{string, False{}}, (at + 1: U32), line, code, open, comments, lines}
199
200def Scan.step(glyph: Glyph, c: Char, s: Scan) -> Scan:
201  match s:
202    case Scan{InCode{}, at, line, code, open, comments, lines}:
203      Scan.in_code(glyph, at, line, code, open, comments, lines)
204    case Scan{InComment{}, at, line, code, open, comments, lines}:
205      Scan.in_comment(glyph, c, at, line, code, open, comments, lines)
206    case Scan{InQuote{string, escaped}, at, line, code, open, comments, lines}:
207      Scan.in_quote(string, escaped, glyph, at, line, code, open, comments, lines)
208
209def Scan.go(text: String, s: Scan) -> Scan:
210  match text:
211    case SNil{}:
212      s
213    case SCon{+c, t}:
214      Scan.go(t, Scan.step(Glyph.of(c), c, s))
215
216def Scan.done(s: Scan) -> Scanned:
217  match s:
218    case Scan{InComment{}, at, line, code, open, comments, lines}:
219      Scanned{
220        List.reverse(&2, Comment, Comment.closed(open, at) <> comments),
221        List.reverse(&2, Bool, code <> lines)
222      }
223    case Scan{mode, at, line, code, open, comments, lines}:
224      Scanned{List.reverse(&2, Comment, comments), List.reverse(&2, Bool, code <> lines)}
225
226def Scan.of(text: String) -> Scanned:
227  Scan.done(Scan.go(text, Scan{InCode{}, 0, 0, False{}, Comment.none(), [], []}))
228
229# Parsing
230# =======
231type Action is Data:
232  Disable{}
233  Expect{}
234
235type Scope is Data:
236  WholeFile{}
237  Begin{}
238  End{}
239  NextLine{}
240  SameLine{}
241
242type Keyword is Data:
243  Keyword{action: Action, scope: Scope}
244
245# What a directive names: a rule, and the severity it pins, as written.
246type Target is Data:
247  Target{rule: String, pinned: Maybe<&2, String>}
248
249# A directive as written, before its severity and rule are checked: its reasons are the texts after each `--`.
250type Draft is Data:
251  Draft{keyword: Keyword, target: Target, reasons: List<&2, String>}
252
253# A directive: what it does, how far, the rule it names, the severity it pins, its comment, and its place among the
254# file's comments.
255type Directive is Data:
256  Directive{
257    action: Action,
258    scope: Scope,
259    rule: String,
260    severity: Maybe<&2, Lint.Severity>,
261    comment: Comment,
262    index: U32
263  }
264
265# A comment that says bend-lint: a directive, or what is wrong with it.
266type Said is Data:
267  Said{directive: Directive}
268  Wrong{comment: Comment, why: String}
269
270def Action.show(a: Action) -> String:
271  match a:
272    case Disable{}:
273      "disable"
274    case Expect{}:
275      "expect"
276
277def Scope.show(s: Scope) -> String:
278  match s:
279    case WholeFile{}:
280      "file"
281    case Begin{}:
282      "begin"
283    case End{}:
284      "end"
285    case NextLine{}:
286      "next"
287    case SameLine{}:
288      "line"
289
290def Action.names() -> List<&2, Names.Named<Action>>:
291  Names.table(~Action, ~Action.show, [Disable{}, Expect{}])
292
293def Scope.names() -> List<&2, Names.Named<Scope>>:
294  Names.table(~Scope, ~Scope.show, [WholeFile{}, Begin{}, End{}, NextLine{}, SameLine{}])
295
296def Severity.pinned(severity: Maybe<&2, Lint.Severity>) -> String:
297  Maybe.default(&2, String, Maybe.map(&2, Lint.Severity, String, Lint.Severity.show, severity), "")
298
299def Severity.is_eq(a: Maybe<&2, Lint.Severity>, b: Maybe<&2, Lint.Severity>) -> Bool:
300  String.eq(Severity.pinned(a), Severity.pinned(b))
301
302# The words of a text, split at blanks: the word so far, last first, and the words before it, last first.
303type Words is Data:
304  Words{word: String, out: List<&2, String>}
305
306def Words.step(blank: Bool, c: Char, w: Words) -> Words:
307  match blank w:
308    case True{} Words{SNil{}, out}:
309      Words{"", out}
310    case True{} Words{SCon{h, t}, out}:
311      Words{"", String.reverse(SCon{h, t}) <> out}
312    case False{} Words{word, out}:
313      Words{SCon{c, word}, out}
314
315def Words.go(s: String, w: Words) -> Words:
316  match s:
317    case SNil{}:
318      w
319    case SCon{+c, t}:
320      Words.go(t, Words.step(Char.is_eq(c, ' ') || Char.is_eq(c, '\t'), c, w))
321
322def Words.done(w: Words) -> List<&2, String>:
323  match w:
324    case Words{SNil{}, out}:
325      List.reverse(&2, String, out)
326    case Words{SCon{h, t}, out}:
327      List.reverse(&2, String, String.reverse(SCon{h, t}) <> out)
328
329def Words.of(s: String) -> List<&2, String>:
330  Words.done(Words.go(s, Words{"", []}))
331
332def Draft.kind(parts: List<&2, String>) -> Maybe<&2, Keyword>:
333  match parts:
334    case Con{a, Con{s, Nil{}}}:
335      do Maybe<&2, Keyword>:
336        action: Action <- Names.lookup(Action, a, Action.names())
337        scope: Scope <- Names.lookup(Scope, s, Scope.names())
338        return Keyword{action, scope}
339    case _:
340      None{}
341
342# Whether a rule id is one or more non-empty parts, split by /: its path in bend-lint-rules/.
343def Draft.is_rule_id(parts: List<&2, String>) -> Bool:
344  match parts:
345    case Con{a, rest}:
346      Bool.not(List.any(~&2, ~String, ~String.is_empty, Con{a, rest}))
347    case _:
348      False{}
349
350# `rule@severity`, split at its @s.
351def Draft.target(parts: List<&2, String>) -> Maybe<&2, Target>:
352  match parts:
353    case Con{+rule, Nil{}}:
354      Lists.when(Target, Draft.is_rule_id(String.split(rule, '/')), Target{rule, None{}})
355    case Con{+rule, Con{p, rest}}:
356      Lists.when(
357        Target,
358        Draft.is_rule_id(String.split(rule, '/')),
359        Target{rule, Some{String.join(p <> rest, "@")}}
360      )
361    case Nil{}:
362      None{}
363
364# The word that starts a disable's second reason: why it is not an expect.
365def Draft.label() -> String:
366  "not-expected-because:"
367
368def Draft.part(part: List<&2, String>, out: List<&2, String>) -> List<&2, String>:
369  String.join(List.reverse(&2, String, part), " ") <> out
370
371# The reason so far (last first) and those before it (last first), with `word` read: the label starts another.
372def Draft.reasons.step(
373  dash: Bool,
374  word: String,
375  read: List<&2, String> & List<&2, String>
376) -> List<&2, String>
377  & List<&2, String>:
378  match dash read:
379    case True{} Tuple{part, out}:
380      ([], Draft.part(part, out))
381    case False{} Tuple{part, out}:
382      (word <> part, out)
383
384# The reasons in `words`, split at each label, in order, after what is `read`.
385def Draft.reasons(
386  words: List<&2, String>,
387  read: List<&2, String> & List<&2, String>
388) -> List<&2, String>:
389  match words read:
390    case Nil{} Tuple{part, out}:
391      List.reverse(&2, String, Draft.part(part, out))
392    case Con{+w, rest} _:
393      Draft.reasons(rest, Draft.reasons.step(String.eq(w, Draft.label()), w, read))
394
395# What comes after the rule: nothing, or `--` and the reasons.
396def Draft.reason(rest: List<&2, String>) -> Maybe<&2, List<&2, String>>:
397  match rest:
398    case Nil{}:
399      Some{[]}
400    case Con{dash, more}:
401      Lists.when(List<&2, String>, String.eq(dash, "--"), Draft.reasons(more, ([], [])))
402
403# The draft of a directive's words, after `bend-lint:`.
404def Draft.of(words: List<&2, String>) -> Maybe<&2, Draft>:
405  match words:
406    case Con{kind, Con{named, rest}}:
407      do Maybe<&2, Draft>:
408        k: Keyword <- Draft.kind(String.split(kind, '-'))
409        t: Target <- Draft.target(String.split(named, '@'))
410        r: List<&2, String> <- Draft.reason(rest)
411        return Draft{k, t, r}
412    case _:
413      None{}
414
415def Problem.pinned(pinned: Maybe<&2, String>) -> Maybe<&2, String>:
416  match pinned:
417    case Some{+p}:
418      Lists.when(
419        String,
420        Maybe.is_none(&2, Lint.Severity, Names.lookup(Lint.Severity, p, Lint.Severity.names())),
421        p ++ " is not a severity."
422      )
423    case None{}:
424      None{}
425
426def Problem.severity(action: Action, pinned: Bool, rule: String) -> Maybe<&2, String>:
427  match action pinned:
428    case Disable{} True{}:
429      Some{"A disable takes no severity."}
430    case Expect{} False{}:
431      Some{"An expect pins a severity: " ++ rule ++ "@..."}
432    case _ _:
433      None{}
434
435def Problem.reason(scope: Scope, reasons: List<&2, String>) -> Maybe<&2, String>:
436  match scope reasons:
437    case End{} Nil{}:
438      None{}
439    case End{} _:
440      Some{"An end takes no reason; the begin has it."}
441    case _ Con{r, more}:
442      Lists.when(String, String.is_empty(r), "A reason is required, after `--`.")
443    case _ Nil{}:
444      Some{"A reason is required, after `--`."}
445
446def Problem.because(action: Action, scope: Scope, reasons: List<&2, String>) -> Maybe<&2, String>:
447  match action scope reasons:
448    case Disable{} End{} _:
449      None{}
450    case Disable{} _ Con{r, Con{b, more}}:
451      Lists.when(String, String.is_empty(b), "Say why it is not an expect, after " ++ Draft.label())
452    case Disable{} _ _:
453      Some{
454        "A disable says why it is not an expect, after its reason: " ++ Draft.label() ++ " <why>"
455      }
456    case _ _ _:
457      None{}
458
459def Problem.line(scope: Scope, trailing: Bool) -> Maybe<&2, String>:
460  match scope trailing:
461    case SameLine{} False{}:
462      Some{"A line directive must follow code on its line."}
463    case _ _:
464      None{}
465
466def Problem.first(problems: List<&2, Maybe<&2, String>>) -> Maybe<&2, String>:
467  match problems:
468    case Nil{}:
469      None{}
470    case Con{Some{why}, rest}:
471      Some{why}
472    case Con{None{}, rest}:
473      Problem.first(rest)
474
475def Rule.is_reserved(namespace: String) -> Bool:
476  String.eq(namespace, "bend-lint")
477
478def Said.checked(why: Maybe<&2, String>, draft: Draft, comment: Comment, index: U32) -> Said:
479  match why draft:
480    case Some{w} _:
481      Wrong{comment, w}
482    case None{} Draft{Keyword{action, scope}, Target{rule, pinned}, reasons}:
483      Said{
484        Directive{
485          action,
486          scope,
487          rule,
488          Maybe.bind(
489            &2,
490            String,
491            Lint.Severity,
492            pinned,
493            p => Names.lookup(Lint.Severity, p, Lint.Severity.names())
494          ),
495          comment,
496          index
497        }
498      }
499
500# What a comment that holds `draft` (or not) says, checked against the rules there are.
501def Said.of(
502  draft: Maybe<&2, Draft>,
503  trailing: Bool,
504  comment: Comment,
505  known: List<&2, String>,
506  index: U32
507) -> Said:
508  match draft:
509    case None{}:
510      Wrong{
511        comment,
512        "Write `# bend-lint: <disable|expect>-<file|begin|end|next|line> <rule>[@severity] -- <reason>`, and, for a disable, "
513          ++ Draft.label()
514          ++ " <why> after it."
515      }
516    case Some{Draft{Keyword{+action, +scope}, Target{+rule, +pinned}, +reasons}}:
517      Said.checked(
518        Problem.first(
519          [
520            Problem.pinned(pinned),
521            Problem.severity(action, Maybe.is_some(&2, String, pinned), rule),
522            Problem.reason(scope, reasons),
523            Problem.because(action, scope, reasons),
524            Problem.line(scope, trailing),
525            Lists.when(
526              String,
527              Rule.is_reserved(Strings.before(rule, '/')),
528              rule ++ " findings cannot be suppressed."
529            ),
530            Lists.when(
531              String,
532              Bool.not(List.contains(~String, ~String.eq, known, rule)),
533              "There is no rule " ++ rule ++ "."
534            )
535          ]
536        ),
537        Draft{Keyword{action, scope}, Target{rule, pinned}, reasons},
538        comment,
539        index
540      )
541
542def Draft.read.if(marked: Bool, text: String) -> Maybe<&2, Maybe<&2, Draft>>:
543  match marked:
544    case True{}:
545      Some{Draft.of(Words.of(String.drop(text, 10n)))}
546    case False{}:
547      None{}
548
549# What a comment's text says, if it says bend-lint:: a directive's draft, if it is one.
550def Draft.read(+body: String) -> Maybe<&2, Maybe<&2, Draft>>:
551  Draft.read.if(String.starts_with(String.trim_start(body), "bend-lint:"), String.trim_start(body))
552
553def Said.read.of(
554  draft: Maybe<&2, Maybe<&2, Draft>>,
555  +comment: Comment,
556  known: List<&2, String>,
557  index: U32
558) -> Maybe<&2, Said>:
559  match draft comment:
560    case Some{d} Comment{beg, end, line, +trailing, body}:
561      Some{Said.of(d, trailing, Comment{beg, end, line, trailing, body}, known, index)}
562    case None{} _:
563      None{}
564
565def Said.read(+comment: Comment, known: List<&2, String>, index: U32) -> Maybe<&2, Said>:
566  match comment:
567    case Comment{beg, end, line, trailing, +body}:
568      Said.read.of(Draft.read(body), Comment{beg, end, line, trailing, body}, known, index)
569
570# What the comments say, in order, each numbered; `out` holds those so far, last first.
571def Said.all(
572  comments: List<&2, Comment>,
573  +known: List<&2, String>,
574  +index: U32,
575  out: List<&2, Said>
576) -> List<&2, Said>:
577  match comments:
578    case Nil{}:
579      List.reverse(&2, Said, out)
580    case Con{c, rest}:
581      Said.all(rest, known, (index + 1: U32), Lists.push(Said, Said.read(c, known, index), out))
582
583# Pairing
584# =======
585
586# Where a begin was closed: its index, and the line of its end.
587type Closed is Data:
588  Closed{index: U32, line: U32}
589
590# The begins open so far (latest first), the begins closed, and what is wrong (last first).
591type Pairing is Data:
592  Pairing{open: List<&2, Directive>, closed: List<&2, Closed>, wrong: List<&2, Said>}
593
594# The latest open begin of an action and a rule.
595def Open.latest(
596  open: List<&2, Directive>,
597  found: Maybe<&2, Directive>,
598  +action: String,
599  +rule: String
600) -> Maybe<&2, Directive>:
601  match open found:
602    case _ Some{d}:
603      Some{d}
604    case Nil{} None{}:
605      None{}
606    case Con{Directive{+a, s, +r, sev, c, i}, rest} None{}:
607      Open.latest(
608        rest,
609        Lists.when(
610          Directive,
611          String.eq(Action.show(a), action) && String.eq(r, rule),
612          Directive{a, s, r, sev, c, i}
613        ),
614        action,
615        rule
616      )
617
618def Open.keep(drop: Bool, d: Directive, rest: List<&2, Directive>) -> List<&2, Directive>:
619  match drop:
620    case True{}:
621      rest
622    case False{}:
623      d <> rest
624
625def Open.without(open: List<&2, Directive>, +index: U32) -> List<&2, Directive>:
626  match open:
627    case Nil{}:
628      []
629    case Con{Directive{a, s, r, sev, c, +i}, rest}:
630      Open.keep(U32.is_eq(i, index), Directive{a, s, r, sev, c, i}, Open.without(rest, index))
631
632def Pairing.closing(
633  same: Bool,
634  +index: U32,
635  line: U32,
636  rule: String,
637  comment: Comment,
638  p: Pairing
639) -> Pairing:
640  match same p:
641    case True{} Pairing{open, closed, wrong}:
642      Pairing{Open.without(open, index), Closed{index, line} <> closed, wrong}
643    case False{} Pairing{open, closed, wrong}:
644      Pairing{open, closed, Wrong{comment, "This end closes no begin of " ++ rule ++ "."} <> wrong}
645
646def Pairing.close(begin: Maybe<&2, Directive>, end: Directive, p: Pairing) -> Pairing:
647  match begin end p:
648    case Some{
649      Directive{a, s, r, sev, c, i}
650    } Directive{ea, es, er, esev, Comment{b, e, +line, t, body}, ei} Pairing{open, closed, wrong}:
651      Pairing.closing(
652        Severity.is_eq(sev, esev),
653        i,
654        line,
655        er,
656        Comment{b, e, line, t, body},
657        Pairing{open, closed, wrong}
658      )
659    case None{} Directive{ea, es, er, esev, ec, ei} Pairing{open, closed, wrong}:
660      Pairing{open, closed, Wrong{ec, "This end closes no begin of " ++ er ++ "."} <> wrong}
661
662def Pairing.step(said: Said, p: Pairing) -> Pairing:
663  match said p:
664    case Said{Directive{a, Begin{}, r, sev, c, i}} Pairing{open, closed, wrong}:
665      Pairing{Directive{a, Begin{}, r, sev, c, i} <> open, closed, wrong}
666    case Said{Directive{+a, End{}, +r, sev, c, i}} Pairing{+open, closed, wrong}:
667      Pairing.close(
668        Open.latest(open, None{}, Action.show(a), r),
669        Directive{a, End{}, r, sev, c, i},
670        Pairing{open, closed, wrong}
671      )
672    case _ _:
673      p
674
675def Pairing.go(saids: List<&2, Said>, p: Pairing) -> Pairing:
676  match saids:
677    case Nil{}:
678      p
679    case Con{s, rest}:
680      Pairing.go(rest, Pairing.step(s, p))
681
682def Pairing.unclosed(open: List<&2, Directive>, wrong: List<&2, Said>) -> List<&2, Said>:
683  match open:
684    case Nil{}:
685      wrong
686    case Con{Directive{a, s, r, sev, c, i}, rest}:
687      Pairing.unclosed(rest, Wrong{c, "This begin of " ++ r ++ " is never closed."} <> wrong)
688
689# Covering
690# ========
691
692# A directive and the lines it covers, both ends included (from 1 to 0 covers none).
693type Covering is Data:
694  Covering{directive: Directive, from: U32, to: U32}
695
696# The first line after `after` that holds code; `i` is the line of the head of `lines`.
697def Lines.next_code(
698  lines: List<&2, Bool>,
699  found: Maybe<&2, U32>,
700  +i: U32,
701  +after: U32
702) -> Maybe<&2, U32>:
703  match lines found:
704    case _ Some{x}:
705      Some{x}
706    case Nil{} None{}:
707      None{}
708    case Con{code, rest} None{}:
709      Lines.next_code(rest, Lists.when(U32, code && U32.is_gt(i, after), i), (i + 1: U32), after)
710
711def Closed.find(closed: List<&2, Closed>, found: Maybe<&2, U32>, +index: U32) -> Maybe<&2, U32>:
712  match closed found:
713    case _ Some{line}:
714      Some{line}
715    case Nil{} None{}:
716      None{}
717    case Con{Closed{i, line}, rest} None{}:
718      Closed.find(rest, Lists.when(U32, U32.is_eq(i, index), line), index)
719
720def Covering.next(d: Directive, found: Maybe<&2, U32>) -> Covering:
721  match found:
722    case Some{+l}:
723      Covering{d, l, l}
724    case None{}:
725      Covering{d, 1, 0}
726
727# The lines a directive covers; `last` is the file's last line.
728def Covering.of(
729  said: Said,
730  lines: List<&2, Bool>,
731  closed: List<&2, Closed>,
732  last: U32
733) -> Maybe<&2, Covering>:
734  match said:
735    case Said{Directive{a, WholeFile{}, r, sev, c, i}}:
736      Some{Covering{Directive{a, WholeFile{}, r, sev, c, i}, 0, last}}
737    case Said{Directive{a, SameLine{}, r, sev, Comment{b, e, +l, t, body}, i}}:
738      Some{Covering{Directive{a, SameLine{}, r, sev, Comment{b, e, l, t, body}, i}, l, l}}
739    case Said{Directive{a, NextLine{}, r, sev, Comment{b, e, +l, t, body}, i}}:
740      Some{
741        Covering.next(
742          Directive{a, NextLine{}, r, sev, Comment{b, e, l, t, body}, i},
743          Lines.next_code(lines, None{}, 0, l)
744        )
745      }
746    case Said{Directive{a, Begin{}, r, sev, Comment{b, e, +l, t, body}, +i}}:
747      Some{
748        Covering{
749          Directive{a, Begin{}, r, sev, Comment{b, e, l, t, body}, i},
750          l,
751          Maybe.default(&2, U32, Closed.find(closed, None{}, i), last)
752        }
753      }
754    case _:
755      None{}
756
757def Covering.all(
758  saids: List<&2, Said>,
759  +lines: List<&2, Bool>,
760  +closed: List<&2, Closed>,
761  +last: U32,
762  out: List<&2, Covering>
763) -> List<&2, Covering>:
764  match saids:
765    case Nil{}:
766      List.reverse(&2, Covering, out)
767    case Con{s, rest}:
768      Covering.all(
769        rest,
770        lines,
771        closed,
772        last,
773        Lists.push(Covering, Covering.of(s, lines, closed, last), out)
774      )
775
776def Covering.pins(
777  action: Action,
778  pinned: Maybe<&2, Lint.Severity>,
779  severity: Lint.Severity
780) -> Bool:
781  match action:
782    case Disable{}:
783      True{}
784    case Expect{}:
785      Severity.is_eq(pinned, Some{severity})
786
787# Whether a covering covers a finding of `rule` and `severity` on line `row`.
788def Covering.covers(rule: String, severity: Lint.Severity, +row: U32, covering: Covering) -> Bool:
789  match covering:
790    case Covering{Directive{action, scope, r, sev, c, i}, from, to}:
791      String.eq(
792        r,
793        rule
794      )
795        && U32.is_le(from, row)
796        && U32.is_le(row, to)
797        && Covering.pins(action, sev, severity)
798
799def Covering.hit(hit: Bool, c: Covering, out: List<&2, U32>) -> List<&2, U32>:
800  match hit c:
801    case True{} Covering{Directive{a, s, r, sev, comment, i}, from, to}:
802      i <> out
803    case False{} _:
804      out
805
806def Covering.hits(
807  coverings: List<&2, Covering>,
808  +rule: String,
809  +severity: Lint.Severity,
810  +row: U32,
811  out: List<&2, U32>
812) -> List<&2, U32>:
813  match coverings:
814    case Nil{}:
815      out
816    case Con{+c, rest}:
817      Covering.hits(
818        rest,
819        rule,
820        severity,
821        row,
822        Covering.hit(Covering.covers(rule, severity, row, c), c, out)
823      )
824
825# Applying
826# ========
827
828# The findings so far: those that stay, those a directive covers (both last first), and the directives that covered
829# one.
830type Applied is Data:
831  Applied{kept: List<&2, Lint.Found>, suppressed: List<&2, Lint.Found>, used: List<&2, U32>}
832
833# A file's findings after its directives: those that stay, then the findings about its directives, and those a
834# directive covers.
835type Suppressed is Data:
836  Suppressed{kept: List<&2, Lint.Found>, suppressed: List<&2, Lint.Found>}
837
838def Applied.add(hits: List<&2, U32>, found: Lint.Found, a: Applied) -> Applied:
839  match hits a:
840    case Nil{} Applied{kept, suppressed, used}:
841      Applied{found <> kept, suppressed, used}
842    case Con{h, t} Applied{kept, suppressed, used}:
843      Applied{kept, found <> suppressed, Lists.onto(&2, U32, Con{h, t}, used)}
844
845# The findings, each with where it starts (`at`, in order), sorted by the directives that cover their lines.
846def Applied.go(
847  founds: List<&2, Lint.Found>,
848  at: List<&2, Text.Position>,
849  +coverings: List<&2, Covering>,
850  a: Applied
851) -> Applied:
852  match founds at:
853    case Con{
854      Lint.Found{+rule, Lint.Finding{+severity, span, message, fixes}},
855      rest
856    } Con{Text.Position{line, column}, more}:
857      Applied.go(
858        rest,
859        more,
860        coverings,
861        Applied.add(
862          Covering.hits(coverings, rule, severity, (line - 1: U32), []),
863          Lint.Found{rule, Lint.Finding{severity, span, message, fixes}},
864          a
865        )
866      )
867    case _ _:
868      a
869
870def Unmet.add(
871  used: Bool,
872  action: Action,
873  rule: String,
874  pinned: Maybe<&2, Lint.Severity>,
875  span: Lint.Span,
876  out: List<&2, Lint.Found>
877) -> List<&2, Lint.Found>:
878  match used action:
879    case True{} _:
880      out
881    case False{} Expect{}:
882      Lint.Found{
883        "bend-lint/unmet-expectation",
884        Lint.Finding{
885          Lint.Error{},
886          span,
887          "Expected a "
888            ++ rule
889            ++ "@"
890            ++ Severity.pinned(pinned)
891            ++ " finding on the lines this covers; none.",
892          []
893        }
894      }
895        <> out
896    case False{} Disable{}:
897      Lint.Found{
898        "bend-lint/unused-disable",
899        Lint.Finding{
900          Lint.Warning{},
901          span,
902          "This disables " ++ rule ++ ", but nothing on the lines it covers needs it.",
903          []
904        }
905      }
906        <> out
907
908# The findings about the directives that nothing used: an expect not met, a disable that covered nothing.
909def Unmet.go(
910  coverings: List<&2, Covering>,
911  +used: List<&2, U32>,
912  +path: String,
913  out: List<&2, Lint.Found>
914) -> List<&2, Lint.Found>:
915  match coverings:
916    case Nil{}:
917      List.reverse(&2, Lint.Found, out)
918    case Con{Covering{Directive{a, s, r, sev, Comment{b, e, l, t, body}, i}, from, to}, rest}:
919      Unmet.go(
920        rest,
921        used,
922        path,
923        Unmet.add(List.contains(~U32, ~U32.is_eq, used, i), a, r, sev, Lint.Span{path, b, e}, out)
924      )
925
926def Wrong.found(
927  saids: List<&2, Said>,
928  +path: String,
929  out: List<&2, Lint.Found>
930) -> List<&2, Lint.Found>:
931  match saids:
932    case Nil{}:
933      List.reverse(&2, Lint.Found, out)
934    case Con{Wrong{Comment{b, e, l, t, body}, why}, rest}:
935      Wrong.found(
936        rest,
937        path,
938        Lint.Found{
939          "bend-lint/directive",
940          Lint.Finding{Lint.Error{}, Lint.Span{path, b, e}, why, []}
941        }
942          <> out
943      )
944    case Con{s, rest}:
945      Wrong.found(rest, path, out)
946
947# Directives
948# ==========
949
950# Where each of `founds` starts, in order, onto `out` (last first).
951def Found.offsets(founds: List<&2, Lint.Found>, out: List<&2, U32>) -> List<&2, U32>:
952  match founds:
953    case Nil{}:
954      List.reverse(&2, U32, out)
955    case Con{
956      Lint.Found{rule, Lint.Finding{severity, Lint.Span{path, beg, end}, message, fixes}},
957      rest
958    }:
959      Found.offsets(rest, beg <> out)
960
961# A linted file: its path and text, the ids of the rules there are, and those of the rules that ran on it. A rule
962# turned off did not run, so its directives are never unused.
963type Linted is Data:
964  Linted{path: String, text: String, known: List<&2, String>, ran: List<&2, String>}
965
966def Linted.path(linted: Linted) -> String:
967  match linted:
968    case Linted{path, text, known, ran}:
969      path
970
971def Linted.text(linted: Linted) -> String:
972  match linted:
973    case Linted{path, text, known, ran}:
974      text
975
976def Linted.known(linted: Linted) -> List<&2, String>:
977  match linted:
978    case Linted{path, text, known, ran}:
979      known
980
981def Linted.ran(linted: Linted) -> List<&2, String>:
982  match linted:
983    case Linted{path, text, known, ran}:
984      ran
985
986def Covering.keep(ran: Bool, c: Covering, out: List<&2, Covering>) -> List<&2, Covering>:
987  match ran:
988    case True{}:
989      c <> out
990    case False{}:
991      out
992
993def Covering.ran(
994  coverings: List<&2, Covering>,
995  +ran: List<&2, String>,
996  out: List<&2, Covering>
997) -> List<&2, Covering>:
998  match coverings:
999    case Nil{}:
1000      List.reverse(&2, Covering, out)
1001    case Con{Covering{Directive{a, s, +r, sev, c, i}, from, to}, rest}:
1002      Covering.ran(
1003        rest,
1004        ran,
1005        Covering.keep(
1006          List.contains(~String, ~String.eq, ran, r),
1007          Covering{Directive{a, s, r, sev, c, i}, from, to},
1008          out
1009        )
1010      )
1011
1012def done(
1013  a: Applied,
1014  coverings: List<&2, Covering>,
1015  unpaired: List<&2, Said>,
1016  saids: List<&2, Said>,
1017  +linted: Linted
1018) -> Suppressed:
1019  match a:
1020    case Applied{kept, suppressed, used}:
1021      Suppressed{
1022        Lists.concat(
1023          &2,
1024          Lint.Found,
1025          [
1026            List.reverse(&2, Lint.Found, kept),
1027            Wrong.found(saids, Linted.path(linted), []),
1028            Wrong.found(List.reverse(&2, Said, unpaired), Linted.path(linted), []),
1029            Unmet.go(Covering.ran(coverings, Linted.ran(linted), []), used, Linted.path(linted), [])
1030          ]
1031        ),
1032        List.reverse(&2, Lint.Found, suppressed)
1033      }
1034
1035def covered(
1036  +coverings: List<&2, Covering>,
1037  unpaired: List<&2, Said>,
1038  saids: List<&2, Said>,
1039  +linted: Linted,
1040  +founds: List<&2, Lint.Found>
1041) -> Suppressed:
1042  done(
1043    Applied.go(
1044      founds,
1045      Text.positions(Linted.text(linted), Found.offsets(founds, [])),
1046      coverings,
1047      Applied{[], [], []}
1048    ),
1049    coverings,
1050    unpaired,
1051    saids,
1052    linted
1053  )
1054
1055def paired(
1056  p: Pairing,
1057  +saids: List<&2, Said>,
1058  +lines: List<&2, Bool>,
1059  linted: Linted,
1060  founds: List<&2, Lint.Found>
1061) -> Suppressed:
1062  match p:
1063    case Pairing{open, closed, wrong}:
1064      covered(
1065        Covering.all(
1066          saids,
1067          lines,
1068          closed,
1069          (U32.from_nat(Lists.length(&2, Bool, lines)) - 1: U32),
1070          []
1071        ),
1072        Pairing.unclosed(open, wrong),
1073        saids,
1074        linted,
1075        founds
1076      )
1077
1078def said(
1079  +saids: List<&2, Said>,
1080  lines: List<&2, Bool>,
1081  linted: Linted,
1082  founds: List<&2, Lint.Found>
1083) -> Suppressed:
1084  paired(Pairing.go(saids, Pairing{[], [], []}), saids, lines, linted, founds)
1085
1086def scanned(scanned: Scanned, +linted: Linted, founds: List<&2, Lint.Found>) -> Suppressed:
1087  match scanned:
1088    case Scanned{comments, lines}:
1089      said(Said.all(comments, Linted.known(linted), 0, []), lines, linted, founds)
1090
1091def when(has: Bool, +linted: Linted, founds: List<&2, Lint.Found>) -> Suppressed:
1092  match has:
1093    case True{}:
1094      scanned(Scan.of(Linted.text(linted)), linted, founds)
1095    case False{}:
1096      Suppressed{founds, []}
1097
1098# Whether `text` has a directive's mark, "bend-lint:". Only a 'b' starts a comparison, so it is one pass.
1099def has_mark(text: String, found: Bool) -> Bool:
1100  match text found:
1101    case _ True{}:
1102      True{}
1103    case SNil{} False{}:
1104      False{}
1105    case SCon{'b', +rest} False{}:
1106      has_mark(rest, String.starts_with(rest, "end-lint:"))
1107    case SCon{c, rest} False{}:
1108      has_mark(rest, False{})
1109
1110def apply(+linted: Linted, founds: List<&2, Lint.Found>) -> Suppressed:
1111  when(has_mark(Linted.text(linted), False{}), linted, founds)