use crate::check::{Violation, ViolationKind};
fn is_attribute(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("#[") || t.starts_with("#![")
}
fn is_comment(line: &str) -> bool {
line.trim_start().starts_with("//")
}
fn is_justified(lines: &[&str], i: usize) -> bool {
if let Some((_, tail)) = lines[i].rsplit_once(']')
&& tail.contains("//")
{
return true;
}
let mut j = i;
while j > 0 && is_attribute(lines[j - 1]) {
j -= 1;
}
j > 0 && is_comment(lines[j - 1])
}
#[must_use]
pub fn scan_unjustified_allows(rel_path: &str, text: &str) -> Vec<Violation> {
if !std::path::Path::new(rel_path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("rs"))
{
return Vec::new();
}
if rto_graph::is_scan_exempt(text.as_bytes()) {
return Vec::new();
}
let lines: Vec<&str> = text.lines().collect();
lines
.iter()
.enumerate()
.filter(|(_, l)| {
let t = l.trim_start();
t.starts_with("#[allow(") || t.starts_with("#![allow(")
})
.filter(|(i, _)| !is_justified(&lines, *i))
.map(|(i, _)| Violation {
kind: ViolationKind::UnjustifiedAllow,
message: format!(
"{rel_path}:{}: `#[allow(…)]` carries no justification — AGENTS.md \
asks that an allow be justified in a comment, so a reader can tell \
a considered exception from a silenced warning",
i + 1
),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::scan_unjustified_allows;
fn hits(text: &str) -> Vec<String> {
scan_unjustified_allows("src/x.rs", text)
.into_iter()
.map(|v| v.message)
.collect()
}
#[test]
fn an_allow_with_a_comment_above_it_is_justified() {
assert!(hits("// why this is right\n#[allow(clippy::foo)]\nfn f() {}\n").is_empty());
}
#[test]
fn a_bare_allow_is_a_violation_naming_its_line() {
let h = hits("fn f() {\n #[allow(clippy::foo)]\n let x = 1;\n}\n");
assert_eq!(h.len(), 1);
assert!(h[0].contains("src/x.rs:2:"), "{}", h[0]);
assert!(h[0].contains("no justification"), "{}", h[0]);
}
#[test]
fn a_justification_above_an_intervening_attribute_still_counts() {
assert!(
hits(
"// the prefix is intentional\n#[derive(Debug)]\n#[allow(clippy::foo)]\nstruct S;\n"
)
.is_empty()
);
}
#[test]
fn a_blank_line_separates_a_comment_from_the_attribute() {
assert_eq!(
hits("// unrelated prose\n\n#[allow(clippy::foo)]\nfn f() {}\n").len(),
1
);
}
#[test]
fn a_trailing_comment_on_the_attribute_line_justifies_it() {
assert!(hits("#[allow(clippy::foo)] // narrow, and deliberate\nfn f() {}\n").is_empty());
}
#[test]
fn an_inner_allow_at_the_top_of_a_file_is_checked_too() {
assert_eq!(hits("#![allow(dead_code)]\nfn f() {}\n").len(), 1);
assert!(
hits("//! shared fixture, not every consumer uses every path\n#![allow(dead_code)]\n")
.is_empty()
);
}
#[test]
fn only_rust_sources_are_scanned() {
let text = "#[allow(clippy::foo)]\n";
assert!(scan_unjustified_allows("docs/AGENTS.md", text).is_empty());
assert!(scan_unjustified_allows("fixtures/x.json", text).is_empty());
assert_eq!(scan_unjustified_allows("src/x.rs", text).len(), 1);
assert_eq!(scan_unjustified_allows("src/X.RS", text).len(), 1);
}
#[test]
fn a_file_declaring_itself_fixture_data_is_exempt() {
let text = "// roteiro:ignore-file — fixtures below\n#[allow(clippy::foo)]\nfn f() {}\n";
assert!(hits(text).is_empty());
assert_eq!(
hits("// fixtures below\n\n#[allow(clippy::foo)]\nfn f() {}\n").len(),
1
);
}
#[test]
fn expect_is_not_allow() {
assert!(hits("#[expect(clippy::foo)]\nfn f() {}\n").is_empty());
}
}