1use crate::rule_id::RuleId;
37
38const NEXT_LINE: &str = "lanekeep-ignore-next-line";
40
41const WHOLE_FILE: &str = "lanekeep-ignore-file";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Scope {
47 NextLine,
49 File,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub struct Date {
59 pub year: u16,
61 pub month: u8,
63 pub day: u8,
65}
66
67impl Date {
68 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Suppression {
104 pub scope: Scope,
106 pub rules: Vec<RuleId>,
108 pub reason: String,
110 pub expires: Option<Date>,
112 pub line: u32,
114 pub column: u32,
116}
117
118impl Suppression {
119 #[must_use]
121 pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
122 let in_scope = match self.scope {
123 Scope::File => true,
124 Scope::NextLine => line == self.line + 1,
128 };
129 in_scope && self.rules.contains(rule)
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Malformed {
136 pub line: u32,
138 pub column: u32,
140 pub problem: String,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct Suppressions {
147 pub valid: Vec<Suppression>,
149 pub malformed: Vec<Malformed>,
151}
152
153impl Suppressions {
154 #[must_use]
156 pub fn is_empty(&self) -> bool {
157 self.valid.is_empty() && self.malformed.is_empty()
158 }
159
160 #[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#[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
207fn 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
223fn 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 && !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
248fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
250 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 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
298fn 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#[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
349fn 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 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 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 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 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 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 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 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 #[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 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 #[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 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}