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
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#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Suppression {
121 pub scope: Scope,
123 pub rules: Vec<RuleId>,
125 pub reason: String,
127 pub expires: Option<Date>,
129 pub line: u32,
131 pub column: u32,
133}
134
135impl Suppression {
136 #[must_use]
138 pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
139 let in_scope = match self.scope {
140 Scope::File => true,
141 Scope::NextLine => line == self.line + 1,
145 };
146 in_scope && self.rules.contains(rule)
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Malformed {
153 pub line: u32,
155 pub column: u32,
157 pub problem: String,
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct Suppressions {
164 pub valid: Vec<Suppression>,
166 pub malformed: Vec<Malformed>,
168}
169
170impl Suppressions {
171 #[must_use]
173 pub fn is_empty(&self) -> bool {
174 self.valid.is_empty() && self.malformed.is_empty()
175 }
176
177 #[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#[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
224fn 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
240fn 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 && !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
265fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
267 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 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
315fn 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#[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
371fn 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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}