use crate::rule_id::RuleId;
const NEXT_LINE: &str = "lanekeep-ignore-next-line";
const WHOLE_FILE: &str = "lanekeep-ignore-file";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
NextLine,
File,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Date {
pub year: u16,
pub month: u8,
pub day: u8,
}
impl Date {
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let bytes = text.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
let year: u16 = text.get(0..4)?.parse().ok()?;
let month: u8 = text.get(5..7)?.parse().ok()?;
let day: u8 = text.get(8..10)?.parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
Some(Self { year, month, day })
}
}
impl std::fmt::Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suppression {
pub scope: Scope,
pub rules: Vec<RuleId>,
pub reason: String,
pub expires: Option<Date>,
pub line: u32,
pub column: u32,
}
impl Suppression {
#[must_use]
pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
let in_scope = match self.scope {
Scope::File => true,
Scope::NextLine => line == self.line + 1,
};
in_scope && self.rules.contains(rule)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed {
pub line: u32,
pub column: u32,
pub problem: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Suppressions {
pub valid: Vec<Suppression>,
pub malformed: Vec<Malformed>,
}
impl Suppressions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.valid.is_empty() && self.malformed.is_empty()
}
#[must_use]
pub fn covering(&self, rule: &RuleId, line: u32) -> Option<usize> {
self.valid
.iter()
.position(|suppression| suppression.covers(rule, line))
}
}
#[must_use]
pub fn parse(source: &str) -> Suppressions {
let mut found = Suppressions::default();
for (index, text) in source.lines().enumerate() {
let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
let Some((scope, at)) = find_directive(text) else {
continue;
};
let column = u32::try_from(at + 1).unwrap_or(u32::MAX);
let token = match scope {
Scope::NextLine => NEXT_LINE,
Scope::File => WHOLE_FILE,
};
let rest = text.get(at + token.len()..).unwrap_or_default();
match parse_body(scope, rest, line, column) {
Ok(suppression) => found.valid.push(suppression),
Err(problem) => found.malformed.push(Malformed {
line,
column,
problem,
}),
}
}
found
}
fn find_directive(text: &str) -> Option<(Scope, usize)> {
let next_line = standalone(text, NEXT_LINE).map(|at| (Scope::NextLine, at));
let whole_file = standalone(text, WHOLE_FILE).map(|at| (Scope::File, at));
match (next_line, whole_file) {
(Some(a), Some(b)) => Some(if a.1 <= b.1 { a } else { b }),
(found, None) | (None, found) => found,
}
}
fn standalone(text: &str, token: &str) -> Option<usize> {
let mut from = 0usize;
while let Some(offset) = text.get(from..)?.find(token) {
let at = from + offset;
let before = text[..at].chars().next_back();
let after = text[at + token.len()..].chars().next();
let bounded = !before.is_some_and(is_word)
&& !after.is_some_and(|c| is_word(c) || c == '-');
if bounded {
return Some(at);
}
from = at + token.len();
}
None
}
const fn is_word(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
let Some((ids, tail)) = rest.split_once("reason:") else {
return Err(format!(
"suppression has no `reason:` — a suppression is a decision to accept a \
violation, and the next person to read it cannot tell whether it still holds \
without one\n write: {} <rule-id> reason: why this is acceptable",
token_for(scope)
));
};
let rules = parse_rules(ids)?;
let (reason, expires) = match tail.rsplit_once("expires:") {
Some((before, date)) => {
let text = date.trim();
let Some(parsed) = Date::parse(text) else {
return Err(format!(
"suppression has an unreadable `expires: {text}` — expected \
YYYY-MM-DD\n an expiry that cannot be read would never expire, which \
is the one thing an expiry exists to prevent"
));
};
(before.trim(), Some(parsed))
}
None => (tail.trim(), None),
};
if reason.is_empty() {
return Err(format!(
"suppression has an empty `reason:`\n write: {} <rule-id> reason: why this is \
acceptable",
token_for(scope)
));
}
Ok(Suppression {
scope,
rules,
reason: reason.to_owned(),
expires,
line,
column,
})
}
fn parse_rules(text: &str) -> Result<Vec<RuleId>, String> {
let mut rules = Vec::new();
for token in text.split([',', ' ', '\t']).filter(|t| !t.is_empty()) {
match token.parse::<RuleId>() {
Ok(id) => rules.push(id),
Err(_) => {
return Err(format!(
"`{token}` is not a rule id\n ids are namespaced — `lanekeep/<name>` \
for built-in rules, `local/<name>` for this project's"
));
}
}
}
if rules.is_empty() {
return Err(String::from(
"suppression names no rules\n a directive that silenced everything would hide \
violations nobody chose to accept — name the rules it is for",
));
}
Ok(rules)
}
const fn token_for(scope: Scope) -> &'static str {
match scope {
Scope::NextLine => NEXT_LINE,
Scope::File => WHOLE_FILE,
}
}
#[must_use]
pub fn today() -> Date {
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs());
from_unix_days(i64::try_from(seconds / 86_400).unwrap_or(0))
}
fn from_unix_days(days: i64) -> Date {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let shifted_month = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
let month = if shifted_month < 10 {
shifted_month + 3
} else {
shifted_month - 9
};
Date {
year: u16::try_from(if month <= 2 { year + 1 } else { year }).unwrap_or(1970),
month: u8::try_from(month).unwrap_or(1),
day: u8::try_from(day).unwrap_or(1),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(id: &str) -> RuleId {
id.parse().expect("valid id")
}
fn only(source: &str) -> Suppression {
let found = parse(source);
assert!(
found.malformed.is_empty(),
"unexpectedly malformed: {:?}",
found.malformed
);
assert_eq!(found.valid.len(), 1, "{:?}", found.valid);
found.valid.into_iter().next().expect("one")
}
fn problem(source: &str) -> String {
let found = parse(source);
assert!(
found.valid.is_empty(),
"unexpectedly valid: {:?}",
found.valid
);
assert_eq!(found.malformed.len(), 1, "{:?}", found.malformed);
found.malformed.into_iter().next().expect("one").problem
}
#[test]
fn a_next_line_directive_parses() {
let found = only("// lanekeep-ignore-next-line local/a reason: legacy\nminWidth: 44,\n");
assert_eq!(found.scope, Scope::NextLine);
assert_eq!(found.rules, vec![rule("local/a")]);
assert_eq!(found.reason, "legacy");
assert_eq!(found.line, 1);
assert_eq!(found.expires, None);
}
#[test]
fn a_file_directive_parses() {
let found = only("// lanekeep-ignore-file local/a reason: generated fixture\n");
assert_eq!(found.scope, Scope::File);
assert_eq!(found.reason, "generated fixture");
}
#[test]
fn several_rules_may_be_named() {
let found = only("// lanekeep-ignore-next-line local/a, local/b lanekeep/c reason: x\n");
assert_eq!(
found.rules,
vec![rule("local/a"), rule("local/b"), rule("lanekeep/c")]
);
}
#[test]
fn an_expiry_parses_and_leaves_the_reason_intact() {
let found = only(
"// lanekeep-ignore-file local/a reason: waiting on the rewrite expires: 2026-12-31\n",
);
assert_eq!(found.reason, "waiting on the rewrite");
assert_eq!(
found.expires,
Some(Date {
year: 2026,
month: 12,
day: 31
})
);
}
#[test]
fn a_reason_may_contain_a_colon() {
let found = only("// lanekeep-ignore-file local/a reason: see ticket ABC-1: the API\n");
assert_eq!(found.reason, "see ticket ABC-1: the API");
}
#[test]
fn a_missing_reason_is_malformed() {
let text = problem("// lanekeep-ignore-next-line local/a\n");
assert!(text.contains("no `reason:`"), "{text}");
}
#[test]
fn an_empty_reason_is_malformed() {
let text = problem("// lanekeep-ignore-next-line local/a reason: \n");
assert!(text.contains("empty"), "{text}");
}
#[test]
fn naming_no_rules_is_malformed() {
let text = problem("// lanekeep-ignore-next-line reason: everything\n");
assert!(text.contains("names no rules"), "{text}");
}
#[test]
fn a_bare_rule_id_is_malformed() {
let text = problem("// lanekeep-ignore-next-line no-default-export reason: x\n");
assert!(text.contains("not a rule id"), "{text}");
assert!(text.contains("namespaced"), "{text}");
}
#[test]
fn an_unreadable_expiry_is_malformed() {
for bad in [
"31-12-2026",
"2026/12/31",
"soon",
"2026-13-01",
"2026-12-32",
] {
let text = problem(&format!(
"// lanekeep-ignore-file local/a reason: x expires: {bad}\n"
));
assert!(text.contains("unreadable"), "`{bad}` gave: {text}");
}
}
#[test]
fn prose_mentioning_the_directive_does_not_match() {
for prose in [
"// use lanekeep-ignore-next-liner for this\n",
"// see lanekeep-ignore-file-format docs\n",
"// xlanekeep-ignore-file local/a reason: x\n",
] {
let found = parse(prose);
assert!(
found.is_empty(),
"prose matched as a directive: {prose:?} -> {found:?}"
);
}
}
#[test]
fn a_directive_is_found_wherever_it_sits_on_the_line() {
let found = only("const a = 1; // lanekeep-ignore-next-line local/a reason: x\n");
assert_eq!(found.line, 1);
assert!(found.column > 1, "column should point at the directive");
}
#[test]
fn several_directives_in_one_file_all_parse() {
let found = parse(
"// lanekeep-ignore-file local/a reason: one\n\
const x = 1;\n\
// lanekeep-ignore-next-line local/b reason: two\n\
const y = 2;\n",
);
assert_eq!(found.valid.len(), 2);
assert_eq!(found.valid[0].line, 1);
assert_eq!(found.valid[1].line, 3);
}
#[test]
fn a_malformed_directive_does_not_stop_the_others() {
let found = parse(
"// lanekeep-ignore-next-line local/a\n\
const x = 1;\n\
// lanekeep-ignore-next-line local/b reason: fine\n",
);
assert_eq!(found.valid.len(), 1);
assert_eq!(found.malformed.len(), 1);
}
#[test]
fn next_line_covers_the_following_line_only() {
let found = only("// lanekeep-ignore-next-line local/a reason: x\nconst y = 1;\n");
assert!(found.covers(&rule("local/a"), 2));
assert!(!found.covers(&rule("local/a"), 1), "not its own line");
assert!(
!found.covers(&rule("local/a"), 3),
"not the line after that"
);
}
#[test]
fn a_directive_covers_only_the_rules_it_names() {
let found = only("// lanekeep-ignore-next-line local/a reason: x\n");
assert!(found.covers(&rule("local/a"), 2));
assert!(!found.covers(&rule("local/b"), 2));
}
#[test]
fn file_scope_covers_every_line() {
let found = only("// lanekeep-ignore-file local/a reason: x\n");
for line in [1, 2, 500] {
assert!(found.covers(&rule("local/a"), line));
}
}
#[test]
fn covering_reports_which_directive_matched() {
let found = parse(
"// lanekeep-ignore-next-line local/a reason: one\n\
const x = 1;\n\
// lanekeep-ignore-next-line local/b reason: two\n\
const y = 2;\n",
);
assert_eq!(found.covering(&rule("local/a"), 2), Some(0));
assert_eq!(found.covering(&rule("local/b"), 4), Some(1));
assert_eq!(found.covering(&rule("local/c"), 2), None);
}
#[test]
fn dates_compare_chronologically() {
let earlier = Date::parse("2026-01-31").expect("valid");
let later = Date::parse("2026-02-01").expect("valid");
assert!(earlier < later);
let next_year = Date::parse("2027-01-01").expect("valid");
assert!(later < next_year);
}
#[test]
fn known_epochs_convert_correctly() {
for (days, expected) in [
(0, "1970-01-01"),
(18_993, "2022-01-01"),
(19_051, "2022-02-28"),
(11_016, "2000-02-29"),
(20_666, "2026-08-01"),
] {
assert_eq!(from_unix_days(days).to_string(), expected, "day {days}");
}
}
#[test]
fn today_is_a_plausible_date() {
let now = today();
assert!(now.year >= 2024 && now.year < 2200, "{now}");
assert!((1..=12).contains(&now.month), "{now}");
assert!((1..=31).contains(&now.day), "{now}");
}
#[test]
fn a_date_renders_back_to_its_input() {
assert_eq!(
Date::parse("2026-08-01").expect("valid").to_string(),
"2026-08-01"
);
}
}