1use crate::rule_id::RuleId;
50
51const NEXT_LINE: &str = concat!("lanekeep", "-ignore-next-line");
56
57const WHOLE_FILE: &str = concat!("lanekeep", "-ignore-file");
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Scope {
64 NextLine,
66 File,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75pub struct Date {
76 pub year: u16,
78 pub month: u8,
80 pub day: u8,
82}
83
84impl Date {
85 #[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 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
105 return None;
106 }
107
108 Some(Self { year, month, day })
109 }
110
111 #[must_use]
117 pub fn add_days(self, days: u32) -> Self {
118 from_unix_days(days_from_civil(self) + i64::from(days))
119 }
120}
121
122impl std::fmt::Display for Date {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Suppression {
131 pub scope: Scope,
133 pub rules: Vec<RuleId>,
135 pub reason: String,
137 pub expires: Option<Date>,
139 pub line: u32,
141 pub column: u32,
143}
144
145impl Suppression {
146 #[must_use]
148 pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
149 let in_scope = match self.scope {
150 Scope::File => true,
151 Scope::NextLine => line == self.line + 1,
155 };
156 in_scope && self.rules.contains(rule)
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct Malformed {
163 pub line: u32,
165 pub column: u32,
167 pub problem: String,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
173pub struct Suppressions {
174 pub valid: Vec<Suppression>,
176 pub malformed: Vec<Malformed>,
178}
179
180impl Suppressions {
181 #[must_use]
183 pub fn is_empty(&self) -> bool {
184 self.valid.is_empty() && self.malformed.is_empty()
185 }
186
187 #[must_use]
192 pub fn covering(&self, rule: &RuleId, line: u32) -> Option<usize> {
193 self.valid
194 .iter()
195 .position(|suppression| suppression.covers(rule, line))
196 }
197}
198
199#[must_use]
204pub fn parse(source: &str) -> Suppressions {
205 let mut found = Suppressions::default();
206
207 for (index, text) in source.lines().enumerate() {
208 let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
209
210 let Some((scope, at)) = find_directive(text) else {
211 continue;
212 };
213 let column = u32::try_from(at + 1).unwrap_or(u32::MAX);
214
215 let token = match scope {
216 Scope::NextLine => NEXT_LINE,
217 Scope::File => WHOLE_FILE,
218 };
219 let rest = text.get(at + token.len()..).unwrap_or_default();
220
221 match parse_body(scope, rest, line, column) {
222 Ok(suppression) => found.valid.push(suppression),
223 Err(problem) => found.malformed.push(Malformed {
224 line,
225 column,
226 problem,
227 }),
228 }
229 }
230
231 found
232}
233
234fn find_directive(text: &str) -> Option<(Scope, usize)> {
241 let next_line = standalone(text, NEXT_LINE).map(|at| (Scope::NextLine, at));
242 let whole_file = standalone(text, WHOLE_FILE).map(|at| (Scope::File, at));
243
244 match (next_line, whole_file) {
245 (Some(a), Some(b)) => Some(if a.1 <= b.1 { a } else { b }),
246 (found, None) | (None, found) => found,
247 }
248}
249
250fn standalone(text: &str, token: &str) -> Option<usize> {
252 let mut from = 0usize;
253 while let Some(offset) = text.get(from..)?.find(token) {
254 let at = from + offset;
255 let before = text[..at].chars().next_back();
256 let after = text[at + token.len()..].chars().next();
257
258 let bounded = !before.is_some_and(is_word)
259 && !after.is_some_and(|c| is_word(c) || c == '-');
262
263 if bounded {
264 return Some(at);
265 }
266 from = at + token.len();
267 }
268 None
269}
270
271const fn is_word(c: char) -> bool {
272 c.is_ascii_alphanumeric() || c == '_'
273}
274
275fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
277 let Some((ids, tail)) = rest.split_once("reason:") else {
280 return Err(format!(
281 "suppression has no `reason:` — a suppression is a decision to accept a \
282 violation, and the next person to read it cannot tell whether it still holds \
283 without one\n write: {} <rule-id> reason: why this is acceptable",
284 token_for(scope)
285 ));
286 };
287
288 let rules = parse_rules(ids)?;
289
290 let (reason, expires) = match tail.rsplit_once("expires:") {
293 Some((before, date)) => {
294 let text = date.trim();
295 let Some(parsed) = Date::parse(text) else {
296 return Err(format!(
297 "suppression has an unreadable `expires: {text}` — expected \
298 YYYY-MM-DD\n an expiry that cannot be read would never expire, which \
299 is the one thing an expiry exists to prevent"
300 ));
301 };
302 (before.trim(), Some(parsed))
303 }
304 None => (tail.trim(), None),
305 };
306
307 if reason.is_empty() {
308 return Err(format!(
309 "suppression has an empty `reason:`\n write: {} <rule-id> reason: why this is \
310 acceptable",
311 token_for(scope)
312 ));
313 }
314
315 Ok(Suppression {
316 scope,
317 rules,
318 reason: reason.to_owned(),
319 expires,
320 line,
321 column,
322 })
323}
324
325fn parse_rules(text: &str) -> Result<Vec<RuleId>, String> {
327 let mut rules = Vec::new();
328
329 for token in text.split([',', ' ', '\t']).filter(|t| !t.is_empty()) {
330 match token.parse::<RuleId>() {
331 Ok(id) => rules.push(id),
332 Err(_) => {
333 return Err(format!(
334 "`{token}` is not a rule id\n ids are namespaced — `lanekeep/<name>` \
335 for built-in rules, `local/<name>` for this project's"
336 ));
337 }
338 }
339 }
340
341 if rules.is_empty() {
342 return Err(String::from(
343 "suppression names no rules\n a directive that silenced everything would hide \
344 violations nobody chose to accept — name the rules it is for",
345 ));
346 }
347
348 Ok(rules)
349}
350
351const fn token_for(scope: Scope) -> &'static str {
352 match scope {
353 Scope::NextLine => NEXT_LINE,
354 Scope::File => WHOLE_FILE,
355 }
356}
357
358#[must_use]
374pub fn today() -> Date {
375 let seconds = std::time::SystemTime::now()
376 .duration_since(std::time::UNIX_EPOCH)
377 .map_or(0, |elapsed| elapsed.as_secs());
378 from_unix_days(i64::try_from(seconds / 86_400).unwrap_or(0))
379}
380
381fn days_from_civil(date: Date) -> i64 {
385 let y = i64::from(date.year) - i64::from(date.month <= 2);
386 let era = (if y >= 0 { y } else { y - 399 }) / 400;
387 let yoe = y - era * 400;
388 let m = i64::from(date.month) + if date.month > 2 { -3 } else { 9 };
389 let doy = (153 * m + 2) / 5 + i64::from(date.day) - 1;
390 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
391 era * 146_097 + doe - 719_468
392}
393
394fn from_unix_days(days: i64) -> Date {
401 let z = days + 719_468;
402 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
403 let day_of_era = z - era * 146_097;
404 let year_of_era =
405 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
406 let year = year_of_era + era * 400;
407 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
408 let shifted_month = (5 * day_of_year + 2) / 153;
409 let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
410 let month = if shifted_month < 10 {
411 shifted_month + 3
412 } else {
413 shifted_month - 9
414 };
415
416 Date {
417 year: u16::try_from(if month <= 2 { year + 1 } else { year }).unwrap_or(1970),
418 month: u8::try_from(month).unwrap_or(1),
419 day: u8::try_from(day).unwrap_or(1),
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn rule(id: &str) -> RuleId {
433 id.parse().expect("valid id")
434 }
435
436 fn only(source: &str) -> Suppression {
437 let found = parse(source);
438 assert!(
439 found.malformed.is_empty(),
440 "unexpectedly malformed: {:?}",
441 found.malformed
442 );
443 assert_eq!(found.valid.len(), 1, "{:?}", found.valid);
444 found.valid.into_iter().next().expect("one")
445 }
446
447 fn problem(source: &str) -> String {
448 let found = parse(source);
449 assert!(
450 found.valid.is_empty(),
451 "unexpectedly valid: {:?}",
452 found.valid
453 );
454 assert_eq!(found.malformed.len(), 1, "{:?}", found.malformed);
455 found.malformed.into_iter().next().expect("one").problem
456 }
457
458 #[test]
459 fn a_next_line_directive_parses() {
460 let found = only(&format!(
461 "// {NEXT_LINE} local/a reason: legacy\nminWidth: 44,\n"
462 ));
463 assert_eq!(found.scope, Scope::NextLine);
464 assert_eq!(found.rules, vec![rule("local/a")]);
465 assert_eq!(found.reason, "legacy");
466 assert_eq!(found.line, 1);
467 assert_eq!(found.expires, None);
468 }
469
470 #[test]
471 fn a_file_directive_parses() {
472 let found = only(&format!(
473 "// {WHOLE_FILE} local/a reason: generated fixture\n"
474 ));
475 assert_eq!(found.scope, Scope::File);
476 assert_eq!(found.reason, "generated fixture");
477 }
478
479 #[test]
480 fn several_rules_may_be_named() {
481 let found = only(&format!(
482 "// {NEXT_LINE} local/a, local/b lanekeep/c reason: x\n"
483 ));
484 assert_eq!(
485 found.rules,
486 vec![rule("local/a"), rule("local/b"), rule("lanekeep/c")]
487 );
488 }
489
490 #[test]
491 fn an_expiry_parses_and_leaves_the_reason_intact() {
492 let found = only(&format!(
493 "// {WHOLE_FILE} local/a reason: waiting on the rewrite expires: 2026-12-31\n"
494 ));
495 assert_eq!(found.reason, "waiting on the rewrite");
496 assert_eq!(
497 found.expires,
498 Some(Date {
499 year: 2026,
500 month: 12,
501 day: 31
502 })
503 );
504 }
505
506 #[test]
507 fn a_reason_may_contain_a_colon() {
508 let found = only(&format!(
510 "// {WHOLE_FILE} local/a reason: see ticket ABC-1: the API\n"
511 ));
512 assert_eq!(found.reason, "see ticket ABC-1: the API");
513 }
514
515 #[test]
516 fn a_missing_reason_is_malformed() {
517 let text = problem(&format!("// {NEXT_LINE} local/a\n"));
519 assert!(text.contains("no `reason:`"), "{text}");
520 }
521
522 #[test]
523 fn an_empty_reason_is_malformed() {
524 let text = problem(&format!("// {NEXT_LINE} local/a reason: \n"));
525 assert!(text.contains("empty"), "{text}");
526 }
527
528 #[test]
529 fn naming_no_rules_is_malformed() {
530 let text = problem(&format!("// {NEXT_LINE} reason: everything\n"));
532 assert!(text.contains("names no rules"), "{text}");
533 }
534
535 #[test]
536 fn a_bare_rule_id_is_malformed() {
537 let text = problem(&format!("// {NEXT_LINE} no-default-export reason: x\n"));
539 assert!(text.contains("not a rule id"), "{text}");
540 assert!(text.contains("namespaced"), "{text}");
541 }
542
543 #[test]
544 fn an_unreadable_expiry_is_malformed() {
545 for bad in [
548 "31-12-2026",
549 "2026/12/31",
550 "soon",
551 "2026-13-01",
552 "2026-12-32",
553 ] {
554 let text = problem(&format!(
555 "// {WHOLE_FILE} local/a reason: x expires: {bad}\n"
556 ));
557 assert!(text.contains("unreadable"), "`{bad}` gave: {text}");
558 }
559 }
560
561 #[test]
562 fn prose_mentioning_the_directive_does_not_match() {
563 for prose in [
565 format!("// use {NEXT_LINE}r for this\n"),
566 format!("// see {WHOLE_FILE}-format docs\n"),
567 format!("// x{WHOLE_FILE} local/a reason: x\n"),
568 ] {
569 let found = parse(&prose);
570 assert!(
571 found.is_empty(),
572 "prose matched as a directive: {prose:?} -> {found:?}"
573 );
574 }
575 }
576
577 #[test]
578 fn a_directive_is_found_wherever_it_sits_on_the_line() {
579 let found = only(&format!("const a = 1; // {NEXT_LINE} local/a reason: x\n"));
580 assert_eq!(found.line, 1);
581 assert!(found.column > 1, "column should point at the directive");
582 }
583
584 #[test]
585 fn several_directives_in_one_file_all_parse() {
586 let found = parse(&format!(
587 "// {WHOLE_FILE} local/a reason: one\n\
588 const x = 1;\n\
589 // {NEXT_LINE} local/b reason: two\n\
590 const y = 2;\n"
591 ));
592 assert_eq!(found.valid.len(), 2);
593 assert_eq!(found.valid[0].line, 1);
594 assert_eq!(found.valid[1].line, 3);
595 }
596
597 #[test]
598 fn a_malformed_directive_does_not_stop_the_others() {
599 let found = parse(&format!(
601 "// {NEXT_LINE} local/a\n\
602 const x = 1;\n\
603 // {NEXT_LINE} local/b reason: fine\n"
604 ));
605 assert_eq!(found.valid.len(), 1);
606 assert_eq!(found.malformed.len(), 1);
607 }
608
609 #[test]
612 fn next_line_covers_the_following_line_only() {
613 let found = only(&format!("// {NEXT_LINE} local/a reason: x\nconst y = 1;\n"));
614 assert!(found.covers(&rule("local/a"), 2));
615 assert!(!found.covers(&rule("local/a"), 1), "not its own line");
616 assert!(
617 !found.covers(&rule("local/a"), 3),
618 "not the line after that"
619 );
620 }
621
622 #[test]
623 fn a_directive_covers_only_the_rules_it_names() {
624 let found = only(&format!("// {NEXT_LINE} local/a reason: x\n"));
625 assert!(found.covers(&rule("local/a"), 2));
626 assert!(!found.covers(&rule("local/b"), 2));
627 }
628
629 #[test]
630 fn file_scope_covers_every_line() {
631 let found = only(&format!("// {WHOLE_FILE} local/a reason: x\n"));
632 for line in [1, 2, 500] {
633 assert!(found.covers(&rule("local/a"), line));
634 }
635 }
636
637 #[test]
638 fn covering_reports_which_directive_matched() {
639 let found = parse(&format!(
641 "// {NEXT_LINE} local/a reason: one\n\
642 const x = 1;\n\
643 // {NEXT_LINE} local/b reason: two\n\
644 const y = 2;\n"
645 ));
646 assert_eq!(found.covering(&rule("local/a"), 2), Some(0));
647 assert_eq!(found.covering(&rule("local/b"), 4), Some(1));
648 assert_eq!(found.covering(&rule("local/c"), 2), None);
649 }
650
651 #[test]
654 fn dates_compare_chronologically() {
655 let earlier = Date::parse("2026-01-31").expect("valid");
656 let later = Date::parse("2026-02-01").expect("valid");
657 assert!(earlier < later);
658
659 let next_year = Date::parse("2027-01-01").expect("valid");
660 assert!(later < next_year);
661 }
662
663 #[test]
664 fn known_epochs_convert_correctly() {
665 for (days, expected) in [
668 (0, "1970-01-01"),
669 (18_993, "2022-01-01"),
670 (19_051, "2022-02-28"),
671 (11_016, "2000-02-29"),
672 (20_666, "2026-08-01"),
673 ] {
674 assert_eq!(from_unix_days(days).to_string(), expected, "day {days}");
675 }
676 }
677
678 #[test]
679 fn today_is_a_plausible_date() {
680 let now = today();
681 assert!(now.year >= 2024 && now.year < 2200, "{now}");
682 assert!((1..=12).contains(&now.month), "{now}");
683 assert!((1..=31).contains(&now.day), "{now}");
684 }
685
686 #[test]
687 fn a_date_renders_back_to_its_input() {
688 assert_eq!(
689 Date::parse("2026-08-01").expect("valid").to_string(),
690 "2026-08-01"
691 );
692 }
693
694 #[test]
695 fn add_days_moves_across_months_and_years() {
696 let start = Date::parse("2026-08-01").expect("valid");
697 assert_eq!(
698 start.add_days(90),
699 Date::parse("2026-10-30").expect("valid")
700 );
701 assert_eq!(start.add_days(0), start);
702
703 let new_year = Date::parse("2026-12-31").expect("valid");
704 assert_eq!(
705 new_year.add_days(1),
706 Date::parse("2027-01-01").expect("valid")
707 );
708 }
709
710 #[test]
711 fn add_days_handles_leap_years() {
712 let leap = Date::parse("2024-02-28").expect("valid");
713 assert_eq!(leap.add_days(1), Date::parse("2024-02-29").expect("valid"));
714 let common = Date::parse("2023-02-28").expect("valid");
715 assert_eq!(
716 common.add_days(1),
717 Date::parse("2023-03-01").expect("valid")
718 );
719 }
720}