use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::finding::Finding;
const MARKER: &str = "judge-ignore:";
const SEPARATORS: [&str; 3] = ["—", "--", "-"];
#[derive(Debug)]
pub enum SuppressionError {
Io(PathBuf, std::io::Error),
MissingReason(PathBuf, usize, String),
}
impl std::fmt::Display for SuppressionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "{}: failed to read file: {err}", path.display()),
Self::MissingReason(path, line, raw) => write!(
f,
"{}:{line}: `judge-ignore` requires a reason, e.g. `// judge-ignore: <rule-id> — <reason>` (found: `{}`)",
path.display(),
raw.trim()
),
}
}
}
impl std::error::Error for SuppressionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(_, err) => Some(err),
Self::MissingReason(..) => None,
}
}
}
struct Suppression {
rule_id: String,
}
fn parse_suppression(
path: &Path,
line_number: usize,
text: &str,
) -> Result<Option<Suppression>, SuppressionError> {
let Some(at) = text.find(MARKER) else {
return Ok(None);
};
let rest = &text[at + MARKER.len()..];
let words: Vec<&str> = rest.split_whitespace().collect();
let missing_reason = || {
SuppressionError::MissingReason(path.to_path_buf(), line_number, text.trim().to_string())
};
let [rule_id, sep, reason @ ..] = words.as_slice() else {
return Err(missing_reason());
};
if !SEPARATORS.contains(sep) || reason.is_empty() {
return Err(missing_reason());
}
Ok(Some(Suppression {
rule_id: (*rule_id).to_string(),
}))
}
pub fn apply_inline_suppressions(
findings: Vec<Finding>,
workspace_root: &Path,
) -> Result<(Vec<Finding>, usize), SuppressionError> {
let mut lines_by_file: HashMap<PathBuf, Vec<String>> = HashMap::new();
let mut kept = Vec::with_capacity(findings.len());
let mut suppressed = 0usize;
for finding in findings {
let path = workspace_root.join(&finding.location.file);
if !lines_by_file.contains_key(&path) {
let content = std::fs::read_to_string(&path)
.map_err(|err| SuppressionError::Io(path.clone(), err))?;
lines_by_file.insert(path.clone(), content.lines().map(str::to_string).collect());
}
let lines = &lines_by_file[&path];
let finding_line = finding.location.line.get();
let rule = finding.rule.as_str();
let matched = suppression_matches(&path, lines, finding_line, false, rule)?
|| suppression_matches(&path, lines, finding_line.saturating_sub(1), true, rule)?;
if matched {
suppressed += 1;
} else {
kept.push(finding);
}
}
Ok((kept, suppressed))
}
fn suppression_matches(
path: &Path,
lines: &[String],
line_number: usize,
require_comment_only: bool,
rule: &str,
) -> Result<bool, SuppressionError> {
if line_number == 0 {
return Ok(false);
}
let Some(text) = lines.get(line_number - 1) else {
return Ok(false);
};
if require_comment_only && !text.trim_start().starts_with("//") {
return Ok(false);
}
let Some(suppression) = parse_suppression(path, line_number, text)? else {
return Ok(false);
};
Ok(suppression.rule_id == rule)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{EvidenceClass, Location, OneBasedLine, Origin, Severity};
use crate::test_util::TempDir;
fn finding_at(rule: &str, file: PathBuf, line: usize) -> Finding {
Finding::new(
format!("{rule}:{}:{line}", file.display()),
rule,
Severity::Warn,
Location {
file,
line: OneBasedLine::new(line).unwrap(),
item_path: "fixture::item".to_string(),
},
EvidenceClass::DerivedFact,
Origin::Code,
None,
)
}
fn ignore_marker() -> String {
["judge", "-ignore:"].concat()
}
#[test]
fn suppresses_a_finding_via_a_same_line_trailing_comment() {
let dir = TempDir::new("suppression-same-line");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} some-rule — best-effort cleanup\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 2);
let (kept, suppressed) = apply_inline_suppressions(vec![finding], &dir).unwrap();
assert!(kept.is_empty());
assert_eq!(suppressed, 1);
}
#[test]
fn suppresses_a_finding_via_a_preceding_line_comment() {
let dir = TempDir::new("suppression-prev-line");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n // {} some-rule — best-effort cleanup\n let _ = 1;\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 3);
let (kept, suppressed) = apply_inline_suppressions(vec![finding], &dir).unwrap();
assert!(kept.is_empty());
assert_eq!(suppressed, 1);
}
#[test]
fn does_not_suppress_a_finding_when_the_rule_id_differs() {
let dir = TempDir::new("suppression-rule-mismatch");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} other-rule — unrelated\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 2);
let (kept, suppressed) = apply_inline_suppressions(vec![finding], &dir).unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(suppressed, 0);
}
#[test]
fn missing_reason_is_a_hard_error() {
let dir = TempDir::new("suppression-missing-reason");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} some-rule\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 2);
let err = apply_inline_suppressions(vec![finding], &dir).unwrap_err();
match err {
SuppressionError::MissingReason(_, line, _) => assert_eq!(line, 2),
other => panic!("expected MissingReason, got {other:?}"),
}
}
#[test]
fn separator_without_a_reason_is_a_hard_error() {
let dir = TempDir::new("suppression-empty-reason");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} some-rule —\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 2);
let err = apply_inline_suppressions(vec![finding], &dir).unwrap_err();
assert!(matches!(err, SuppressionError::MissingReason(..)));
}
#[test]
fn all_three_separator_spellings_work() {
for sep in ["—", "--", "-"] {
let dir = TempDir::new("suppression-separator");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} some-rule {sep} reason\n}}\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 2);
let (kept, suppressed) = apply_inline_suppressions(vec![finding], &dir).unwrap();
assert!(kept.is_empty(), "separator {sep:?} did not suppress");
assert_eq!(suppressed, 1);
}
}
#[test]
fn multiple_findings_in_one_file_are_each_evaluated_independently() {
let dir = TempDir::new("suppression-multi-finding");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{\n let _ = 1; // {} some-rule — cleanup\n let _ = 2;\n}}\n",
ignore_marker()
),
)
.unwrap();
let findings = vec![
finding_at("some-rule", file.clone(), 2),
finding_at("some-rule", file, 3),
];
let (kept, suppressed) = apply_inline_suppressions(findings, &dir).unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(suppressed, 1);
assert_eq!(kept[0].location.line, 3);
}
#[test]
fn a_finding_on_the_first_line_only_checks_its_own_line() {
let dir = TempDir::new("suppression-first-line");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"fn main() {{}} // {} some-rule — cleanup\n",
ignore_marker()
),
)
.unwrap();
let finding = finding_at("some-rule", file, 1);
let (kept, suppressed) = apply_inline_suppressions(vec![finding], &dir).unwrap();
assert!(kept.is_empty());
assert_eq!(suppressed, 1);
}
}