use crate::context::Date;
use crate::finding::Finding;
use headwater_census::census::{Census, Outcome as Classification};
use headwater_census::resolve::Step;
use headwater_doc::body::{Block, BlockKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reason {
FalsePositive,
AcceptedDeviation,
}
impl Reason {
pub fn name(self) -> &'static str {
match self {
Reason::FalsePositive => "false_positive",
Reason::AcceptedDeviation => "accepted_deviation",
}
}
fn read(text: &str) -> Option<Self> {
match text {
"false_positive" => Some(Reason::FalsePositive),
"accepted_deviation" => Some(Reason::AcceptedDeviation),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Extent {
File,
Block { from: usize, to: usize },
}
impl Extent {
fn covers(self, line: usize) -> bool {
match self {
Extent::File => true,
Extent::Block { from, to } => line >= from && line <= to && line > 0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Applied,
Unused,
Expired,
}
#[derive(Clone, Debug)]
pub struct Suppression {
pub path: String,
pub shelf: Option<String>,
pub rule: String,
pub extent: Extent,
pub line: usize,
pub until: Date,
pub reason: Reason,
pub note: String,
pub hid: Vec<Finding>,
pub state: State,
}
#[derive(Clone, Debug)]
pub struct Refused {
pub path: String,
pub line: usize,
pub why: String,
}
#[derive(Clone, Debug, Default)]
pub struct Inventory {
pub suppressions: Vec<Suppression>,
pub refused: Vec<Refused>,
}
const MARKER: &str = "headwater";
pub fn declared(census: &Census, rules: &[&'static str]) -> (Vec<Suppression>, Vec<Refused>) {
let mut found = Vec::new();
let mut refused = Vec::new();
for row in &census.rows {
let Some(document) = &row.document else {
continue;
};
let shelf = match &row.outcome {
Classification::Typed { derivation, .. } => {
derivation.steps.iter().find_map(|step| match step {
Step::ShelfMatched { shelf, .. } => Some(shelf.clone()),
_ => None,
})
}
_ => None,
};
scan(document, &row.path, shelf, rules, &mut found, &mut refused);
}
for row in &census.outside.rows {
let Some(document) = &row.document else {
continue;
};
scan(document, &row.path, None, rules, &mut found, &mut refused);
}
(found, refused)
}
fn scan(
document: &headwater_doc::Document,
path: &str,
shelf: Option<String>,
rules: &[&'static str],
found: &mut Vec<Suppression>,
refused: &mut Vec<Refused>,
) {
for (at, block) in document.body.blocks.iter().enumerate() {
for text in comments(block) {
let Some(directive) = directive(text) else {
continue;
};
let line = block.span.start.line;
match read(
&directive,
path,
shelf.clone(),
line,
extent(document.body.blocks.as_slice(), at, block),
rules,
) {
Ok(suppression) => found.push(suppression),
Err(why) => refused.push(Refused {
path: path.to_string(),
line,
why,
}),
}
}
}
}
pub fn apply(
findings: Vec<Finding>,
mut suppressions: Vec<Suppression>,
refused: Vec<Refused>,
now: Date,
) -> (Vec<Finding>, Inventory) {
for suppression in &mut suppressions {
if suppression.until < now {
suppression.state = State::Expired;
}
}
let mut kept = Vec::with_capacity(findings.len());
for finding in findings {
let hit = suppressions.iter_mut().find(|suppression| {
suppression.state != State::Expired
&& suppression.rule == finding.rule
&& suppression.path == finding.path
&& suppression.extent.covers(finding.line)
});
match hit {
Some(suppression) => {
suppression.hid.push(finding);
suppression.state = State::Applied;
}
None => kept.push(finding),
}
}
(
kept,
Inventory {
suppressions,
refused,
},
)
}
impl Inventory {
fn of(&self, state: State) -> impl Iterator<Item = &Suppression> {
self.suppressions
.iter()
.filter(move |suppression| suppression.state == state)
}
pub fn hidden(&self) -> usize {
self.suppressions
.iter()
.map(|suppression| suppression.hid.len())
.sum()
}
pub fn hidden_findings(&self) -> Vec<(&Finding, &Suppression)> {
self.suppressions
.iter()
.flat_map(|suppression| {
suppression
.hid
.iter()
.map(move |finding| (finding, suppression))
})
.collect()
}
fn grouped<'a>(
&'a self,
by: impl Fn(&'a Suppression) -> Option<&'a str>,
) -> Vec<(&'a str, usize)> {
let mut groups: Vec<(&str, usize)> = Vec::new();
for suppression in self.of(State::Applied) {
let Some(key) = by(suppression) else {
continue;
};
match groups.iter_mut().find(|(known, _)| *known == key) {
Some((_, count)) => *count += suppression.hid.len(),
None => groups.push((key, suppression.hid.len())),
}
}
groups.sort_by(|(left, one), (right, two)| two.cmp(one).then(left.cmp(right)));
groups
}
pub fn by_rule(&self) -> Vec<(&str, usize)> {
self.grouped(|suppression| Some(suppression.rule.as_str()))
}
pub fn by_shelf(&self) -> Vec<(&str, usize)> {
self.grouped(|suppression| suppression.shelf.as_deref())
}
pub fn by_reason(&self) -> Vec<(Reason, usize)> {
[Reason::FalsePositive, Reason::AcceptedDeviation]
.into_iter()
.map(|reason| {
(
reason,
self.of(State::Applied)
.filter(|suppression| suppression.reason == reason)
.map(|suppression| suppression.hid.len())
.sum(),
)
})
.filter(|(_, count)| *count > 0)
.collect()
}
pub fn is_empty(&self) -> bool {
self.suppressions.is_empty() && self.refused.is_empty()
}
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
out.push_str("suppressions\n");
let _ = writeln!(
out,
" {} findings hidden by {} directives",
self.hidden(),
self.of(State::Applied).count()
);
for (reason, count) in self.by_reason() {
let _ = writeln!(out, " {count:5} {}", reason.name());
}
for (rule, count) in self.by_rule() {
let _ = writeln!(out, " {count:5} of {rule}");
}
for (shelf, count) in self.by_shelf() {
let _ = writeln!(out, " {count:5} on the shelf {shelf}");
}
for state in [State::Expired, State::Unused] {
let count = self.of(state).count();
if count > 0 {
let _ = match state {
State::Expired => writeln!(
out,
" {count:5} expired, and the findings they named are reported"
),
State::Unused => writeln!(out, " {count:5} matched no finding"),
State::Applied => Ok(()),
};
}
}
for refused in &self.refused {
let _ = writeln!(
out,
" {}:{} suppresses nothing: {}",
refused.path, refused.line, refused.why
);
}
out
}
}
fn comments(block: &Block) -> impl Iterator<Item = &str> {
block
.runs
.iter()
.map(|run| run.text.trim())
.filter(|text| text.starts_with("<!--") && text.ends_with("-->"))
}
fn directive(comment: &str) -> Option<String> {
let inside = comment
.strip_prefix("<!--")?
.strip_suffix("-->")?
.trim()
.to_string();
let rest = inside.strip_prefix(MARKER)?;
match rest.starts_with([' ', ':']) {
true => Some(rest.trim_start_matches([' ', ':']).to_string()),
false => None,
}
}
fn extent(blocks: &[Block], at: usize, block: &Block) -> Extent {
let alone = matches!(block.kind, BlockKind::Html);
let covered = match alone {
true => blocks.get(at + 1).unwrap_or(block),
false => block,
};
Extent::Block {
from: covered.span.start.line,
to: covered.span.end.line,
}
}
fn read(
directive: &str,
path: &str,
shelf: Option<String>,
line: usize,
block: Extent,
rules: &[&'static str],
) -> Result<Suppression, String> {
let mut allow: Option<String> = None;
let mut scope: Option<String> = None;
let mut until: Option<String> = None;
let mut reason: Option<String> = None;
let mut note = String::new();
let mut rest = directive;
loop {
let current = rest.trim_start();
if current.is_empty() {
break;
}
let (token, tail) = match current.split_once(char::is_whitespace) {
Some((token, tail)) => (token, tail),
None => (current, ""),
};
let Some((key, value)) = token.split_once('=') else {
return Err(format!("`{token}` is not `key=value`"));
};
if key == "note" {
note = current[key.len() + 1..].trim().to_string();
break;
}
let slot = match key {
"allow" => &mut allow,
"scope" => &mut scope,
"until" => &mut until,
"reason" => &mut reason,
other => return Err(format!("`{other}` is not a field of a suppression")),
};
if slot.is_some() {
return Err(format!("`{key}` is written twice"));
}
*slot = Some(value.to_string());
rest = tail;
}
let allow = allow.ok_or("it names no rule, so `allow=` is missing")?;
if !rules.contains(&allow.as_str()) {
return Err(format!("no rule of this engine is named `{allow}`"));
}
let extent = match scope.as_deref() {
Some("file") => Extent::File,
Some("block") => block,
Some(other) => return Err(format!("`scope={other}` is neither `file` nor `block`")),
None => return Err("it states no scope, so `scope=` is missing".to_string()),
};
let until = until.ok_or("it states no expiry, so `until=` is missing")?;
let until = Date::parse(&until)
.ok_or_else(|| format!("`until={until}` is not a date written `YYYY-MM-DD`"))?;
let reason = reason.ok_or("it states no reason, so `reason=` is missing")?;
let reason = Reason::read(&reason).ok_or_else(|| {
format!("`reason={reason}` is neither `false_positive` nor `accepted_deviation`")
})?;
Ok(Suppression {
path: path.to_string(),
shelf,
rule: allow,
extent,
line,
until,
reason,
note,
hid: Vec::new(),
state: State::Unused,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::Severity;
const RULES: [&str; crate::RULES.len()] = crate::RULES;
fn day(text: &str) -> Date {
Date::parse(text).expect("a date")
}
fn one(text: &str) -> Result<Suppression, String> {
read(
&directive(text).expect("a directive"),
"a.md",
Some("specification".to_string()),
3,
Extent::Block { from: 5, to: 7 },
&RULES,
)
}
fn finding(rule: &'static str, line: usize) -> Finding {
Finding {
rule,
severity: Severity::Warn,
obligation: None,
path: "a.md".to_string(),
line,
column: 1,
message: "a message".to_string(),
remediation: "do the thing".to_string(),
patch: None,
}
}
#[test]
fn a_directive_reads_its_four_fields_in_any_order() {
let read = one(
"<!-- headwater reason=false_positive allow=language.controlled.not_met \
until=2027-01-01 scope=file note=it is a list of citations -->",
)
.expect("it reads");
assert_eq!(read.rule, "language.controlled.not_met");
assert_eq!(read.extent, Extent::File);
assert_eq!(read.until, day("2027-01-01"));
assert_eq!(read.reason, Reason::FalsePositive);
assert_eq!(read.note, "it is a list of citations");
}
#[test]
fn only_our_comments_are_directives() {
assert!(directive("<!-- a note to a reader -->").is_none());
assert!(directive("<!-- headwaters allow=x -->").is_none());
assert!(directive("<!-- ste-lint: allow sentence-length -->").is_none());
assert!(directive("<!-- headwater: allow=x -->").is_some());
}
#[test]
fn a_directive_this_engine_cannot_read_suppresses_nothing() {
for (text, expected) in [
(
"<!-- headwater allow=language.controled.not_met scope=file until=2027-01-01 \
reason=false_positive -->",
"no rule of this engine is named `language.controled.not_met`",
),
(
"<!-- headwater scope=file until=2027-01-01 reason=false_positive -->",
"it names no rule, so `allow=` is missing",
),
(
"<!-- headwater allow=voice.forbidden_construction until=2027-01-01 \
reason=false_positive -->",
"it states no scope, so `scope=` is missing",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=paragraph \
until=2027-01-01 reason=false_positive -->",
"`scope=paragraph` is neither `file` nor `block`",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file \
reason=false_positive -->",
"it states no expiry, so `until=` is missing",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file until=soon \
reason=false_positive -->",
"`until=soon` is not a date written `YYYY-MM-DD`",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file until=2027-01-01 -->",
"it states no reason, so `reason=` is missing",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file until=2027-01-01 \
reason=it-is-fine -->",
"`reason=it-is-fine` is neither `false_positive` nor `accepted_deviation`",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file until=2027-01-01 \
reason=false_positive expires=never -->",
"`expires` is not a field of a suppression",
),
(
"<!-- headwater allow=voice.forbidden_construction scope=file scope=block \
until=2027-01-01 reason=false_positive -->",
"`scope` is written twice",
),
(
"<!-- headwater allow=voice.forbidden_construction file until=2027-01-01 -->",
"`file` is not `key=value`",
),
] {
assert_eq!(one(text).expect_err("it is refused"), expected);
}
}
#[test]
fn a_file_scoped_directive_covers_the_document_and_nothing_else() {
let mut elsewhere = finding("language.controlled.not_met", 40);
elsewhere.path = "b.md".to_string();
let (kept, inventory) = apply(
vec![
finding("language.controlled.not_met", 6),
finding("language.controlled.not_met", 90),
finding("voice.forbidden_construction", 6),
elsewhere,
],
vec![one(
"<!-- headwater allow=language.controlled.not_met scope=file until=2027-01-01 \
reason=accepted_deviation -->",
)
.expect("it reads")],
Vec::new(),
day("2026-08-12"),
);
assert_eq!(kept.len(), 2);
assert_eq!(inventory.hidden(), 2);
assert_eq!(inventory.by_reason(), vec![(Reason::AcceptedDeviation, 2)]);
assert_eq!(inventory.by_shelf(), vec![("specification", 2)]);
}
#[test]
fn a_block_scoped_directive_covers_its_block_alone() {
let (kept, inventory) = apply(
vec![
finding("language.controlled.not_met", 5),
finding("language.controlled.not_met", 7),
finding("language.controlled.not_met", 8),
],
vec![one(
"<!-- headwater allow=language.controlled.not_met scope=block until=2027-01-01 \
reason=false_positive -->",
)
.expect("it reads")],
Vec::new(),
day("2026-08-12"),
);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].line, 8);
assert_eq!(inventory.hidden(), 2);
}
#[test]
fn an_expired_directive_returns_its_findings() {
let (kept, inventory) = apply(
vec![finding("language.controlled.not_met", 6)],
vec![one(
"<!-- headwater allow=language.controlled.not_met scope=file until=2026-08-11 \
reason=false_positive -->",
)
.expect("it reads")],
Vec::new(),
day("2026-08-12"),
);
assert_eq!(kept.len(), 1);
assert_eq!(inventory.hidden(), 0);
assert_eq!(inventory.of(State::Expired).count(), 1);
assert!(inventory.render().contains("1 expired"));
}
#[test]
fn a_directive_holds_on_the_day_it_names() {
let text = "<!-- headwater allow=language.controlled.not_met scope=file until=2026-08-12 \
reason=false_positive -->";
let (kept, _) = apply(
vec![finding("language.controlled.not_met", 6)],
vec![one(text).expect("it reads")],
Vec::new(),
day("2026-08-12"),
);
assert!(kept.is_empty());
}
#[test]
fn a_directive_that_hid_nothing_is_reported_as_unused() {
let (_, inventory) = apply(
Vec::new(),
vec![one(
"<!-- headwater allow=voice.forbidden_construction scope=file until=2027-01-01 \
reason=false_positive -->",
)
.expect("it reads")],
Vec::new(),
day("2026-08-12"),
);
assert_eq!(inventory.of(State::Unused).count(), 1);
assert!(inventory.render().contains("1 matched no finding"));
}
}