Skip to main content

lanekeep_core/
suppression.rs

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