use crate::check::{Violation, ViolationKind};
fn is_attribute(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("#[") || t.starts_with("#![")
}
fn is_justifying_comment(line: &str) -> bool {
let t = line.trim_start();
if !t.starts_with("//") {
return false;
}
if t.starts_with("////") {
return true;
}
!t.starts_with("///")
}
fn is_justified(lines: &[&str], i: usize) -> bool {
if carries_reason(lines, i) {
return true;
}
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_justifying_comment(lines[j - 1])
}
fn carries_reason(lines: &[&str], i: usize) -> bool {
const MAX_SPAN: usize = 200;
let mut depth = 0i32;
let mut comment_depth = 0usize;
for line in lines.iter().skip(i).take(MAX_SPAN) {
let code = strip_comments(line, &mut comment_depth);
if has_reason_field(&code) {
return true;
}
for c in code.chars() {
match c {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
}
if depth <= 0 {
break;
}
}
false
}
fn strip_comments(line: &str, depth: &mut usize) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
loop {
while *depth > 0 {
let open = rest.find("/*");
let close = rest.find("*/");
let opens_first = match (open, close) {
(Some(o), Some(c)) => o < c,
(Some(_), None) => true,
_ => false,
};
if opens_first {
let o = open.expect("an opener, by the match above");
*depth += 1;
rest = &rest[o + 2..];
} else if let Some(c) = close {
*depth -= 1;
rest = &rest[c + 2..];
} else {
return out;
}
}
let line_at = rest.find("//");
let block_at = rest.find("/*");
let opens_block = match (line_at, block_at) {
(Some(l), Some(b)) => b < l,
(None, Some(_)) => true,
_ => false,
};
if opens_block {
let b = block_at.expect("a block opener, by the match above");
out.push_str(&rest[..b]);
*depth = 1;
rest = &rest[b + 2..];
continue;
}
out.push_str(&rest[..line_at.unwrap_or(rest.len())]);
return out;
}
}
fn has_reason_field(line: &str) -> bool {
let bytes = line.as_bytes();
line.match_indices("reason").any(|(at, _)| {
let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
let after = line[at + "reason".len()..].trim_start();
before_ok && after.starts_with('=') && !after.starts_with("==")
})
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[must_use]
pub fn scan_lossy_identity(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();
let mut out = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !line.contains("to_string_lossy") {
continue;
}
if HASH_MARKERS.iter().any(|m| line.contains(m)) {
out.push(Violation {
kind: ViolationKind::LossyIdentity,
message: format!(
"{rel_path}:{}: a lossy string conversion reaches a hash — two \
inputs differing only in invalid UTF-8 collapse to one digest, \
and the second silently replaces the first. Hash the bytes \
(`as_os_str().as_encoded_bytes()`) rather than the lossy string, \
or reject non-UTF-8 input explicitly.",
i + 1
),
});
}
}
out
}
const HASH_MARKERS: [&str; 5] = ["sha256", "Sha256", "Hasher", "blake3", "digest"];
#[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 for a `reason = \"…\"` field or a comment, so a reader can tell \
a considered exception from a silenced warning",
i + 1
),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{ViolationKind, scan_lossy_identity, scan_unjustified_allows};
#[test]
fn a_doc_comment_above_an_allow_is_not_a_justification() {
let src = "/// What this function does.\n#[allow(clippy::too_many_lines)]\nfn f() {}\n";
let v = scan_unjustified_allows("src/x.rs", src);
assert_eq!(v.len(), 1, "an outer doc comment must not justify: {v:?}");
assert_eq!(v[0].kind, ViolationKind::UnjustifiedAllow);
assert!(v[0].message.contains("src/x.rs:2"), "{}", v[0].message);
assert!(
scan_unjustified_allows(
"src/x.rs",
"//! shared fixture, not every consumer uses every path\n#![allow(dead_code)]\n",
)
.is_empty(),
"module prose is where a file-level allow's reason lives"
);
}
#[test]
fn the_real_justifications_still_count() {
for src in [
"// Exact by construction; see the ranges above.\n#[allow(clippy::cast_sign_loss)]\nfn f() {}\n",
"#[allow(clippy::cast_sign_loss, reason = \"exact by construction\")]\nfn f() {}\n",
"#[allow(clippy::cast_sign_loss)] // exact by construction\nfn f() {}\n",
"//// Not a doc comment.\n#[allow(clippy::cast_sign_loss)]\nfn f() {}\n",
"// Justified.\n#[must_use]\n#[allow(clippy::cast_sign_loss)]\nfn f() {}\n",
] {
assert!(
scan_unjustified_allows("src/x.rs", src).is_empty(),
"must stay silent: {src}"
);
}
}
#[test]
fn an_allow_with_no_comment_at_all_is_still_reported() {
let v =
scan_unjustified_allows("src/x.rs", "#[allow(clippy::too_many_lines)]\nfn f() {}\n");
assert_eq!(v.len(), 1, "{v:?}");
}
#[test]
fn a_lossy_conversion_feeding_a_hash_is_reported() {
let v = scan_lossy_identity(
"src/runner.rs",
"let digest = sha256_hex(absolute.to_string_lossy().as_bytes());\n",
);
assert_eq!(v.len(), 1, "{v:?}");
assert_eq!(v[0].kind, ViolationKind::LossyIdentity);
assert!(v[0].message.contains("src/runner.rs:1"), "{}", v[0].message);
}
#[test]
fn a_lossy_conversion_in_a_message_is_not_reported() {
for line in [
"eprintln!(\"cannot read {}\", path.to_string_lossy());",
"let name = entry.file_name().to_string_lossy().into_owned();",
"anyhow::bail!(\"{}: unreadable\", p.to_string_lossy())",
] {
assert!(
scan_lossy_identity("src/x.rs", line).is_empty(),
"false positive on: {line}"
);
}
}
#[test]
fn a_conversion_near_a_hash_but_not_in_it_is_not_reported() {
let src = "found.push((\n entry.file_name().to_string_lossy().into_owned(),\n sha256_hex(&bytes),\n));\n";
assert!(
scan_lossy_identity("tests/pin.rs", src).is_empty(),
"a conversion two lines from a hash it does not feed must not fire"
);
}
#[test]
fn a_mention_in_prose_is_not_a_use() {
let md = "Call `sha256_hex(p.to_string_lossy().as_bytes())` to hash a path.\n";
assert!(scan_lossy_identity("docs/guide.md", md).is_empty());
}
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_reason_field_is_a_justification() {
assert!(
hits("#[allow(clippy::foo, reason = \"counts stay under 2^53\")]\nfn f() {}\n")
.is_empty()
);
assert!(
hits(
"#[allow(\n clippy::too_many_lines,\n reason = \"one home; splitting \
would re-fork the copies this deletes\"\n)]\nfn f() {}\n"
)
.is_empty()
);
assert!(hits("#![allow(dead_code, reason = \"test support\")]\nfn f() {}\n").is_empty());
}
#[test]
fn a_bracket_in_a_comment_does_not_reach_the_next_attribute() {
let text = "#[allow(\n clippy::a, // see note [1\n)]\nfn f() {}\n\n\
#[allow(clippy::b, reason = \"stated\")]\nfn g() {}\n";
let h = hits(text);
assert_eq!(h.len(), 1, "the bare allow is still reported: {h:?}");
assert!(h[0].contains("src/x.rs:1:"), "{}", h[0]);
}
#[test]
fn a_block_comment_does_not_hide_the_end_of_the_attribute() {
let overrun = "#[allow(\n clippy::a, /* note [1 */\n)]\nfn f() {}\n\n\
#[allow(clippy::b, reason = \"stated\")]\nfn g() {}\n";
let h = hits(overrun);
assert_eq!(h.len(), 1, "the bare allow is still reported: {h:?}");
assert!(h[0].contains("src/x.rs:1:"), "{}", h[0]);
assert!(
hits(
"#[allow(\n clippy::a, /* a note\n still the note */\n \
reason = \"stated\"\n)]\nfn f() {}\n"
)
.is_empty(),
"a real reason after a multi-line block comment still counts"
);
}
#[test]
fn a_long_attribute_still_finds_its_reason_and_the_bound_still_bounds() {
let long = |lints: usize| {
use std::fmt::Write as _;
let mut src = String::from("#[allow(\n");
for n in 0..lints {
let _ = writeln!(src, " clippy::lint_{n},");
}
src.push_str(" reason = \"stated\"\n)]\nfn f() {}\n");
src
};
assert!(
hits(&long(120)).is_empty(),
"a reason 120 lines down is still a reason"
);
assert_eq!(
hits(&long(400)).len(),
1,
"and past the backstop the scan gives up, which is what the backstop is"
);
}
#[test]
fn a_nested_block_comment_does_not_end_early() {
let overrun = "#[allow(\n clippy::a, /* see /* note */ [1 */\n)]\nfn f() {}\n\n\
#[allow(clippy::b, reason = \"stated\")]\nfn g() {}\n";
let h = hits(overrun);
assert_eq!(h.len(), 1, "the bare allow is still reported: {h:?}");
assert!(h[0].contains("src/x.rs:1:"), "{}", h[0]);
assert!(
hits(
"#[allow(\n clippy::a, /* a /* nested */ note */\n \
reason = \"stated\"\n)]\nfn f() {}\n"
)
.is_empty(),
"a reason after a nested block comment still counts"
);
}
#[test]
fn a_comment_mentioning_a_reason_is_not_the_field() {
let h = hits("// the reason = it was needed\n\n#[allow(clippy::a)]\nfn f() {}\n");
assert_eq!(h.len(), 1, "{h:?}");
}
#[test]
fn accepting_a_reason_does_not_accept_a_bare_allow() {
let text = "#[allow(clippy::a, reason = \"stated\")]\nfn f() {}\n\n\
#[allow(clippy::b)]\nfn g() {}\n";
let h = hits(text);
assert_eq!(h.len(), 1, "only the bare one is reported: {h:?}");
assert!(h[0].contains("src/x.rs:4:"), "{}", h[0]);
}
#[test]
fn a_word_containing_reason_is_not_the_field() {
assert_eq!(
hits("#[allow(clippy::unreasonable_x)]\nfn f() {}\n").len(),
1
);
assert_eq!(hits("#[allow(some::reasoning)]\nfn f() {}\n").len(), 1);
assert_eq!(hits("#[allow(cfg(reason == 1))]\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());
}
}