use glob::Pattern;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IgnoreResult {
Ignored(String), NotIgnored,
}
#[derive(Debug, Default)]
pub struct IgnoreFile {
include_patterns: Vec<CompiledPattern>,
exclude_patterns: Vec<CompiledPattern>,
ignored_rule_codes: HashSet<String>,
file_specific_ignores: HashMap<String, HashSet<String>>,
line_specific_ignores: HashMap<(String, usize), HashSet<String>>,
}
#[derive(Debug)]
struct CompiledPattern {
original: String,
pattern: Pattern,
}
fn is_rule_code(s: &str) -> bool {
let s = s.trim().to_uppercase();
if s.starts_with("SEC") && s.len() >= 4 && s[3..].chars().all(|c| c.is_ascii_digit()) {
return true;
}
if s.starts_with("SC") && s.len() >= 3 && s[2..].chars().all(|c| c.is_ascii_digit()) {
return true;
}
if s.starts_with("DET") && s.len() >= 4 && s[3..].chars().all(|c| c.is_ascii_digit()) {
return true;
}
if s.starts_with("IDEM") && s.len() >= 5 && s[4..].chars().all(|c| c.is_ascii_digit()) {
return true;
}
false
}
fn parse_rule_specifier(s: &str) -> Option<(String, Option<String>, Option<usize>)> {
let trimmed = s.trim();
let parts: Vec<&str> = trimmed.splitn(3, ':').collect();
let rule_code = parts.first()?;
if !is_rule_code(rule_code) {
return None;
}
match parts.len() {
1 => Some((rule_code.to_string(), None, None)),
2 => {
let path = parts[1].trim();
if path.is_empty() {
return None;
}
Some((rule_code.to_string(), Some(path.to_string()), None))
}
3 => {
let path = parts[1].trim();
let line_str = parts[2].trim();
if path.is_empty() {
return None;
}
let line_num = line_str.parse::<usize>().ok()?;
Some((
rule_code.to_string(),
Some(path.to_string()),
Some(line_num),
))
}
_ => None,
}
}
fn normalize_path(path: &str) -> String {
let path = path.strip_prefix("./").unwrap_or(path);
path.replace('\\', "/")
}
impl IgnoreFile {
pub fn empty() -> Self {
Self::default()
}
pub fn load(path: &Path) -> Result<Option<Self>, String> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
Ok(Some(Self::parse(&content)?))
}
pub fn parse(content: &str) -> Result<Self, String> {
let mut ignore = Self::default();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (is_negation, pattern_str) = if let Some(stripped) = trimmed.strip_prefix('!') {
(true, stripped.trim())
} else {
(false, trimmed)
};
if !is_negation {
if let Some((rule_code, file_path, line_num)) = parse_rule_specifier(pattern_str) {
let rule_upper = rule_code.to_uppercase();
match (file_path, line_num) {
(Some(path), Some(line)) => {
let key = (normalize_path(&path), line);
ignore
.line_specific_ignores
.entry(key)
.or_default()
.insert(rule_upper);
}
(Some(path), None) => {
let key = normalize_path(&path);
ignore
.file_specific_ignores
.entry(key)
.or_default()
.insert(rule_upper);
}
(None, None) => {
ignore.ignored_rule_codes.insert(rule_upper);
}
(None, Some(_)) => {
}
}
continue;
}
}
let pattern = Pattern::new(pattern_str).map_err(|e| {
format!(
"Invalid pattern on line {}: '{}' - {}",
line_num + 1,
pattern_str,
e
)
})?;
let compiled = CompiledPattern {
original: trimmed.to_string(),
pattern,
};
if is_negation {
ignore.exclude_patterns.push(compiled);
} else {
ignore.include_patterns.push(compiled);
}
}
Ok(ignore)
}
pub fn should_ignore(&self, path: &Path) -> IgnoreResult {
let path_str = path.to_string_lossy();
for pattern in &self.exclude_patterns {
if pattern.pattern.matches(&path_str) {
return IgnoreResult::NotIgnored;
}
}
for pattern in &self.include_patterns {
if pattern.pattern.matches(&path_str) {
return IgnoreResult::Ignored(pattern.original.clone());
}
}
IgnoreResult::NotIgnored
}
pub fn has_patterns(&self) -> bool {
!self.include_patterns.is_empty() || !self.exclude_patterns.is_empty()
}
pub fn pattern_count(&self) -> usize {
self.include_patterns.len() + self.exclude_patterns.len()
}
pub fn should_ignore_rule(&self, rule_code: &str) -> bool {
self.ignored_rule_codes.contains(&rule_code.to_uppercase())
}
pub fn should_ignore_rule_at(&self, rule_code: &str, file_path: &Path, line: usize) -> bool {
let rule_upper = rule_code.to_uppercase();
let path_str = normalize_path(&file_path.to_string_lossy());
let line_key = (path_str.clone(), line);
if let Some(rules) = self.line_specific_ignores.get(&line_key) {
if rules.contains(&rule_upper) {
return true;
}
}
if let Some(rules) = self.file_specific_ignores.get(&path_str) {
if rules.contains(&rule_upper) {
return true;
}
}
self.ignored_rule_codes.contains(&rule_upper)
}
pub fn ignored_rules(&self) -> Vec<String> {
self.ignored_rule_codes.iter().cloned().collect()
}
pub fn has_ignored_rules(&self) -> bool {
!self.ignored_rule_codes.is_empty()
}
}
#[cfg(test)]
#[path = "ignore_file_tests_issue_85.rs"]
mod tests_extracted;