Skip to main content

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}