htl_core/lint.rs
1//! The rules a finding can be reported under, and which of them a run has on.
2//!
3//! Every rule name htl prints — the ` [htl <rule>]` suffix a finding's message ends with,
4//! and the `rule` field of `--format json` — is one entry of [`RULES`]. Fifteen of them are
5//! implemented in `lint.lua`, five in the project layer and seven by the vendored Teal
6//! compiler, and that difference used to decide what a project could say about them: the
7//! registry was `L.DEFAULT` in `lint.lua`, so `--lint` and `[lint]` knew the thirteen and
8//! answered `unknown lint rule: contract` to a name htl had just printed.
9//!
10//! The list lives on this side because both sides can read it here and only one of them
11//! could read it there. The Lua side keeps no defaults of its own any more: it is handed
12//! the resolved selection ([`Htl::select_lints`](crate::Htl::select_lints)), so the names a
13//! project may write and the names that run cannot drift apart. What `lint.lua` still owns
14//! is the *implementation* of its fifteen — `tests/lint_registry.rs` holds that list to this
15//! one, so a rule renamed on one side fails a test rather than going quietly silent.
16//!
17//! Whether a rule is on and how much it matters are one question here, answered by a
18//! [`Level`]: `allow` is not reported, `warn` is reported, `deny` is reported and fails the
19//! run. A rule's default is a level ([`Rule::default`]) and a project overrides it by name
20//! (`[lint.rules]`, `--lint`), which is what lets one rule be advice while another stops
21//! the run. `strict` is not a fourth thing: it promotes every `warn` of the run to `deny`.
22//!
23//! Not every entry of [`RULES`] is a name a run can turn off. Two of them —
24//! `forward-ref` and `tl:error` — are the classes `htl fix` gives to a Teal error that has
25//! no rule of its own: read by `htl fix --rule` and `[fix] disable`, consulted by nothing
26//! in a check. So each entry says which surfaces it appears on ([`Surfaces`]), and the two
27//! sides ask different questions of the list. What the listing prints and a spec takes
28//! ([`rule_defaults`], [`rule_names`], [`Selection::parse`]) is the rules a check reports
29//! under; what a fix filter takes ([`fix_rule_names`], [`check_fix_rules`]) is all of them,
30//! since a fix travels with the diagnostic of whatever named it. Without the split,
31//! `--list-lints` would print a name no level applies to and `--lint -forward-ref` would
32//! parse into an off switch nothing reads.
33//!
34//! A rule can also be renamed, and [`RENAMED`] is where the old spelling is kept so that a
35//! project which wrote it is told what to write instead. There is one entry: bare `error`
36//! became `tl:error` when it joined the identity space, because `error` as a name collides
37//! with everything and Teal's errors belong in the same namespace as its warnings. The
38//! fix filters match by string, so without the entry `--rule error` would select nothing
39//! and report that it fixed nothing — which reads exactly like a project with nothing to
40//! fix.
41
42use crate::{Diagnostic, Severity};
43use anyhow::{Result, bail};
44use serde::Deserialize;
45use std::cell::RefCell;
46use std::collections::HashMap;
47use std::path::{Path, PathBuf};
48
49/// Which part of htl produces a finding under a rule. It decides nothing a user can see —
50/// all three are configured by the same names and silenced by the same comment — and
51/// exists so that a selection can be handed to a producer without the rules it does not
52/// produce.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Side {
55 /// A rule of `lint.lua`, run over one file's syntax tree.
56 Lua,
57 /// A rule of the project layer, run over what a check resolved.
58 Rust,
59 /// A warning kind of the vendored Teal compiler, reported under the name Teal gives
60 /// it. htl decides whether it is said; it does not decide what it says.
61 Tl,
62}
63
64/// How much a finding under a rule matters: whether it is said, and whether the run fails
65/// on it.
66///
67/// The three words are selene's `[lints]` and Cargo's, in that spelling, because a reader
68/// arriving from either already knows them. Nothing defaults to [`Deny`](Self::Deny) — the
69/// levels a project gets without writing anything are `warn` for every rule htl reports
70/// and `allow` for the three that are opinions, the one that is a flow question and the
71/// one about code that is already right — so `deny` is the thing a project asks
72/// for, and `strict` is asking for it run-wide.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum Level {
76 /// Not reported, and so not counted: a run is judged on what it said.
77 Allow,
78 /// Reported; the run does not fail on it. What every rule htl reported was, before
79 /// levels: `--strict` was the only way to make one matter.
80 Warn,
81 /// Reported, and the run fails. `htl check` exits 1 on a finding at this level with no
82 /// flag needed.
83 Deny,
84}
85
86impl Level {
87 /// The word a config and a spec write it as.
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Self::Allow => "allow",
91 Self::Warn => "warn",
92 Self::Deny => "deny",
93 }
94 }
95
96 /// A level from the word, or `None` for anything else.
97 pub fn parse(s: &str) -> Option<Self> {
98 match s {
99 "allow" => Some(Self::Allow),
100 "warn" => Some(Self::Warn),
101 "deny" => Some(Self::Deny),
102 _ => None,
103 }
104 }
105
106 /// Whether a finding at this level is reported at all.
107 pub fn is_on(self) -> bool {
108 self != Self::Allow
109 }
110}
111
112impl std::fmt::Display for Level {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str(self.as_str())
115 }
116}
117
118/// Which of htl's two rule-name surfaces a rule appears on.
119///
120/// Two things in htl take a rule by name and they do not take the same set. One is the
121/// lint surface — `htl check --list-lints`, `[lint.rules]`, `--lint`, `HTL_LINTS`, and the
122/// `-- htl: allow(...)` comment — which answers "is this said, and does it stop the run".
123/// The other is the fix surface — `htl fix --rule`, `[fix] disable`, `[fix] unsafe` —
124/// which answers "may a tool rewrite this". The two questions are independent (a level
125/// never decides applicability and applicability never decides a level), and `[lint]` and
126/// `[fix]` stay two tables; what this says is only *which names each table may contain*.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Surfaces {
129 /// Both. A rule some check reports under: the listing prints it with its default
130 /// level, a spec sets that level, an allow comment silences one occurrence — and a
131 /// fix filter names it too, because a finding under it may carry a fix.
132 LintAndFix,
133 /// The fix surface alone. A class `htl fix` gives to a Teal error that has no rule of
134 /// its own: nothing in a check reports under the name, so there is no level for a
135 /// project to set, nothing for the listing to print and nothing an allow comment
136 /// could silence. `htl fix --rule` and `[fix] disable` still have to name it, which is
137 /// why it is a registered rule rather than a bare string in `fix.rs`.
138 FixOnly,
139}
140
141/// One rule: the name it is reported and configured under, the level a project that says
142/// nothing about it gets, which half implements it, and where the name is taken.
143#[derive(Debug, Clone, Copy)]
144pub struct Rule {
145 /// The one spelling of this rule everywhere it can be named: a report's `[htl <name>]`
146 /// suffix, `[lint.rules]`, `--lint`, `HTL_LINTS`, `-- htl: allow(...)`, and the fix
147 /// filters. A `tl:` prefix marks a name the Teal compiler gave rather than htl.
148 pub name: &'static str,
149 /// What a check says under this name for a project that configures nothing. A
150 /// [`Surfaces::FixOnly`] rule is [`Level::Allow`], which is not a placeholder: no
151 /// check reports under it, so "nothing is said under this name" is the true answer,
152 /// and it is the answer nothing can change — the listing does not print it and a spec
153 /// refuses to set it.
154 pub default: Level,
155 /// Which half of htl implements it, and so where a finding under it comes from. Not a
156 /// setting — a project cannot move a rule from one side to the other — but it is what
157 /// decides which selection table a run hands the name to.
158 pub side: Side,
159 /// Which of the two name surfaces may contain it. See [`Surfaces`].
160 pub surfaces: Surfaces,
161}
162
163impl Rule {
164 /// Reported, and advisory until a project says `deny` or the run says `strict`.
165 const fn warn(name: &'static str, side: Side) -> Self {
166 Self {
167 name,
168 default: Level::Warn,
169 side,
170 surfaces: Surfaces::LintAndFix,
171 }
172 }
173
174 /// Not reported until a project asks for it. An opinion htl has, rather than a state
175 /// a project is in by accident.
176 const fn allow(name: &'static str, side: Side) -> Self {
177 Self {
178 name,
179 default: Level::Allow,
180 side,
181 surfaces: Surfaces::LintAndFix,
182 }
183 }
184
185 /// A name only `htl fix` uses: the class it files an error's fix under when the error
186 /// has no rule to file it under.
187 const fn fix_class(name: &'static str, side: Side) -> Self {
188 Self {
189 name,
190 default: Level::Allow,
191 side,
192 surfaces: Surfaces::FixOnly,
193 }
194 }
195
196 /// Whether the listing prints this rule and a spec may set its level.
197 pub fn is_lint(&self) -> bool {
198 matches!(self.surfaces, Surfaces::LintAndFix)
199 }
200}
201
202/// Every rule there is. The first twenty-six are the lint surface, in the order
203/// `htl check --list-lints` prints them: the file-level rules first, in the order
204/// `lint.lua` runs them, then the ones the project layer asks once the files have been
205/// checked, then the warning kinds the vendored Teal compiler reports for itself. The last
206/// two are `htl fix`'s error classes, which the listing does not print — see [`Surfaces`].
207pub const RULES: &[Rule] = &[
208 Rule::warn("nil-index", Side::Lua),
209 Rule::warn("nil-return", Side::Lua),
210 // `allow`, unlike its half above, and that is the point. Indexing a call's result
211 // directly is wrong whatever an analysis says; holding the local it was bound to to a
212 // guard is a flow question, and the three shapes lua-language-server has had open on
213 // its equivalent for years (a check inside a helper, a check on a second local, `or
214 // error(...)`) are what a project would be arguing with. A rule that is wrong while it
215 // is doing its job does not belong in every project by default; one that wants it says
216 // so by name.
217 Rule::allow("nil-return-unchecked", Side::Lua),
218 // `allow`, and for a third reason again. The two above are about code that may be
219 // wrong; this one is about code that is right and that a library the project already
220 // depends on says in one call. It is silent unless `htlx` is installed — advice to use
221 // what a project has, never to take on what it does not — and it holds a loop to three
222 // conditions before reading it as a call, so it is rarely wrong. Rarely is still not a
223 // reason to argue with every project about working code, so a project asks for it.
224 Rule::allow("htlx-available", Side::Lua),
225 Rule::warn("struct-fields", Side::Lua),
226 Rule::warn("sealed-record", Side::Lua),
227 Rule::warn("enum-exhaustive", Side::Lua),
228 Rule::warn("enum-cast", Side::Lua),
229 Rule::warn("enum-table", Side::Lua),
230 Rule::warn("union-exhaustive", Side::Lua),
231 Rule::warn("shadow-local", Side::Lua),
232 Rule::warn("no-global", Side::Lua),
233 Rule::allow("no-any", Side::Lua),
234 Rule::allow("explicit-number", Side::Lua),
235 Rule::allow("class-record", Side::Lua),
236 // The project layer. All `warn`: each describes a state a project is in by accident
237 // rather than on purpose, so it is worth saying, and none of them is worth failing a
238 // run over unless the project says so — which is what a level is for.
239 Rule::warn("duplicate-declaration", Side::Rust),
240 Rule::warn("host-module-shadowed", Side::Rust),
241 Rule::warn("contract", Side::Rust),
242 Rule::warn("contract-unenforced", Side::Rust),
243 Rule::warn("require-cycle", Side::Rust),
244 // Teal's warning kinds, kept in the compiler's own vocabulary behind a `tl:` prefix.
245 // The prefix is not decoration. `unused` already means something else here — `htl
246 // unused` reports modules nothing requires, not locals nothing reads — and these seven
247 // words are Teal's to rename, not htl's; keeping them in a namespace of their own says
248 // where they came from and leaves htl's fifteen free of them.
249 //
250 // All `warn`, which is what they have always been: the compiler raises them as
251 // warnings and htl forwarded them as warnings long before it could name them. There is
252 // one severity to inherit, so the level says the same thing with nothing added — see
253 // the withdrawn fourth level in the umbrella issue.
254 Rule::warn("tl:unknown", Side::Tl),
255 Rule::warn("tl:unused", Side::Tl),
256 Rule::warn("tl:unread", Side::Tl),
257 Rule::warn("tl:redeclaration", Side::Tl),
258 Rule::warn("tl:branch", Side::Tl),
259 Rule::warn("tl:hint", Side::Tl),
260 Rule::warn("tl:debug", Side::Tl),
261 // The classes `htl fix` files an error's fix under. A Teal error carries no rule of
262 // its own — the compiler does not name its errors the way it names its warning kinds —
263 // so `--rule` and `[fix] disable` would have nothing to say about one. These two are
264 // that name. `forward-ref` is the class htl recognises by its message (a record key
265 // used before the function that defines it); `tl:error` is every other error, in the
266 // same namespace as the compiler's warnings because it is the same compiler speaking.
267 //
268 // Both are `Side::Tl` — the compiler is what produced the diagnostic — and neither is
269 // a lint: no check reports under either name, and `--list-lints` and `[lint.rules]`
270 // say so by not having them.
271 Rule::fix_class("forward-ref", Side::Tl),
272 Rule::fix_class("tl:error", Side::Tl),
273];
274
275/// Old spellings and what they are called now: consulted only to answer a project that
276/// wrote one, never to accept it.
277///
278/// `error` was the class of every fixable Teal error that was not a forward reference,
279/// and it was a name a project could write into `--rule` and `[fix] disable`. Renaming it
280/// is a breaking change made on purpose, so what it must not be is a *silent* one: the
281/// filters match by string, and an unrecognised name would simply match nothing and
282/// report that nothing was fixed.
283pub const RENAMED: &[(&str, &str)] = &[("error", "tl:error")];
284
285/// The rules of the lint surface, with their position in a [`Selection`].
286fn lint_rules() -> impl Iterator<Item = (usize, &'static Rule)> {
287 RULES.iter().filter(|r| r.is_lint()).enumerate()
288}
289
290/// Where `name` sits in a [`Selection`], or `None` when it is not a rule of the lint
291/// surface (a fix class, or not a rule at all).
292fn index_of(name: &str) -> Option<usize> {
293 lint_rules().find(|(_, r)| r.name == name).map(|(i, _)| i)
294}
295
296/// The name of every rule of the lint surface, in [`RULES`] order.
297pub fn rule_names() -> Vec<&'static str> {
298 lint_rules().map(|(_, r)| r.name).collect()
299}
300
301/// Every rule of the lint surface with the level a project that says nothing gets, in
302/// [`RULES`] order. What `htl check --list-lints` prints, which is the one place a reader
303/// sees which rules are `allow` without having to fail to provoke one.
304pub fn rule_defaults() -> Vec<(&'static str, Level)> {
305 lint_rules().map(|(_, r)| (r.name, r.default)).collect()
306}
307
308/// The names `htl fix --rule`, `[fix] disable` and `[fix] unsafe` take: every rule there
309/// is. A fix travels with a diagnostic, so any rule that can report can carry one, and the
310/// two classes exist for the errors that report under no rule.
311///
312/// A rule with no fix to its name is accepted here and matches nothing — `--rule
313/// require-cycle` is a run that fixes nothing rather than an error — because which rules
314/// carry fixes is a fact about today's implementations, not about the vocabulary.
315pub fn fix_rule_names() -> Vec<&'static str> {
316 RULES.iter().map(|r| r.name).collect()
317}
318
319/// Refuse the names a fix filter was given that no fix could ever be filed under.
320///
321/// `surface` is what the message calls the filter (`htl fix --rule`, `[fix] disable`,
322/// `[fix] unsafe`). A name that was renamed is answered with the name it has now: this is
323/// the only place that knows the old spelling meant something, and a project reading
324/// "unknown" about a word that used to work would be left guessing.
325pub fn check_fix_rules(names: &[String], surface: &str) -> Result<()> {
326 for name in names {
327 if RULES.iter().any(|r| r.name == name.as_str()) {
328 continue;
329 }
330 if let Some((_, now)) = RENAMED.iter().find(|(old, _)| *old == name.as_str()) {
331 bail!(
332 "{surface}: `{name}` is now `{now}` — Teal's errors are named in the `tl:` \
333 namespace, as its warnings are"
334 );
335 }
336 bail!(
337 "{surface}: unknown rule `{name}`. Takes any rule `htl check --list-lints` \
338 names, or one of the classes an error's fix is filed under: {}",
339 fix_classes().join(", ")
340 );
341 }
342 Ok(())
343}
344
345/// The names that exist on the fix surface and nowhere else.
346pub fn fix_classes() -> Vec<&'static str> {
347 RULES
348 .iter()
349 .filter(|r| !r.is_lint())
350 .map(|r| r.name)
351 .collect()
352}
353
354/// What level each rule has for a run: the defaults, with a spec applied over them.
355///
356/// The spec is what `[lint.rules]` is turned into
357/// ([`HtlConfig::lint_spec`](crate::config::HtlConfig::lint_spec)), what `--lint` takes and
358/// what `HTL_LINTS` carries into `include_tl!`. Later entries win, so a flag can raise or
359/// lower what the file set.
360#[derive(Debug, Clone)]
361pub struct Selection {
362 /// Parallel to the lint surface of [`RULES`] — a fix class has no level to carry, so
363 /// there is no slot for one here either.
364 levels: Vec<Level>,
365}
366
367impl Default for Selection {
368 fn default() -> Self {
369 Self {
370 levels: lint_rules().map(|(_, r)| r.default).collect(),
371 }
372 }
373}
374
375impl Selection {
376 /// `"no-any=warn,nil-index=deny"` over the defaults.
377 ///
378 /// `+rule` and `-rule` are the shorthand the flag has always taken, and they say the
379 /// same thing a level does: `+` is `=warn` (say it) and `-` is `=allow` (do not).
380 /// A bare name is `+name`.
381 ///
382 /// An unknown name is an error rather than a no-op: a typo in `htl.toml` that
383 /// silently turned nothing on would read exactly like a rule that found nothing. So
384 /// is an unknown level, for the same reason — a misspelt `deny` that meant "fail the
385 /// run" must not read as "everything passed".
386 ///
387 /// A name that is a rule but not one of this surface ([`Surfaces::FixOnly`]) is
388 /// refused too, and says so rather than saying "unknown": the name exists, and what a
389 /// reader needs to hear is that a level is not a thing it has.
390 pub fn parse(spec: &str) -> Result<Self> {
391 let mut sel = Self::default();
392 for item in spec
393 .split(|c: char| c == ',' || c.is_whitespace())
394 .filter(|s| !s.is_empty())
395 {
396 let (name, level) = match item.split_once('=') {
397 Some((name, word)) => {
398 let Some(level) = Level::parse(word.trim()) else {
399 bail!("unknown lint level: {item} (allow, warn or deny)");
400 };
401 (name.trim(), level)
402 }
403 None => match item.strip_prefix('-') {
404 Some(rest) => (rest, Level::Allow),
405 None => (item.strip_prefix('+').unwrap_or(item), Level::Warn),
406 },
407 };
408 let Some(i) = index_of(name) else {
409 if RULES.iter().any(|r| r.name == name) {
410 bail!(
411 "not a lint rule: {name} is the class `htl fix` files an error's \
412 fix under, taken by `htl fix --rule` and `[fix] disable`. No \
413 check reports under it, so it has no level"
414 );
415 }
416 bail!("unknown lint rule: {item}");
417 };
418 sel.levels[i] = level;
419 }
420 Ok(sel)
421 }
422
423 /// The level `name` has for this run. An unknown name is [`Level::Allow`]: nothing
424 /// produces one, and a caller asking about a name that is not a rule is asking about
425 /// nothing.
426 pub fn level_of(&self, name: &str) -> Level {
427 index_of(name).map_or(Level::Allow, |i| self.levels[i])
428 }
429
430 /// Whether this run reports `name` at all — `allow` is the only level that does not.
431 pub fn is_on(&self, name: &str) -> bool {
432 self.level_of(name).is_on()
433 }
434
435 /// The rules of one side and whether each is on, for a consumer that has to be handed
436 /// the selection rather than ask about it — `lint.lua`, which runs its fifteen from a
437 /// table. Fix classes are not among them: no producer produces one, so there is
438 /// nothing to tell a producer about them.
439 ///
440 /// A producer is told whether to produce and not how much it matters: the level of
441 /// what it produced is read where the run is judged ([`Lints::level`]), so a rule
442 /// moving between `warn` and `deny` changes no producer's work.
443 pub fn of_side(&self, side: Side) -> impl Iterator<Item = (&'static str, bool)> + '_ {
444 lint_rules()
445 .filter(move |(_, r)| r.side == side)
446 .map(|(i, r)| (r.name, self.levels[i].is_on()))
447 }
448}
449
450/// A run's rule selection together with the `-- htl: allow(...)` comments of the sources it
451/// reports on: everything needed to decide whether a finding of the project layer is said.
452///
453/// `lint.lua` answers the same two questions for its own fifteen, inside `report`. This is
454/// the other half — the same allow syntax, read from the file a finding points into. The
455/// mechanism was never specific to Lua rules: an allow comment needs a line number and a
456/// rule name, and a project-layer finding has both.
457///
458/// A file is read at most once per run, and only when something was reported in it.
459pub struct Lints {
460 sel: Selection,
461 /// file -> line -> the rules that line allows. `None` for a file that could not be
462 /// read (a diagnostic anchored at a path relative to somewhere else, or at `htl.toml`).
463 allows: RefCell<HashMap<PathBuf, Option<AllowedLines>>>,
464}
465
466/// The `-- htl: allow(...)` lines of one source: line number -> the rules it names.
467type AllowedLines = HashMap<usize, Vec<String>>;
468
469impl Lints {
470 /// A run over an already-built [`Selection`]. The allow-comment cache starts empty and
471 /// fills as findings arrive, so a run that reports nothing reads no source.
472 pub fn new(sel: Selection) -> Self {
473 Self {
474 sel,
475 allows: RefCell::new(HashMap::new()),
476 }
477 }
478
479 /// The run's selection from a `+rule,-rule` spec.
480 pub fn parse(spec: &str) -> Result<Self> {
481 Ok(Self::new(Selection::parse(spec)?))
482 }
483
484 /// The levels this run resolved to, for a caller that has to hand them somewhere else
485 /// — the Lua side's selection table, or a report of what a spec came to.
486 pub fn selection(&self) -> &Selection {
487 &self.sel
488 }
489
490 /// Whether this run reports `rule` at all. Ask before doing the work a rule needs:
491 /// the contract rules type-check a module and scan a crate's Rust sources, and a run
492 /// that turned them off should pay for neither.
493 pub fn on(&self, rule: &str) -> bool {
494 self.sel.is_on(rule)
495 }
496
497 /// The level `rule` has for this run: what decides whether a finding under it fails
498 /// the run, once it has been decided that the finding is said at all.
499 pub fn level(&self, rule: &str) -> Level {
500 self.sel.level_of(rule)
501 }
502
503 /// The findings of `lines` this run says: the rest are a rule the run has off, or a
504 /// site whose line allows the rule by name.
505 ///
506 /// Text with no ` [htl <rule>]` suffix is kept. Nothing the project layer produces is
507 /// in that shape, and dropping a finding because its name could not be read would be
508 /// the wrong way round.
509 pub fn keep(&self, lines: Vec<String>) -> Vec<String> {
510 lines
511 .into_iter()
512 .filter(|l| {
513 let d = Diagnostic::parse(Severity::Lint, l);
514 let Some(rule) = d.rule.as_deref() else {
515 return true;
516 };
517 self.on(rule) && !self.allowed(Path::new(&d.file), d.line, rule)
518 })
519 .collect()
520 }
521
522 /// Whether the source line the finding points at carries `-- htl: allow(<rule>)`.
523 fn allowed(&self, file: &Path, line: usize, rule: &str) -> bool {
524 if line == 0 || file.as_os_str().is_empty() {
525 return false;
526 }
527 let mut cache = self.allows.borrow_mut();
528 let entry = cache.entry(file.to_path_buf()).or_insert_with(|| {
529 std::fs::read_to_string(file)
530 .ok()
531 .map(|s| collect_allows(&s))
532 });
533 entry
534 .as_ref()
535 .and_then(|m| m.get(&line))
536 .is_some_and(|names| names.iter().any(|n| n == rule))
537 }
538}
539
540/// The `-- htl: allow(a, b)` comments of a source, by line number.
541///
542/// The sibling of `collect_allows` in `lint.lua`, and it has to accept what that accepts:
543/// the comment anywhere on the line, any spacing around the `htl:`, names separated by
544/// commas or spaces.
545fn collect_allows(src: &str) -> AllowedLines {
546 let mut out: AllowedLines = HashMap::new();
547 for (i, line) in src.lines().enumerate() {
548 for (at, _) in line.match_indices("--") {
549 let rest = line[at + 2..].trim_start();
550 let Some(rest) = rest.strip_prefix("htl:") else {
551 continue;
552 };
553 let Some(rest) = rest.trim_start().strip_prefix("allow(") else {
554 continue;
555 };
556 let Some(end) = rest.find(')') else { continue };
557 let names = rest[..end]
558 .split(|c: char| c == ',' || c.is_whitespace())
559 .filter(|s| !s.is_empty())
560 .map(str::to_string);
561 out.entry(i + 1).or_default().extend(names);
562 break;
563 }
564 }
565 out
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn every_name_is_registered_once() {
574 let mut names: Vec<&str> = rule_names();
575 let n = names.len();
576 names.sort_unstable();
577 names.dedup();
578 assert_eq!(names.len(), n, "a rule name appears twice in RULES");
579 }
580
581 #[test]
582 fn a_spec_turns_rules_on_and_off_over_the_defaults() {
583 let sel = Selection::parse("+no-any,-nil-index").unwrap();
584 assert!(sel.is_on("no-any"));
585 assert!(!sel.is_on("nil-index"));
586 // Untouched rules keep their default.
587 assert!(sel.is_on("shadow-local"));
588 assert!(!sel.is_on("class-record"));
589 }
590
591 /// The defaults are the levels a project that writes nothing gets, and they are what
592 /// the run before levels behaved as: everything htl reported was advisory, and the
593 /// three opinions were not reported. Nothing is `deny`, so no project's verdict
594 /// changed when levels arrived.
595 ///
596 /// `nil-return-unchecked` joined them later and is `allow` for a different reason: not
597 /// an opinion, but a flow rule whose false positives are the cases where something did
598 /// check and it could not tell. `htlx-available` for a third: the code it reports is
599 /// right, and the call it names is a library's, not htl's.
600 #[test]
601 fn the_defaults_are_warn_except_the_opinions_and_the_two_that_are_not() {
602 for (name, level) in rule_defaults() {
603 let want = match name {
604 "no-any"
605 | "explicit-number"
606 | "class-record"
607 | "nil-return-unchecked"
608 | "htlx-available" => Level::Allow,
609 _ => Level::Warn,
610 };
611 assert_eq!(level, want, "{name}");
612 }
613 }
614
615 #[test]
616 fn a_spec_sets_a_level_by_name() {
617 let sel = Selection::parse("nil-index=deny,tl:hint=allow,no-any=warn").unwrap();
618 assert_eq!(sel.level_of("nil-index"), Level::Deny);
619 assert_eq!(sel.level_of("tl:hint"), Level::Allow);
620 assert_eq!(sel.level_of("no-any"), Level::Warn);
621 // Untouched rules keep their default level.
622 assert_eq!(sel.level_of("shadow-local"), Level::Warn);
623 assert_eq!(sel.level_of("class-record"), Level::Allow);
624 }
625
626 /// `+`/`-` are the older spelling and say the same thing, so a `HTL_LINTS` already in
627 /// someone's CI keeps its meaning.
628 #[test]
629 fn the_plus_and_minus_spelling_is_the_same_as_a_level() {
630 let short = Selection::parse("+no-any,-nil-index").unwrap();
631 let long = Selection::parse("no-any=warn,nil-index=allow").unwrap();
632 for (name, _) in rule_defaults() {
633 assert_eq!(short.level_of(name), long.level_of(name), "{name}");
634 }
635 }
636
637 #[test]
638 fn later_entries_win_so_a_flag_can_raise_what_a_file_set() {
639 let sel = Selection::parse("nil-index=allow,nil-index=deny").unwrap();
640 assert_eq!(sel.level_of("nil-index"), Level::Deny);
641 }
642
643 /// A misspelt level must not read as "nothing to report": it is refused as written,
644 /// the way an unknown name is.
645 #[test]
646 fn an_unknown_level_is_refused_as_written() {
647 let err = Selection::parse("nil-index=error").unwrap_err().to_string();
648 assert_eq!(
649 err,
650 "unknown lint level: nil-index=error (allow, warn or deny)"
651 );
652 assert!(Selection::parse("no-such-rule=deny").is_err());
653 }
654
655 #[test]
656 fn the_names_the_project_layer_prints_are_names_a_spec_takes() {
657 for rule in [
658 "require-cycle",
659 "duplicate-declaration",
660 "host-module-shadowed",
661 "contract",
662 "contract-unenforced",
663 ] {
664 let sel = Selection::parse(&format!("-{rule}")).unwrap();
665 assert!(!sel.is_on(rule), "{rule} stayed on");
666 }
667 }
668
669 #[test]
670 fn a_teal_warning_kind_is_a_name_a_spec_takes() {
671 // The `:` is inside the name, not a separator: a spec splits on commas and
672 // whitespace only, so `-tl:hint` is one item naming one rule.
673 let sel = Selection::parse("-tl:hint,-tl:unused").unwrap();
674 assert!(!sel.is_on("tl:hint"));
675 assert!(!sel.is_on("tl:unused"));
676 assert!(sel.is_on("tl:redeclaration"), "the rest keep their default");
677 // And the prefix is load-bearing: htl has no rule called `hint`.
678 assert!(Selection::parse("-hint").is_err());
679 }
680
681 #[test]
682 fn an_unknown_name_is_refused_as_written() {
683 let err = Selection::parse("+nil-idex").unwrap_err().to_string();
684 assert_eq!(err, "unknown lint rule: +nil-idex");
685 }
686
687 /// The two surfaces do not take the same names any more. The listing is the smaller
688 /// set, and every name in it round-trips through a spec — which is the property the
689 /// registry exists for, held here for whatever the listing prints rather than for a
690 /// list written out by hand.
691 #[test]
692 fn the_listing_prints_fewer_names_than_a_fix_filter_takes() {
693 let listed = rule_names();
694 let fixable = fix_rule_names();
695 for name in &listed {
696 assert!(fixable.contains(name), "{name} is not a name a fix takes");
697 Selection::parse(&format!("{name}=deny"))
698 .unwrap_or_else(|e| panic!("the listing prints {name} and a spec refuses it: {e}"));
699 }
700 let only_fix: Vec<&str> = fixable
701 .iter()
702 .copied()
703 .filter(|n| !listed.contains(n))
704 .collect();
705 assert_eq!(only_fix, fix_classes());
706 assert_eq!(only_fix, ["forward-ref", "tl:error"], "{only_fix:?}");
707 }
708
709 /// A fix class is refused by a spec, and told apart from a typo: the name exists, and
710 /// what a reader needs to hear is that a level is not something it has.
711 #[test]
712 fn a_fix_class_has_no_level_to_set() {
713 for name in fix_classes() {
714 let err = Selection::parse(&format!("-{name}"))
715 .unwrap_err()
716 .to_string();
717 assert!(err.starts_with("not a lint rule: "), "{err}");
718 assert!(
719 err.contains(name) && err.contains("htl fix --rule"),
720 "{err}"
721 );
722 // And it is not silently on: nothing produces one, so nothing reports one.
723 assert!(!Selection::default().is_on(name), "{name}");
724 }
725 }
726
727 #[test]
728 fn a_fix_filter_takes_a_class_and_a_rule_and_refuses_a_typo() {
729 let names = |s: &[&str]| s.iter().map(|n| (*n).to_string()).collect::<Vec<_>>();
730 check_fix_rules(
731 &names(&["tl:error", "forward-ref", "no-global"]),
732 "[fix] disable",
733 )
734 .unwrap();
735 let err = check_fix_rules(&names(&["forwardref"]), "htl fix --rule")
736 .unwrap_err()
737 .to_string();
738 assert!(
739 err.contains("htl fix --rule: unknown rule `forwardref`"),
740 "{err}"
741 );
742 // The message says where to look, and names the classes the listing will not.
743 assert!(
744 err.contains("--list-lints") && err.contains("tl:error"),
745 "{err}"
746 );
747 }
748
749 /// The rename is breaking and says so. A project that wrote `error` is told the name
750 /// it has now, rather than being told it is unknown — or, worse, being told nothing
751 /// and shown a run that fixed nothing.
752 #[test]
753 fn the_old_error_spelling_names_what_replaced_it() {
754 for surface in ["htl fix --rule", "[fix] disable", "[fix] unsafe"] {
755 let err = check_fix_rules(&["error".to_string()], surface)
756 .unwrap_err()
757 .to_string();
758 assert_eq!(
759 err,
760 format!(
761 "{surface}: `error` is now `tl:error` — Teal's errors are named in the \
762 `tl:` namespace, as its warnings are"
763 )
764 );
765 }
766 }
767
768 #[test]
769 fn an_allow_comment_names_rules_for_its_own_line() {
770 let src = "local t = {}\nlocal x = t[1].y -- htl: allow(nil-index, shadow-local)\n";
771 let allows = collect_allows(src);
772 assert_eq!(allows.get(&1), None);
773 assert_eq!(
774 allows.get(&2).unwrap(),
775 &vec!["nil-index".to_string(), "shadow-local".to_string()]
776 );
777 }
778
779 #[test]
780 fn a_finding_is_dropped_by_the_rule_being_off() {
781 let lints = Lints::parse("-require-cycle").unwrap();
782 let kept = lints.keep(vec![
783 "a.tl:1:1: a -> b -> a [htl require-cycle]".to_string(),
784 "a.tl:2:1: x is declared more than once [htl duplicate-declaration]".to_string(),
785 ]);
786 assert_eq!(kept.len(), 1);
787 assert!(kept[0].contains("duplicate-declaration"));
788 }
789}