Skip to main content

lanekeep_core/
suppression.rs

1//! Suppression directives: a next-line form and a whole-file form.
2//!
3//! Each is a comment carrying its form's token, the rule ids it silences, and a mandatory
4//! `reason:` — optionally followed by `expires:` and a date. The two tokens are the
5//! `NEXT_LINE` and `WHOLE_FILE` constants below, and this file cannot spell either of them
6//! out, for the reason under "Scanning text, not the tree". So the shape, with the tokens
7//! stood in for:
8//!
9//! ```text
10//! // <next-line-token> local/no-numeric-sizes reason: legacy API requires exact 44
11//! minWidth: 44,
12//!
13//! // <whole-file-token> local/no-primitive-components reason: generated fixture
14//! ```
15//!
16//! # A suppression that does not work must say so
17//!
18//! The failure mode this module is arranged against is a directive that looks like it
19//! silences something and does not — a typo in the rule id, a missing `reason:`, a token
20//! with a hyphen dropped out of it. The author moves on believing the violation is handled,
21//! and nothing ever tells them otherwise.
22//!
23//! So a malformed directive is **reported**, not skipped. Every field that could be
24//! mistyped is either required or checked, and the diagnostic names what was wrong.
25//!
26//! # Why `reason:` is mandatory
27//!
28//! A suppression is a decision to accept a violation, and the next person to read it needs
29//! to know whether that decision still holds. Without a reason it is indistinguishable from
30//! someone silencing a diagnostic to make a build pass.
31//!
32//! # Scanning text, not the tree
33//!
34//! Directives are found by scanning the source for a standalone token rather than by
35//! walking comments in the parse tree. That is what §10 specifies, it costs one pass over
36//! bytes already in memory, and it works for a file that failed to parse.
37//!
38//! The cost is that a directive inside a string literal counts. That is a strange thing to
39//! write and the consequence is a suppression that does nothing visible, which the unused
40//! report surfaces.
41//!
42//! It also applies to this file, which lanekeep checks along with the rest of its own
43//! source. A token spelled out here — in prose, in a doc example, in a test fixture — is a
44//! directive, not a description of one: either a malformed one that gets reported, or a
45//! well-formed one quietly silencing the rule it names, for the whole file if it is the
46//! whole-file form. So the two constants below are assembled from pieces, and everything
47//! needing a token builds it from them. Nothing in this file writes one out.
48
49use crate::rule_id::RuleId;
50
51/// The token introducing a directive that covers the following line.
52///
53/// Assembled rather than written, per the module documentation. The value is byte-for-byte
54/// what the scanner looks for; only this file's own source differs.
55const NEXT_LINE: &str = concat!("lanekeep", "-ignore-next-line");
56
57/// The token introducing a directive that covers the whole file. Assembled for the same
58/// reason as `NEXT_LINE`.
59const WHOLE_FILE: &str = concat!("lanekeep", "-ignore-file");
60
61/// What a directive covers.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Scope {
64    /// The line after the directive.
65    NextLine,
66    /// Every line in the file.
67    File,
68}
69
70/// A calendar date, for `expires:`.
71///
72/// Deliberately not a general date type: it exists to be parsed from `YYYY-MM-DD`, compared,
73/// and printed. Comparison is on the tuple, which is why the fields are in that order.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75pub struct Date {
76    /// Four-digit year.
77    pub year: u16,
78    /// One-based month.
79    pub month: u8,
80    /// One-based day.
81    pub day: u8,
82}
83
84impl Date {
85    /// Parse `YYYY-MM-DD`.
86    ///
87    /// Rejects anything else, including a real date written another way. A directive whose
88    /// expiry could not be read would otherwise never expire, which is the one outcome an
89    /// expiry exists to prevent.
90    #[must_use]
91    pub fn parse(text: &str) -> Option<Self> {
92        let bytes = text.as_bytes();
93        if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
94            return None;
95        }
96
97        let year: u16 = text.get(0..4)?.parse().ok()?;
98        let month: u8 = text.get(5..7)?.parse().ok()?;
99        let day: u8 = text.get(8..10)?.parse().ok()?;
100
101        // Range-checked but not calendar-checked: 2026-02-30 is accepted. Validating month
102        // lengths would need leap-year rules for no benefit — the date is only ever
103        // compared, and an impossible date compares perfectly sensibly.
104        if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
105            return None;
106        }
107
108        Some(Self { year, month, day })
109    }
110}
111
112impl std::fmt::Display for Date {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
115    }
116}
117
118/// A directive that parsed.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Suppression {
121    /// What it covers.
122    pub scope: Scope,
123    /// The rules it silences. Never empty — a directive naming none is malformed.
124    pub rules: Vec<RuleId>,
125    /// Why, as the author wrote it.
126    pub reason: String,
127    /// When it stops applying, if it says.
128    pub expires: Option<Date>,
129    /// One-based line the directive is on.
130    pub line: u32,
131    /// One-based column the directive starts at.
132    pub column: u32,
133}
134
135impl Suppression {
136    /// Whether this directive covers a violation of `rule` at `line`.
137    #[must_use]
138    pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
139        let in_scope = match self.scope {
140            Scope::File => true,
141            // The line after the directive. Not the directive's own line: a trailing
142            // comment on the offending line would be a different form, and supporting both
143            // silently would make it unclear which one a reader is looking at.
144            Scope::NextLine => line == self.line + 1,
145        };
146        in_scope && self.rules.contains(rule)
147    }
148}
149
150/// A directive that did not parse, and why.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Malformed {
153    /// One-based line.
154    pub line: u32,
155    /// One-based column.
156    pub column: u32,
157    /// What is wrong, phrased for the person who wrote it.
158    pub problem: String,
159}
160
161/// Everything a file's directives amount to.
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct Suppressions {
164    /// Directives that parsed.
165    pub valid: Vec<Suppression>,
166    /// Directives that did not.
167    pub malformed: Vec<Malformed>,
168}
169
170impl Suppressions {
171    /// Whether anything here could silence a violation.
172    #[must_use]
173    pub fn is_empty(&self) -> bool {
174        self.valid.is_empty() && self.malformed.is_empty()
175    }
176
177    /// The index of the directive covering a violation, if one does.
178    ///
179    /// The index rather than a boolean, so a caller can record which directives were used
180    /// and report the rest as unused.
181    #[must_use]
182    pub fn covering(&self, rule: &RuleId, line: u32) -> Option<usize> {
183        self.valid
184            .iter()
185            .position(|suppression| suppression.covers(rule, line))
186    }
187}
188
189/// Find every directive in a file.
190///
191/// Never fails: a directive that cannot be understood becomes a [`Malformed`] entry rather
192/// than an error, because one bad comment must not stop a file from being checked.
193#[must_use]
194pub fn parse(source: &str) -> Suppressions {
195    let mut found = Suppressions::default();
196
197    for (index, text) in source.lines().enumerate() {
198        let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
199
200        let Some((scope, at)) = find_directive(text) else {
201            continue;
202        };
203        let column = u32::try_from(at + 1).unwrap_or(u32::MAX);
204
205        let token = match scope {
206            Scope::NextLine => NEXT_LINE,
207            Scope::File => WHOLE_FILE,
208        };
209        let rest = text.get(at + token.len()..).unwrap_or_default();
210
211        match parse_body(scope, rest, line, column) {
212            Ok(suppression) => found.valid.push(suppression),
213            Err(problem) => found.malformed.push(Malformed {
214                line,
215                column,
216                problem,
217            }),
218        }
219    }
220
221    found
222}
223
224/// Locate a directive token in a line, if it stands alone.
225///
226/// "Stands alone" means not adjacent to a word character on either side, which is what stops
227/// prose about a token with `-ish` appended from matching. Neither token is a prefix of the
228/// other, so which one is looked for first does not matter — only which one sits earlier in
229/// the line.
230fn find_directive(text: &str) -> Option<(Scope, usize)> {
231    let next_line = standalone(text, NEXT_LINE).map(|at| (Scope::NextLine, at));
232    let whole_file = standalone(text, WHOLE_FILE).map(|at| (Scope::File, at));
233
234    match (next_line, whole_file) {
235        (Some(a), Some(b)) => Some(if a.1 <= b.1 { a } else { b }),
236        (found, None) | (None, found) => found,
237    }
238}
239
240/// The offset of `token` in `text`, if it appears as a standalone word.
241fn standalone(text: &str, token: &str) -> Option<usize> {
242    let mut from = 0usize;
243    while let Some(offset) = text.get(from..)?.find(token) {
244        let at = from + offset;
245        let before = text[..at].chars().next_back();
246        let after = text[at + token.len()..].chars().next();
247
248        let bounded = !before.is_some_and(is_word)
249            // A trailing `-` would make this the token with `-later` appended, which is not
250            // the directive and must not be treated as one.
251            && !after.is_some_and(|c| is_word(c) || c == '-');
252
253        if bounded {
254            return Some(at);
255        }
256        from = at + token.len();
257    }
258    None
259}
260
261const fn is_word(c: char) -> bool {
262    c.is_ascii_alphanumeric() || c == '_'
263}
264
265/// Parse everything after the directive token.
266fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
267    // `reason:` splits the directive: rule ids before, prose after. Splitting on the keyword
268    // rather than on whitespace is what lets a reason contain anything, including a colon.
269    let Some((ids, tail)) = rest.split_once("reason:") else {
270        return Err(format!(
271            "suppression has no `reason:` — a suppression is a decision to accept a \
272             violation, and the next person to read it cannot tell whether it still holds \
273             without one\n  write: {} <rule-id> reason: why this is acceptable",
274            token_for(scope)
275        ));
276    };
277
278    let rules = parse_rules(ids)?;
279
280    // `expires:` may follow the reason. Taken from the end so the reason keeps any text
281    // before it — a reason is prose and must not be truncated by a word appearing in it.
282    let (reason, expires) = match tail.rsplit_once("expires:") {
283        Some((before, date)) => {
284            let text = date.trim();
285            let Some(parsed) = Date::parse(text) else {
286                return Err(format!(
287                    "suppression has an unreadable `expires: {text}` — expected \
288                     YYYY-MM-DD\n  an expiry that cannot be read would never expire, which \
289                     is the one thing an expiry exists to prevent"
290                ));
291            };
292            (before.trim(), Some(parsed))
293        }
294        None => (tail.trim(), None),
295    };
296
297    if reason.is_empty() {
298        return Err(format!(
299            "suppression has an empty `reason:`\n  write: {} <rule-id> reason: why this is \
300             acceptable",
301            token_for(scope)
302        ));
303    }
304
305    Ok(Suppression {
306        scope,
307        rules,
308        reason: reason.to_owned(),
309        expires,
310        line,
311        column,
312    })
313}
314
315/// Parse the rule ids between the directive and `reason:`.
316fn parse_rules(text: &str) -> Result<Vec<RuleId>, String> {
317    let mut rules = Vec::new();
318
319    for token in text.split([',', ' ', '\t']).filter(|t| !t.is_empty()) {
320        match token.parse::<RuleId>() {
321            Ok(id) => rules.push(id),
322            Err(_) => {
323                return Err(format!(
324                    "`{token}` is not a rule id\n  ids are namespaced — `lanekeep/<name>` \
325                     for built-in rules, `local/<name>` for this project's"
326                ));
327            }
328        }
329    }
330
331    if rules.is_empty() {
332        return Err(String::from(
333            "suppression names no rules\n  a directive that silenced everything would hide \
334             violations nobody chose to accept — name the rules it is for",
335        ));
336    }
337
338    Ok(rules)
339}
340
341const fn token_for(scope: Scope) -> &'static str {
342    match scope {
343        Scope::NextLine => NEXT_LINE,
344        Scope::File => WHOLE_FILE,
345    }
346}
347
348/// Today, from the host clock.
349///
350/// The one place the checker consults the clock for a result it reports, and it is
351/// deliberately here rather than in the sandbox: a rule must not be able to observe the
352/// date, but a suppression's expiry has to be compared against something. Callers fix it
353/// once per run so two files checked a millisecond apart cannot disagree about what day it
354/// is — and any file whose result depends on it says so in its cache key.
355///
356/// `local/no-ambient-observation` names the full list of places this repository's own
357/// source legitimately reads the clock — this one, and the run budget's origin in
358/// `lanekeep-js`, which is a different case: that clock can stop a result from being
359/// produced at all, never change what one contains.
360///
361/// UTC, not local time. A deadline that moved with the reader's time zone would expire
362/// twice in some places and not at all in others.
363#[must_use]
364pub fn today() -> Date {
365    let seconds = std::time::SystemTime::now()
366        .duration_since(std::time::UNIX_EPOCH)
367        .map_or(0, |elapsed| elapsed.as_secs());
368    from_unix_days(i64::try_from(seconds / 86_400).unwrap_or(0))
369}
370
371/// Civil date from a count of days since 1970-01-01.
372///
373/// Howard Hinnant's `civil_from_days`, which is exact for the whole proleptic Gregorian
374/// range and needs no table. Written out rather than pulled in: one function against a
375/// dependency that would carry formatting, parsing and time zones for a date this only ever
376/// compares.
377fn from_unix_days(days: i64) -> Date {
378    let z = days + 719_468;
379    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
380    let day_of_era = z - era * 146_097;
381    let year_of_era =
382        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
383    let year = year_of_era + era * 400;
384    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
385    let shifted_month = (5 * day_of_year + 2) / 153;
386    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
387    let month = if shifted_month < 10 {
388        shifted_month + 3
389    } else {
390        shifted_month - 9
391    };
392
393    Date {
394        year: u16::try_from(if month <= 2 { year + 1 } else { year }).unwrap_or(1970),
395        month: u8::try_from(month).unwrap_or(1),
396        day: u8::try_from(day).unwrap_or(1),
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    // Every fixture here builds its directive from `NEXT_LINE` or `WHOLE_FILE` rather than
405    // writing the token out. The bytes handed to `parse` are unchanged — which is the point,
406    // since these tests are about scanning bytes — while this file stays free of directives.
407    // See the module documentation.
408
409    fn rule(id: &str) -> RuleId {
410        id.parse().expect("valid id")
411    }
412
413    fn only(source: &str) -> Suppression {
414        let found = parse(source);
415        assert!(
416            found.malformed.is_empty(),
417            "unexpectedly malformed: {:?}",
418            found.malformed
419        );
420        assert_eq!(found.valid.len(), 1, "{:?}", found.valid);
421        found.valid.into_iter().next().expect("one")
422    }
423
424    fn problem(source: &str) -> String {
425        let found = parse(source);
426        assert!(
427            found.valid.is_empty(),
428            "unexpectedly valid: {:?}",
429            found.valid
430        );
431        assert_eq!(found.malformed.len(), 1, "{:?}", found.malformed);
432        found.malformed.into_iter().next().expect("one").problem
433    }
434
435    #[test]
436    fn a_next_line_directive_parses() {
437        let found = only(&format!(
438            "// {NEXT_LINE} local/a reason: legacy\nminWidth: 44,\n"
439        ));
440        assert_eq!(found.scope, Scope::NextLine);
441        assert_eq!(found.rules, vec![rule("local/a")]);
442        assert_eq!(found.reason, "legacy");
443        assert_eq!(found.line, 1);
444        assert_eq!(found.expires, None);
445    }
446
447    #[test]
448    fn a_file_directive_parses() {
449        let found = only(&format!(
450            "// {WHOLE_FILE} local/a reason: generated fixture\n"
451        ));
452        assert_eq!(found.scope, Scope::File);
453        assert_eq!(found.reason, "generated fixture");
454    }
455
456    #[test]
457    fn several_rules_may_be_named() {
458        let found = only(&format!(
459            "// {NEXT_LINE} local/a, local/b lanekeep/c reason: x\n"
460        ));
461        assert_eq!(
462            found.rules,
463            vec![rule("local/a"), rule("local/b"), rule("lanekeep/c")]
464        );
465    }
466
467    #[test]
468    fn an_expiry_parses_and_leaves_the_reason_intact() {
469        let found = only(&format!(
470            "// {WHOLE_FILE} local/a reason: waiting on the rewrite expires: 2026-12-31\n"
471        ));
472        assert_eq!(found.reason, "waiting on the rewrite");
473        assert_eq!(
474            found.expires,
475            Some(Date {
476                year: 2026,
477                month: 12,
478                day: 31
479            })
480        );
481    }
482
483    #[test]
484    fn a_reason_may_contain_a_colon() {
485        // Splitting on the `reason:` keyword rather than on whitespace is what allows this.
486        let found = only(&format!(
487            "// {WHOLE_FILE} local/a reason: see ticket ABC-1: the API\n"
488        ));
489        assert_eq!(found.reason, "see ticket ABC-1: the API");
490    }
491
492    #[test]
493    fn a_missing_reason_is_malformed() {
494        // The failure this module exists for: a directive that looks like it works.
495        let text = problem(&format!("// {NEXT_LINE} local/a\n"));
496        assert!(text.contains("no `reason:`"), "{text}");
497    }
498
499    #[test]
500    fn an_empty_reason_is_malformed() {
501        let text = problem(&format!("// {NEXT_LINE} local/a reason:   \n"));
502        assert!(text.contains("empty"), "{text}");
503    }
504
505    #[test]
506    fn naming_no_rules_is_malformed() {
507        // A blanket suppression would hide violations nobody chose to accept.
508        let text = problem(&format!("// {NEXT_LINE} reason: everything\n"));
509        assert!(text.contains("names no rules"), "{text}");
510    }
511
512    #[test]
513    fn a_bare_rule_id_is_malformed() {
514        // Namespacing is a one-way door, and a bare id here would silently silence nothing.
515        let text = problem(&format!("// {NEXT_LINE} no-default-export reason: x\n"));
516        assert!(text.contains("not a rule id"), "{text}");
517        assert!(text.contains("namespaced"), "{text}");
518    }
519
520    #[test]
521    fn an_unreadable_expiry_is_malformed() {
522        // An expiry that cannot be read would never expire, which is the one thing an
523        // expiry exists to prevent.
524        for bad in [
525            "31-12-2026",
526            "2026/12/31",
527            "soon",
528            "2026-13-01",
529            "2026-12-32",
530        ] {
531            let text = problem(&format!(
532                "// {WHOLE_FILE} local/a reason: x expires: {bad}\n"
533            ));
534            assert!(text.contains("unreadable"), "`{bad}` gave: {text}");
535        }
536    }
537
538    #[test]
539    fn prose_mentioning_the_directive_does_not_match() {
540        // §10: the directive must be a standalone token.
541        for prose in [
542            format!("// use {NEXT_LINE}r for this\n"),
543            format!("// see {WHOLE_FILE}-format docs\n"),
544            format!("// x{WHOLE_FILE} local/a reason: x\n"),
545        ] {
546            let found = parse(&prose);
547            assert!(
548                found.is_empty(),
549                "prose matched as a directive: {prose:?} -> {found:?}"
550            );
551        }
552    }
553
554    #[test]
555    fn a_directive_is_found_wherever_it_sits_on_the_line() {
556        let found = only(&format!("const a = 1; // {NEXT_LINE} local/a reason: x\n"));
557        assert_eq!(found.line, 1);
558        assert!(found.column > 1, "column should point at the directive");
559    }
560
561    #[test]
562    fn several_directives_in_one_file_all_parse() {
563        let found = parse(&format!(
564            "// {WHOLE_FILE} local/a reason: one\n\
565             const x = 1;\n\
566             // {NEXT_LINE} local/b reason: two\n\
567             const y = 2;\n"
568        ));
569        assert_eq!(found.valid.len(), 2);
570        assert_eq!(found.valid[0].line, 1);
571        assert_eq!(found.valid[1].line, 3);
572    }
573
574    #[test]
575    fn a_malformed_directive_does_not_stop_the_others() {
576        // One bad comment must not stop a file from being checked.
577        let found = parse(&format!(
578            "// {NEXT_LINE} local/a\n\
579             const x = 1;\n\
580             // {NEXT_LINE} local/b reason: fine\n"
581        ));
582        assert_eq!(found.valid.len(), 1);
583        assert_eq!(found.malformed.len(), 1);
584    }
585
586    // --- what a directive covers ---------------------------------------------------------
587
588    #[test]
589    fn next_line_covers_the_following_line_only() {
590        let found = only(&format!("// {NEXT_LINE} local/a reason: x\nconst y = 1;\n"));
591        assert!(found.covers(&rule("local/a"), 2));
592        assert!(!found.covers(&rule("local/a"), 1), "not its own line");
593        assert!(
594            !found.covers(&rule("local/a"), 3),
595            "not the line after that"
596        );
597    }
598
599    #[test]
600    fn a_directive_covers_only_the_rules_it_names() {
601        let found = only(&format!("// {NEXT_LINE} local/a reason: x\n"));
602        assert!(found.covers(&rule("local/a"), 2));
603        assert!(!found.covers(&rule("local/b"), 2));
604    }
605
606    #[test]
607    fn file_scope_covers_every_line() {
608        let found = only(&format!("// {WHOLE_FILE} local/a reason: x\n"));
609        for line in [1, 2, 500] {
610            assert!(found.covers(&rule("local/a"), line));
611        }
612    }
613
614    #[test]
615    fn covering_reports_which_directive_matched() {
616        // The index, not a boolean, so a caller can tell which directives went unused.
617        let found = parse(&format!(
618            "// {NEXT_LINE} local/a reason: one\n\
619             const x = 1;\n\
620             // {NEXT_LINE} local/b reason: two\n\
621             const y = 2;\n"
622        ));
623        assert_eq!(found.covering(&rule("local/a"), 2), Some(0));
624        assert_eq!(found.covering(&rule("local/b"), 4), Some(1));
625        assert_eq!(found.covering(&rule("local/c"), 2), None);
626    }
627
628    // --- dates ----------------------------------------------------------------------------
629
630    #[test]
631    fn dates_compare_chronologically() {
632        let earlier = Date::parse("2026-01-31").expect("valid");
633        let later = Date::parse("2026-02-01").expect("valid");
634        assert!(earlier < later);
635
636        let next_year = Date::parse("2027-01-01").expect("valid");
637        assert!(later < next_year);
638    }
639
640    #[test]
641    fn known_epochs_convert_correctly() {
642        // Fixed points, including a leap day and a century boundary, so the conversion is
643        // checked against something other than itself.
644        for (days, expected) in [
645            (0, "1970-01-01"),
646            (18_993, "2022-01-01"),
647            (19_051, "2022-02-28"),
648            (11_016, "2000-02-29"),
649            (20_666, "2026-08-01"),
650        ] {
651            assert_eq!(from_unix_days(days).to_string(), expected, "day {days}");
652        }
653    }
654
655    #[test]
656    fn today_is_a_plausible_date() {
657        let now = today();
658        assert!(now.year >= 2024 && now.year < 2200, "{now}");
659        assert!((1..=12).contains(&now.month), "{now}");
660        assert!((1..=31).contains(&now.day), "{now}");
661    }
662
663    #[test]
664    fn a_date_renders_back_to_its_input() {
665        assert_eq!(
666            Date::parse("2026-08-01").expect("valid").to_string(),
667            "2026-08-01"
668        );
669    }
670}