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
};
let (Ok(canonical), Ok(root_canonical)) =
(abs_path.canonicalize(), project_root.canonicalize())
else {
continue;
};
if !canonical.starts_with(&root_canonical) {
continue; }
if let Ok(content) = fs::read_to_string(&canonical) {
let file_suppressions = parse_suppressions(&content);
if !file_suppressions.is_empty() {
suppressions.insert(normalize_path(&file_path, project_root), 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, project_root))
.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 normalize_path(path: &Path, project_root: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
};
joined.canonicalize().unwrap_or(joined)
}
fn is_suppressed(
diag: &Diagnostic,
suppressions: &HashMap<PathBuf, Vec<Suppression>>,
project_root: &Path,
) -> bool {
let Some(line) = diag.line else {
return false; };
let key = normalize_path(&diag.file_path, project_root);
let Some(file_suppressions) = suppressions.get(&key) 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);
}
const TEST_ROOT: &str = "/rust-doctor-suppression-test-root";
fn supp_map(file: &str, suppressions: Vec<Suppression>) -> HashMap<PathBuf, Vec<Suppression>> {
let mut map = HashMap::new();
map.insert(
normalize_path(Path::new(file), Path::new(TEST_ROOT)),
suppressions,
);
map
}
#[test]
fn test_suppressed_by_specific_rule() {
let diag = make_diag("test.rs", "unwrap-in-production", 5);
let suppressions = supp_map(
"test.rs",
vec![Suppression {
target_line: 5,
rule: Some("unwrap-in-production".to_string()),
}],
);
assert!(is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_suppressed_by_wildcard() {
let diag = make_diag("test.rs", "any-rule", 5);
let suppressions = supp_map(
"test.rs",
vec![Suppression {
target_line: 5,
rule: None,
}],
);
assert!(is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_not_suppressed_wrong_rule() {
let diag = make_diag("test.rs", "rule-a", 5);
let suppressions = supp_map(
"test.rs",
vec![Suppression {
target_line: 5,
rule: Some("rule-b".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_not_suppressed_wrong_line() {
let diag = make_diag("test.rs", "rule-a", 5);
let suppressions = supp_map(
"test.rs",
vec![Suppression {
target_line: 10,
rule: Some("rule-a".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_not_suppressed_no_line_number() {
let mut diag = make_diag("test.rs", "rule-a", 5);
diag.line = None;
let suppressions = supp_map(
"test.rs",
vec![Suppression {
target_line: 5,
rule: None,
}],
);
assert!(!is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_homonym_members_do_not_cross_suppress() {
let diag = make_diag("src/main.rs", "rule-a", 5);
let suppressions = supp_map(
"crateB/src/main.rs",
vec![Suppression {
target_line: 5,
rule: Some("rule-a".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_homonym_members_do_not_cross_suppress_mirror() {
let diag = make_diag("crateA/src/main.rs", "rule-a", 5);
let suppressions = supp_map(
"src/main.rs",
vec![Suppression {
target_line: 5,
rule: Some("rule-a".to_string()),
}],
);
assert!(!is_suppressed(&diag, &suppressions, Path::new(TEST_ROOT)));
}
#[test]
fn test_abs_rel_mismatch_still_matches_same_file() {
let root = Path::new(TEST_ROOT);
let abs = root.join("src/main.rs");
let diag = make_diag("src/main.rs", "rule-a", 5);
let mut suppressions = HashMap::new();
suppressions.insert(
normalize_path(&abs, root),
vec![Suppression {
target_line: 5,
rule: Some("rule-a".to_string()),
}],
);
assert!(is_suppressed(&diag, &suppressions, root));
}
#[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_apply_homonym_workspace_no_cross_suppression() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let crate_b_src = root.join("crateB/src");
std::fs::create_dir_all(&crate_b_src).unwrap();
std::fs::write(
crate_b_src.join("main.rs"),
"// rust-doctor-disable-next-line test-rule\nfn b() {}\n",
)
.unwrap();
let surviving_diag = make_diag("src/main.rs", "test-rule", 2);
let suppressed_diag = make_diag("crateB/src/main.rs", "test-rule", 2);
let (filtered, suppressed) =
apply_inline_suppressions(vec![surviving_diag, suppressed_diag], root);
assert_eq!(
suppressed, 1,
"only crateB's own diagnostic must be suppressed"
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].file_path, PathBuf::from("src/main.rs"));
}
#[test]
fn test_out_of_root_file_not_read() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("evil.rs");
std::fs::write(
&outside_file,
"// rust-doctor-disable-next-line test-rule\nfn x() {}\n",
)
.unwrap();
let diag = Diagnostic {
file_path: outside_file,
rule: "test-rule".to_string(),
category: Category::Style,
severity: Severity::Warning,
message: "test".to_string(),
help: None,
line: Some(2),
column: None,
fix: None,
};
let (filtered, suppressed) = apply_inline_suppressions(vec![diag], root.path());
assert_eq!(
suppressed, 0,
"an out-of-root suppression comment must not apply"
);
assert_eq!(filtered.len(), 1);
}
#[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());
}
}