use crate::diagnostics::Diagnostic;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
const DISABLE_NEXT_LINE: &str = "rust-doctor-disable-next-line";
const DISABLE_LINE: &str = "rust-doctor-disable-line";
#[derive(Debug)]
struct Suppression {
target_line: u32,
rule: Option<String>,
}
pub fn apply_inline_suppressions(
diagnostics: Vec<Diagnostic>,
project_root: &Path,
) -> (Vec<Diagnostic>, usize) {
if diagnostics.is_empty() {
return (diagnostics, 0);
}
let file_set: std::collections::HashSet<PathBuf> = diagnostics
.iter()
.filter(|d| d.line.is_some())
.map(|d| d.file_path.clone())
.collect();
let files_to_check: Vec<PathBuf> = file_set.into_iter().collect();
let mut suppressions: HashMap<PathBuf, Vec<Suppression>> = HashMap::new();
for file_path in files_to_check {
let abs_buf;
let abs_path: &Path = if file_path.is_absolute() {
&file_path
} else {
abs_buf = project_root.join(&file_path);
&abs_buf
};
if let Ok(canonical) = abs_path.canonicalize()
&& let Ok(root_canonical) = project_root.canonicalize()
&& !canonical.starts_with(&root_canonical)
{
continue; }
if let Ok(content) = fs::read_to_string(abs_path) {
let file_suppressions = parse_suppressions(&content);
if !file_suppressions.is_empty() {
suppressions.insert(file_path, file_suppressions);
}
}
}
if suppressions.is_empty() {
return (diagnostics, 0);
}
let original_count = diagnostics.len();
let filtered: Vec<Diagnostic> = diagnostics
.into_iter()
.filter(|d| !is_suppressed(d, &suppressions))
.collect();
let suppressed_count = original_count - filtered.len();
(filtered, suppressed_count)
}
fn parse_suppressions(content: &str) -> Vec<Suppression> {
let mut suppressions = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let line_num = (line_idx + 1) as u32;
let trimmed = line.trim();
if let Some(rest) = extract_comment_directive(trimmed, DISABLE_NEXT_LINE) {
let rule = parse_rule_name(rest);
suppressions.push(Suppression {
target_line: line_num + 1, rule,
});
}
if let Some(rest) = extract_comment_directive(trimmed, DISABLE_LINE) {
let rule = parse_rule_name(rest);
suppressions.push(Suppression {
target_line: line_num, rule,
});
}
if !trimmed.starts_with("//")
&& let Some(comment_start) = find_line_comment_start(line)
{
let comment = line[comment_start + 2..].trim();
if let Some(rest) = comment.strip_prefix(DISABLE_LINE) {
let rule = parse_rule_name(rest);
suppressions.push(Suppression {
target_line: line_num,
rule,
});
}
}
}
suppressions
}
fn find_line_comment_start(line: &str) -> Option<usize> {
let mut in_string = false;
let mut prev_backslash = false;
let bytes = line.as_bytes();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
if in_string {
if b == b'\\' && !prev_backslash {
prev_backslash = true;
i += 1;
continue;
}
if b == b'"' && !prev_backslash {
in_string = false;
}
prev_backslash = false;
} else if b == b'"' {
in_string = true;
} else if b == b'/' && bytes.get(i + 1) == Some(&b'/') {
return Some(i);
}
i += 1;
}
None
}
fn extract_comment_directive<'a>(line: &'a str, directive: &str) -> Option<&'a str> {
let stripped = line.strip_prefix("//")?;
let stripped = stripped.trim_start();
stripped.strip_prefix(directive).map(str::trim)
}
fn parse_rule_name(rest: &str) -> Option<String> {
let name = rest.trim();
if name.is_empty() {
None } else {
Some(name.to_string())
}
}
fn is_suppressed(diag: &Diagnostic, suppressions: &HashMap<PathBuf, Vec<Suppression>>) -> bool {
let Some(line) = diag.line else {
return false; };
let file_suppressions = suppressions.get(&diag.file_path).or_else(|| {
suppressions.iter().find_map(|(k, v)| {
if diag.file_path.ends_with(k) || k.ends_with(&diag.file_path) {
Some(v)
} else {
None
}
})
});
let Some(file_suppressions) = file_suppressions else {
return false;
};
file_suppressions.iter().any(|s| {
s.target_line == line && (s.rule.is_none() || s.rule.as_deref() == Some(diag.rule.as_str()))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostics::{Category, Severity};
fn make_diag(file: &str, rule: &str, line: u32) -> Diagnostic {
Diagnostic {
file_path: PathBuf::from(file),
rule: rule.to_string(),
category: Category::Style,
severity: Severity::Warning,
message: "test".to_string(),
help: None,
line: Some(line),
column: None,
fix: None,
}
}
#[test]
fn test_parse_disable_next_line_with_rule() {
let content =
"// rust-doctor-disable-next-line unwrap-in-production\nlet x = foo.unwrap();\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 1);
assert_eq!(supps[0].target_line, 2);
assert_eq!(supps[0].rule, Some("unwrap-in-production".to_string()));
}
#[test]
fn test_parse_disable_next_line_no_rule() {
let content = "// rust-doctor-disable-next-line\nlet x = foo.unwrap();\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 1);
assert_eq!(supps[0].target_line, 2);
assert_eq!(supps[0].rule, None);
}
#[test]
fn test_parse_disable_line() {
let content = "let x = foo.unwrap(); // rust-doctor-disable-line\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 1);
assert_eq!(supps[0].target_line, 1);
assert_eq!(supps[0].rule, None);
}
#[test]
fn test_parse_disable_line_with_rule() {
let content = "let x = foo.unwrap(); // rust-doctor-disable-line unwrap-in-production\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 1);
assert_eq!(supps[0].rule, Some("unwrap-in-production".to_string()));
}
#[test]
fn test_parse_standalone_disable_line_comment() {
let content = "// rust-doctor-disable-line some-rule\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 1);
assert_eq!(supps[0].target_line, 1);
assert_eq!(supps[0].rule, Some("some-rule".to_string()));
}
#[test]
fn test_parse_no_suppressions() {
let content = "fn main() {\n println!(\"hello\");\n}\n";
let supps = parse_suppressions(content);
assert!(supps.is_empty());
}
#[test]
fn test_parse_multiple_suppressions() {
let content = "// rust-doctor-disable-next-line rule-a\nline1\n// rust-doctor-disable-next-line rule-b\nline2\n";
let supps = parse_suppressions(content);
assert_eq!(supps.len(), 2);
}
#[test]
fn test_suppressed_by_specific_rule() {
let diag = make_diag("test.rs", "unwrap-in-production", 5);
let mut suppressions = HashMap::new();
suppressions.insert(
PathBuf::from("test.rs"),
vec![Suppression {
target_line: 5,
rule: Some("unwrap-in-production".to_string()),
}],
);
assert!(is_suppressed(&diag, &suppressions));
}
#[test]
fn test_suppressed_by_wildcard() {
let diag = make_diag("test.rs", "any-rule", 5);
let mut suppressions = HashMap::new();
suppressions.insert(
PathBuf::from("test.rs"),
vec![Suppression {
target_line: 5,
rule: None,
}],
);
assert!(is_suppressed(&diag, &suppressions));
}
#[test]
fn test_not_suppressed_wrong_rule() {
let diag = make_diag("test.rs", "rule-a", 5);
let mut suppressions = HashMap::new();
suppressions.insert(
PathBuf::from("test.rs"),
vec![Suppression {
target_line: 5,
rule: Some("rule-b".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions));
}
#[test]
fn test_not_suppressed_wrong_line() {
let diag = make_diag("test.rs", "rule-a", 5);
let mut suppressions = HashMap::new();
suppressions.insert(
PathBuf::from("test.rs"),
vec![Suppression {
target_line: 10,
rule: Some("rule-a".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions));
}
#[test]
fn test_not_suppressed_no_line_number() {
let mut diag = make_diag("test.rs", "rule-a", 5);
diag.line = None;
let mut suppressions = HashMap::new();
suppressions.insert(
PathBuf::from("test.rs"),
vec![Suppression {
target_line: 5,
rule: None,
}],
);
assert!(!is_suppressed(&diag, &suppressions));
}
#[test]
fn test_apply_with_temp_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.rs");
std::fs::write(
&file_path,
"// rust-doctor-disable-next-line test-rule\nlet x = 1;\nlet y = 2;\n",
)
.unwrap();
let diags = vec![
Diagnostic {
file_path: file_path.clone(),
rule: "test-rule".to_string(),
category: Category::Style,
severity: Severity::Warning,
message: "test".to_string(),
help: None,
line: Some(2),
column: None,
fix: None,
},
Diagnostic {
file_path,
rule: "other-rule".to_string(),
category: Category::Style,
severity: Severity::Warning,
message: "test".to_string(),
help: None,
line: Some(3),
column: None,
fix: None,
},
];
let (filtered, suppressed) = apply_inline_suppressions(diags, dir.path());
assert_eq!(suppressed, 1);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].rule, "other-rule");
}
#[test]
fn test_find_comment_in_normal_code() {
assert_eq!(find_line_comment_start("let x = 1; // comment"), Some(11));
}
#[test]
fn test_find_comment_ignores_string_literal() {
assert_eq!(
find_line_comment_start(r#"let url = "https://example.com"; // real comment"#),
Some(33)
);
}
#[test]
fn test_find_comment_only_string_no_comment() {
assert_eq!(
find_line_comment_start(r#"let url = "https://example.com";"#),
None
);
}
#[test]
fn test_find_comment_no_comment_at_all() {
assert_eq!(find_line_comment_start("let x = 1;"), None);
}
#[test]
fn test_suppression_not_triggered_by_string_literal() {
let content = r#"let msg = "see // rust-doctor-disable-line for details";"#;
let supps = parse_suppressions(content);
assert!(supps.is_empty());
}
}