use crate::cache::Cache;
use crate::config::Config;
use crate::language_rules::LanguageRuleSet;
use crate::output::{LintMessage, LintResult, Severity};
use crate::rules::{Rule, RuleSet};
use anyhow::{Context, Result};
use ignore::WalkBuilder;
use ignore::gitignore::GitignoreBuilder;
use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct SuppressionDirective {
start_line: usize,
end_line: usize,
rule: Option<String>,
is_block: bool,
}
struct DirConfig {
config: Config,
rule_set: RuleSet,
language_rule_set: LanguageRuleSet,
gitignore: Option<ignore::gitignore::Gitignore>,
ignore_names: std::collections::HashSet<String>,
}
pub struct Linter {
config: Config,
rule_set: RuleSet,
language_rule_set: LanguageRuleSet,
cache: Option<Arc<Mutex<Cache>>>,
gitignore: Option<ignore::gitignore::Gitignore>,
ignore_names: std::collections::HashSet<String>,
per_dir_configs: std::collections::HashMap<PathBuf, DirConfig>,
profile: bool,
rule_times: Arc<Mutex<HashMap<String, std::time::Duration>>>,
file_times: Arc<Mutex<Vec<(PathBuf, std::time::Duration)>>>,
}
impl Linter {
pub fn new(config: &Config) -> Self {
Self::new_with_cache(config, None)
}
pub fn new_with_cache(config: &Config, cache: Option<Arc<Mutex<Cache>>>) -> Self {
Self::new_with_per_dir_configs(config, cache, HashMap::new())
}
pub fn new_with_per_dir_configs(
config: &Config,
cache: Option<Arc<Mutex<Cache>>>,
per_dir_configs: std::collections::HashMap<PathBuf, Config>,
) -> Self {
let (rule_set, language_rule_set, gitignore, ignore_names) =
Self::build_from_config(config);
let mut per_dir = HashMap::new();
for (dir, cfg) in per_dir_configs {
let (rs, lrs, gi, ign) = Self::build_from_config(&cfg);
per_dir.insert(
dir,
DirConfig {
config: cfg,
rule_set: rs,
language_rule_set: lrs,
gitignore: gi,
ignore_names: ign,
},
);
}
Self {
config: config.clone(),
rule_set,
language_rule_set,
cache,
gitignore,
ignore_names,
per_dir_configs: per_dir,
profile: false,
rule_times: Arc::new(Mutex::new(HashMap::new())),
file_times: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn rule_set(&self) -> &RuleSet {
&self.rule_set
}
pub fn language_rule_set(&self) -> &LanguageRuleSet {
&self.language_rule_set
}
pub fn set_profile(&mut self, enabled: bool) {
self.profile = enabled;
}
pub fn rule_times(&self) -> HashMap<String, Duration> {
self.rule_times.lock().unwrap().clone()
}
pub fn file_times(&self) -> Vec<(PathBuf, Duration)> {
self.file_times.lock().unwrap().clone()
}
fn build_from_config(
config: &Config,
) -> (
RuleSet,
LanguageRuleSet,
Option<ignore::gitignore::Gitignore>,
std::collections::HashSet<String>,
) {
let gitignore = if config.no_gitignore {
None
} else {
let mut builder = GitignoreBuilder::new(".");
builder.add(".gitignore");
builder.build().ok()
};
let ignore_names: std::collections::HashSet<String> =
config.ignore_patterns.iter().cloned().collect();
let mut enabled: std::collections::HashSet<String> =
config.rule_set.enabled_rules.iter().cloned().collect();
for plugin in &config.plugins {
for rule_name in crate::rules::plugin_rules(plugin) {
enabled.insert(rule_name);
}
}
let mut rule_set = RuleSet::new();
let rules = vec![
Box::new(crate::rules::LineLengthRule {
max_length: config.max_line_length.unwrap_or(100),
}) as Box<dyn Rule>,
Box::new(crate::rules::TrailingWhitespaceRule) as Box<dyn Rule>,
Box::new(crate::rules::NoTodoRule::default()) as Box<dyn Rule>,
Box::new(crate::rules::NoEmptyFileRule) as Box<dyn Rule>,
Box::new(crate::rules::NoConsecutiveEmptyLinesRule) as Box<dyn Rule>,
Box::new(crate::rules::NoTabsRule) as Box<dyn Rule>,
Box::new(crate::rules::FinalNewlineRule) as Box<dyn Rule>,
Box::new(crate::rules::NoMixedLineEndingsRule) as Box<dyn Rule>,
Box::new(crate::rules::HardcodedSecretRule) as Box<dyn Rule>,
Box::new(crate::rules::UnsafeEvalRule) as Box<dyn Rule>,
Box::new(crate::rules::SqlInjectionRiskRule) as Box<dyn Rule>,
Box::new(crate::rules::MaxNestingDepthRule {
max_depth: config.max_nesting_depth.unwrap_or(4),
}) as Box<dyn Rule>,
Box::new(crate::rules::MaxFunctionLinesRule {
max_lines: config.max_function_lines.unwrap_or(50),
}) as Box<dyn Rule>,
Box::new(crate::rules::SortImportsRule) as Box<dyn Rule>,
];
for rule in rules {
if enabled.contains(rule.name()) {
rule_set = rule_set.add_rule(rule);
}
}
if let Some(ref custom_path) = config.rule_set.custom_rules_path
&& let Ok(content) = fs::read_to_string(custom_path)
&& let Ok(definitions) =
serde_json::from_str::<Vec<crate::rules::CustomRuleDefinition>>(&content)
{
for def in definitions {
if let Ok(rule) = crate::rules::CustomRule::from_definition(def)
&& enabled.contains(rule.name())
{
rule_set = rule_set.add_rule(Box::new(rule));
}
}
}
let language_rule_set = if config.plugins.is_empty() {
LanguageRuleSet::new()
} else {
LanguageRuleSet::new_filtered(&enabled)
};
(rule_set, language_rule_set, gitignore, ignore_names)
}
fn effective_config(&self, path: &Path) -> &Config {
if let Some(parent) = path.parent()
&& let Some(dir_cfg) = self.per_dir_configs.get(parent)
{
return &dir_cfg.config;
}
&self.config
}
fn effective_rule_sets(&self, path: &Path) -> (&RuleSet, &LanguageRuleSet) {
if let Some(parent) = path.parent()
&& let Some(dir_cfg) = self.per_dir_configs.get(parent)
{
return (&dir_cfg.rule_set, &dir_cfg.language_rule_set);
}
(&self.rule_set, &self.language_rule_set)
}
fn is_ignored_for_path(&self, path: &Path, dir_cfg: Option<&DirConfig>) -> bool {
let ignore_names = dir_cfg
.map(|d| &d.ignore_names)
.unwrap_or(&self.ignore_names);
for component in path.components() {
if let Some(name) = component.as_os_str().to_str()
&& ignore_names.contains(name)
{
return true;
}
}
let gitignore = dir_cfg
.and_then(|d| d.gitignore.as_ref())
.or(self.gitignore.as_ref());
if let Some(gi) = gitignore {
let is_dir = path.is_dir();
if gi.matched(path, is_dir).is_ignore() {
return true;
}
}
false
}
pub fn list_files(&self) -> Vec<PathBuf> {
let mut files = Vec::new();
for path in &self.config.paths {
if path.is_file() && self.should_lint_file(path) && !self.is_ignored(path) {
files.push(path.to_path_buf());
} else if path.is_dir() {
let walker = WalkBuilder::new(path)
.git_ignore(!self.config.no_gitignore)
.build();
for entry in walker.flatten() {
let entry_path = entry.path();
if self.is_ignored(entry_path) {
continue;
}
if entry_path.is_file() && self.should_lint_file(entry_path) {
files.push(entry_path.to_path_buf());
}
}
}
}
files
}
pub fn run(&self) -> Result<Vec<LintResult>> {
let path_results: Vec<Result<Vec<LintResult>>> = self
.config
.paths
.par_iter()
.map(|path| self.lint_path(path))
.collect();
let mut results = Vec::new();
for pr in path_results {
results.extend(pr?);
}
Ok(results)
}
fn lint_path(&self, path: &Path) -> Result<Vec<LintResult>> {
let mut results = Vec::new();
let dir_cfg = path.parent().and_then(|p| self.per_dir_configs.get(p));
if path.is_file() {
if self.config.force_exclude && self.is_ignored_for_path(path, dir_cfg) {
return Ok(results);
}
let result = self.lint_file(path)?;
results.push(result);
} else if path.is_dir() {
let dir_results = self.lint_directory(path)?;
results.extend(dir_results);
}
Ok(results)
}
fn lint_directory(&self, dir: &Path) -> Result<Vec<LintResult>> {
let mut results = Vec::new();
let walker = WalkBuilder::new(dir)
.git_ignore(!self.config.no_gitignore)
.build();
for entry in walker {
match entry {
Ok(entry) => {
let path = entry.path();
let dir_cfg = path.parent().and_then(|p| self.per_dir_configs.get(p));
if self.is_ignored_for_path(path, dir_cfg) {
continue;
}
if path.is_file()
&& self.should_lint_file(path)
&& let Ok(result) = self.lint_file(path)
{
results.push(result);
}
}
Err(e) => {
eprintln!("Warning: Could not read entry: {}", e);
}
}
}
Ok(results)
}
fn is_ignored(&self, path: &Path) -> bool {
self.is_ignored_for_path(path, None)
}
fn effective_path<'a>(&'a self, path: &'a Path, config: &'a Config) -> &'a Path {
if let Some(ref stdin_path) = config.stdin_file_path
&& path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f.starts_with("lint_stdin_"))
{
Path::new(stdin_path.as_str())
} else {
path
}
}
fn lint_file(&self, path: &Path) -> Result<LintResult> {
let config = self.effective_config(path);
let (_rule_set, _language_rule_set) = self.effective_rule_sets(path);
let use_content_hash = config.cache_strategy == crate::config::CacheStrategy::Content;
let config_hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
let mut enabled = config.rule_set.enabled_rules.clone();
enabled.sort();
enabled.hash(&mut hasher);
config.max_line_length.hash(&mut hasher);
config.max_nesting_depth.hash(&mut hasher);
config.max_function_lines.hash(&mut hasher);
let mut overrides: Vec<(&String, &crate::output::Severity)> =
config.severity_overrides.iter().collect();
overrides.sort_by_key(|(k, _)| *k);
overrides.hash(&mut hasher);
config.ignore_suppressions.hash(&mut hasher);
format!("{:x}", hasher.finish())
};
let metadata = fs::metadata(path);
let cache_key = metadata.as_ref().ok().and_then(|m| {
let mtime = m
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let size = m.len();
Some((mtime, size))
});
if let Some((mtime, size)) = cache_key
&& let Some(ref cache) = self.cache
{
let cache_guard = cache.lock().unwrap();
if let Some(cached_messages) = cache_guard.get(path, mtime, size, Some(&config_hash)) {
let mut result = LintResult::new(
self.effective_path(path, config).to_path_buf(),
String::new(),
);
result.messages = cached_messages.clone();
return Ok(result);
}
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
if use_content_hash && let Some(ref cache) = self.cache {
let hash = format!("{:x}", {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
});
let cache_guard = cache.lock().unwrap();
if let Some(cached_messages) = cache_guard.get_by_hash(path, &hash, Some(&config_hash))
{
let mut result =
LintResult::new(self.effective_path(path, config).to_path_buf(), content);
result.messages = cached_messages.clone();
return Ok(result);
}
}
let lint_start = Instant::now();
let result = self.lint_content(&content, path)?;
if self.profile {
let elapsed = lint_start.elapsed();
self.file_times
.lock()
.unwrap()
.push((path.to_path_buf(), elapsed));
}
if !use_content_hash {
if let Some((mtime, size)) = cache_key
&& let Some(ref cache) = self.cache
{
let mut cache_guard = cache.lock().unwrap();
cache_guard.insert(
path.to_path_buf(),
mtime,
size,
Some(config_hash.clone()),
result.messages.clone(),
);
}
} else if let Some(ref cache) = self.cache {
let hash = format!("{:x}", {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
result.file_content.hash(&mut hasher);
hasher.finish()
});
let mut cache_guard = cache.lock().unwrap();
cache_guard.insert_with_hash(
path.to_path_buf(),
hash,
Some(config_hash.clone()),
result.messages.clone(),
);
}
Ok(result)
}
pub fn lint_content(&self, content: &str, path: &Path) -> Result<LintResult> {
let config = self.effective_config(path);
let (rule_set, language_rule_set) = self.effective_rule_sets(path);
let mut result = LintResult::new(
self.effective_path(path, config).to_path_buf(),
content.to_string(),
);
let file_level_ignored = Self::parse_file_level_ignore(&result.file_content);
if file_level_ignored
.as_ref()
.is_some_and(|rules| rules.is_empty())
{
return Ok(result);
}
let is_file_ignored = |rule_name: &str| {
let code = crate::rules::code_from_name(rule_name);
file_level_ignored.as_ref().is_some_and(|rules| {
rules.contains(&rule_name.to_string()) || rules.contains(&code.to_string())
})
};
for rule in rule_set.get_rules() {
if !config.ignore_suppressions && is_file_ignored(rule.name()) {
continue;
}
let rule_start = Instant::now();
let messages = rule.check(&result.file_content, path);
if self.profile {
let elapsed = rule_start.elapsed();
let mut times = self.rule_times.lock().unwrap();
let entry = times
.entry(rule.name().to_string())
.or_insert(Duration::ZERO);
*entry += elapsed;
}
for message in messages {
result.add_message(message);
}
}
let lang_start = Instant::now();
let language_messages = language_rule_set.check(&result.file_content, path);
if self.profile {
let elapsed = lang_start.elapsed();
let mut times = self.rule_times.lock().unwrap();
let entry = times
.entry("__language_rules__".to_string())
.or_insert(Duration::ZERO);
*entry += elapsed;
}
for message in language_messages {
if !config.ignore_suppressions && is_file_ignored(&message.rule) {
continue;
}
result.add_message(message);
}
if !config.ignore_suppressions {
let lines: Vec<&str> = result.file_content.lines().collect();
let directives = Self::parse_suppression_directives(&lines);
let disabled_by_line = Self::parse_block_suppressions(&lines);
let check_unused = config
.rule_set
.enabled_rules
.contains(&"unused-suppression".to_string());
let mut unused_suppressions = Vec::new();
if check_unused {
for d in &directives {
let used = if d.is_block {
result.messages.iter().any(|msg| {
let line_idx = msg.line.saturating_sub(1);
let msg_code = crate::rules::code_from_name(&msg.rule);
line_idx >= d.start_line
&& line_idx < d.end_line
&& (d.rule.is_none()
|| d.rule
.as_ref()
.is_some_and(|r| r == &msg.rule || r == msg_code))
})
} else {
result.messages.iter().any(|msg| {
let msg_code = crate::rules::code_from_name(&msg.rule);
msg.line.saturating_sub(1) == d.start_line
&& (d.rule.is_none()
|| d.rule
.as_ref()
.is_some_and(|r| r == &msg.rule || r == msg_code))
})
};
if !used {
unused_suppressions.push(d);
}
}
}
result.messages.retain(|msg| {
if let Some(line) = lines.get(msg.line.saturating_sub(1)) {
let inline_suppressed = Self::is_line_suppressed(line, &msg.rule);
let rule_code = crate::rules::code_from_name(&msg.rule);
let block_suppressed = disabled_by_line
.get(&msg.line.saturating_sub(1))
.is_some_and(|disabled| {
disabled.is_empty()
|| disabled.contains(&msg.rule)
|| disabled.contains(rule_code)
});
!(inline_suppressed || block_suppressed)
} else {
true
}
});
for d in unused_suppressions {
let message = if let Some(ref rule) = d.rule {
format!("Unused suppression comment: `lint: ignore={}`", rule)
} else {
"Unused suppression comment: `lint: ignore`".to_string()
};
result.add_message(LintMessage::new(
d.start_line + 1,
1,
Severity::Warning,
message,
"unused-suppression".to_string(),
Some(
"Remove this suppression comment or fix the underlying issue.".to_string(),
),
));
}
}
if !config.per_file_ignores.is_empty() {
let path_str = self.effective_path(path, config).to_string_lossy();
let mut ignored_rules: Vec<&str> = Vec::new();
for (pattern, rules) in &config.per_file_ignores {
if let Ok(glob_pattern) = glob::Pattern::new(pattern)
&& glob_pattern.matches(&path_str)
{
ignored_rules.extend(rules.iter().map(|r| r.as_str()));
}
}
if !ignored_rules.is_empty() {
result
.messages
.retain(|msg| !ignored_rules.contains(&msg.rule.as_str()));
}
}
if !config.severity_overrides.is_empty() {
for msg in &mut result.messages {
if let Some(severity) = config.severity_overrides.get(&msg.rule) {
msg.severity = *severity;
}
}
}
Ok(result)
}
fn is_line_suppressed(line: &str, rule_name: &str) -> bool {
let rule_code = crate::rules::code_from_name(rule_name);
if let Some(pos) = line.find("lint: ignore") {
let after = &line[pos + "lint: ignore".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
return true;
}
if let Some(stripped) = after.strip_prefix('=') {
let items: Vec<&str> = stripped.split(',').map(|s| s.trim()).collect();
return items.contains(&rule_name) || items.contains(&rule_code);
}
}
false
}
fn parse_block_suppressions(lines: &[&str]) -> HashMap<usize, HashSet<String>> {
let mut result = HashMap::new();
let mut all_disabled = false;
let mut disabled_rules: HashSet<String> = HashSet::new();
for (i, line) in lines.iter().enumerate() {
if let Some(pos) = line.find("lint: disable") {
let after = &line[pos + "lint: disable".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
all_disabled = true;
disabled_rules.clear();
} else if let Some(stripped) = after.strip_prefix('=') {
for rule in stripped.split(',').map(|s| s.trim()) {
let rule = rule.to_string();
if !all_disabled {
disabled_rules.insert(rule);
}
}
}
} else if let Some(pos) = line.find("lint: enable") {
let after = &line[pos + "lint: enable".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
all_disabled = false;
disabled_rules.clear();
} else if let Some(stripped) = after.strip_prefix('=') {
for rule in stripped.split(',').map(|s| s.trim()) {
let rule = rule.to_string();
if !all_disabled {
disabled_rules.remove(&rule);
}
}
}
}
if all_disabled {
let _ = result.insert(i, HashSet::new());
} else if !disabled_rules.is_empty() {
let _ = result.insert(i, disabled_rules.clone());
}
}
result
}
fn parse_file_level_ignore(content: &str) -> Option<Vec<String>> {
let first_line = content.lines().next()?;
let pos = first_line.find("lint: ignore-file")?;
let after = &first_line[pos + "lint: ignore-file".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
return Some(Vec::new());
}
if let Some(stripped) = after.strip_prefix('=') {
let rules: Vec<String> = stripped.split(',').map(|s| s.trim().to_string()).collect();
return Some(rules);
}
None
}
fn parse_suppression_directives(lines: &[&str]) -> Vec<SuppressionDirective> {
let mut directives = Vec::new();
let mut block_stack: Vec<(usize, Option<String>)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if let Some(pos) = line.find("lint: ignore") {
let after = &line[pos + "lint: ignore".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
directives.push(SuppressionDirective {
start_line: i,
end_line: i + 1,
rule: None,
is_block: false,
});
} else if let Some(stripped) = after.strip_prefix('=') {
for rule in stripped.split(',').map(|s| s.trim()) {
directives.push(SuppressionDirective {
start_line: i,
end_line: i + 1,
rule: Some(rule.to_string()),
is_block: false,
});
}
} else {
continue;
}
} else if let Some(pos) = line.find("lint: disable") {
let after = &line[pos + "lint: disable".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
block_stack.push((i, None));
} else if let Some(stripped) = after.strip_prefix('=') {
for rule in stripped.split(',').map(|s| s.trim()) {
block_stack.push((i, Some(rule.to_string())));
}
} else {
continue;
}
} else if let Some(pos) = line.find("lint: enable") {
let after = &line[pos + "lint: enable".len()..];
let after = after.trim_start().trim_end_matches("*/").trim();
if after.is_empty() {
while let Some((start, _)) = block_stack.pop() {
directives.push(SuppressionDirective {
start_line: start,
end_line: i,
rule: None,
is_block: true,
});
}
} else if let Some(stripped) = after.strip_prefix('=') {
for rule_name in stripped.split(',').map(|s| s.trim()) {
if let Some(pos) = block_stack
.iter()
.rposition(|(_, r)| r.as_ref().is_some_and(|r| r == rule_name))
{
let (start, _) = block_stack.remove(pos);
directives.push(SuppressionDirective {
start_line: start,
end_line: i,
rule: Some(rule_name.to_string()),
is_block: true,
});
}
}
}
}
}
while let Some((start, rule)) = block_stack.pop() {
directives.push(SuppressionDirective {
start_line: start,
end_line: lines.len(),
rule,
is_block: true,
});
}
directives
}
fn should_lint_file(&self, path: &Path) -> bool {
if let Some(extension) = path.extension() {
let ext_str = extension.to_string_lossy();
if let Some(ref configured_exts) = self.config.ext {
configured_exts.iter().any(|ext| ext == ext_str.as_ref())
} else {
let supported_extensions = [
"rs", "js", "ts", "jsx", "tsx", "py", "java", "go", "c", "cpp", "h", "hpp",
"rb", "php", "swift", "kt", "dart", "cs", "sh", "bash", "sql", "lua", "scala",
"r", "zig", "html", "htm", "css", "scss", "sass",
];
supported_extensions
.iter()
.any(|ext| *ext == ext_str.as_ref())
}
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn test_linter_new() {
let config = ConfigBuilder::new()
.paths(vec![PathBuf::from("src")])
.build();
let linter = Linter::new(&config);
assert_eq!(linter.config.paths.len(), 1);
}
#[test]
fn test_linter_default_rules() {
let config = ConfigBuilder::new()
.paths(vec![PathBuf::from("src")])
.build();
let linter = Linter::new(&config);
assert!(!linter.rule_set.get_rules().is_empty());
}
#[test]
fn test_linter_enabled_rules() {
let config = ConfigBuilder::new()
.paths(vec![PathBuf::from("src")])
.enabled_rules(vec!["line-length".to_string()])
.build();
let linter = Linter::new(&config);
assert_eq!(linter.rule_set.get_rules().len(), 1);
}
#[test]
fn test_linter_empty_config() {
let config = ConfigBuilder::new().enabled_rules(vec![]).build();
let linter = Linter::new(&config);
assert_eq!(linter.rule_set.get_rules().len(), 0);
}
#[test]
fn test_should_lint_file_supported() {
let config = ConfigBuilder::new().build();
let linter = Linter::new(&config);
assert!(linter.should_lint_file(Path::new("test.rs")));
assert!(linter.should_lint_file(Path::new("test.js")));
assert!(linter.should_lint_file(Path::new("test.py")));
assert!(linter.should_lint_file(Path::new("test.go")));
assert!(linter.should_lint_file(Path::new("test.cs")));
assert!(linter.should_lint_file(Path::new("test.sh")));
assert!(linter.should_lint_file(Path::new("test.sql")));
assert!(linter.should_lint_file(Path::new("test.lua")));
assert!(linter.should_lint_file(Path::new("test.html")));
assert!(linter.should_lint_file(Path::new("test.css")));
assert!(linter.should_lint_file(Path::new("test.scss")));
assert!(linter.should_lint_file(Path::new("test.zig")));
}
#[test]
fn test_should_lint_file_unsupported() {
let config = ConfigBuilder::new().build();
let linter = Linter::new(&config);
assert!(!linter.should_lint_file(Path::new("test.txt")));
assert!(!linter.should_lint_file(Path::new("test.md")));
assert!(!linter.should_lint_file(Path::new("test.json")));
}
#[test]
fn test_should_lint_file_no_extension() {
let config = ConfigBuilder::new().build();
let linter = Linter::new(&config);
assert!(!linter.should_lint_file(Path::new("Makefile")));
assert!(!linter.should_lint_file(Path::new(".gitignore")));
}
#[test]
fn test_is_ignored_exact_match() {
let config = ConfigBuilder::new()
.ignore_patterns(vec!["node_modules".to_string(), ".git".to_string()])
.build();
let linter = Linter::new(&config);
assert!(linter.is_ignored(Path::new("node_modules/package.json")));
assert!(linter.is_ignored(Path::new("src/node_modules/package.json")));
assert!(linter.is_ignored(Path::new(".git/config")));
assert!(linter.is_ignored(Path::new("project/.git/HEAD")));
assert!(!linter.is_ignored(Path::new("src/main.rs")));
assert!(!linter.is_ignored(Path::new("lib/git.rs")));
assert!(!linter.is_ignored(Path::new("node_modules_info.txt")));
}
#[test]
fn test_lint_file_content() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "fn main() {\n println!(\"Hello\");\n}";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.file_path, file.path());
assert_eq!(result.file_content, content);
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_lint_file_with_issues() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content =
"fn main() {\n let very_long_line_that_exceeds_default_maximum_length = 42;\n}";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.max_line_length(Some(50))
.enabled_rules(vec!["line-length".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.file_path, file.path());
assert!(!result.messages.is_empty());
assert!(result.has_warnings());
Ok(())
}
#[test]
fn test_suppression_specific_rule() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=trailing-whitespace\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_suppression_all_rules() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let very_long_line_that_exceeds_the_default_maximum_length_of_one_hundred_characters = 42; // lint: ignore\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.max_line_length(Some(50))
.enabled_rules(vec!["line-length".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_suppression_only_affects_own_line() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=trailing-whitespace\nlet y = 10; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].line, 2);
Ok(())
}
#[test]
fn test_is_line_suppressed_unit() {
assert!(Linter::is_line_suppressed(
"let x = 5; // lint: ignore=line-length",
"line-length"
));
assert!(!Linter::is_line_suppressed(
"let x = 5; // lint: ignore=trailing-whitespace",
"line-length"
));
assert!(Linter::is_line_suppressed(
"let x = 5; // lint: ignore",
"any-rule"
));
assert!(!Linter::is_line_suppressed("let x = 5;", "line-length"));
}
#[test]
fn test_per_file_ignore_filters_rule() -> anyhow::Result<()> {
use std::collections::HashMap;
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let mut per_file_ignores = HashMap::new();
per_file_ignores.insert("*.rs".to_string(), vec!["trailing-whitespace".to_string()]);
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.per_file_ignores(per_file_ignores)
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_per_file_ignore_does_not_affect_other_files() -> anyhow::Result<()> {
use std::collections::HashMap;
let mut file = NamedTempFile::new()?;
let content = "let x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let mut per_file_ignores = HashMap::new();
per_file_ignores.insert("*.py".to_string(), vec!["trailing-whitespace".to_string()]);
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.per_file_ignores(per_file_ignores)
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(!result.messages.is_empty());
Ok(())
}
#[test]
fn test_severity_override_changes_severity() -> anyhow::Result<()> {
use crate::output::Severity;
use std::collections::HashMap;
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let mut overrides = HashMap::new();
overrides.insert("trailing-whitespace".to_string(), Severity::Error);
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.severity_overrides(overrides)
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].severity, Severity::Error);
Ok(())
}
#[test]
fn test_severity_override_only_affects_matching_rule() -> anyhow::Result<()> {
use crate::output::Severity;
use std::collections::HashMap;
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let mut overrides = HashMap::new();
overrides.insert("line-length".to_string(), Severity::Error);
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.severity_overrides(overrides)
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].severity, Severity::Warning);
Ok(())
}
#[test]
fn test_block_suppression_disable_enable_rule() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n// lint: disable=trailing-whitespace\nlet y = 10; \nlet z = 20; \n// lint: enable=trailing-whitespace\nlet w = 30; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
let lines: Vec<_> = result.messages.iter().map(|m| m.line).collect();
assert!(lines.contains(&1));
assert!(!lines.contains(&3));
assert!(!lines.contains(&4));
assert!(lines.contains(&6));
Ok(())
}
#[test]
fn test_block_suppression_disable_all_enable_all() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n// lint: disable\nlet y = 10; \nlet z = 20; \n// lint: enable\nlet w = 30; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
let lines: Vec<_> = result.messages.iter().map(|m| m.line).collect();
assert!(lines.contains(&1));
assert!(!lines.contains(&3));
assert!(!lines.contains(&4));
assert!(lines.contains(&6));
Ok(())
}
#[test]
fn test_block_suppression_with_inline_ignore() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "// lint: disable=trailing-whitespace\nlet x = 5; // lint: ignore=trailing-whitespace\nlet y = 10; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
let tw_messages: Vec<_> = result
.messages
.iter()
.filter(|m| m.rule == "trailing-whitespace")
.collect();
assert!(
tw_messages.is_empty(),
"trailing-whitespace should be fully suppressed"
);
Ok(())
}
#[test]
fn test_file_level_ignore_all_rules() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "// lint: ignore-file\nlet x = 5; \nlet y = 10; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_file_level_ignore_specific_rule() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "// lint: ignore-file=trailing-whitespace\nlet x = 5; \nTODO: fix this\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"trailing-whitespace".to_string(),
"no-todo".to_string(),
])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result
.messages
.iter()
.all(|m| m.rule != "trailing-whitespace")
);
assert!(result.messages.iter().any(|m| m.rule == "no-todo"));
Ok(())
}
#[test]
fn test_file_level_ignore_not_on_first_line_is_ignored() -> anyhow::Result<()> {
let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?;
let content = "let x = 5; \n// lint: ignore-file\nlet y = 10; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(!result.messages.is_empty());
Ok(())
}
#[test]
fn test_unused_inline_suppression_detected() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=trailing-whitespace\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"trailing-whitespace".to_string(),
"unused-suppression".to_string(),
])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].rule, "unused-suppression");
assert!(result.messages[0].message.contains("trailing-whitespace"));
Ok(())
}
#[test]
fn test_used_inline_suppression_not_reported() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=trailing-whitespace \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"trailing-whitespace".to_string(),
"unused-suppression".to_string(),
])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_unused_block_suppression_detected() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "// lint: disable=trailing-whitespace\nlet x = 5;\n// lint: enable=trailing-whitespace\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"trailing-whitespace".to_string(),
"unused-suppression".to_string(),
])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].rule, "unused-suppression");
Ok(())
}
#[test]
fn test_used_block_suppression_not_reported() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "// lint: disable=trailing-whitespace\nlet x = 5; \n// lint: enable=trailing-whitespace\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"trailing-whitespace".to_string(),
"unused-suppression".to_string(),
])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(result.messages.is_empty());
Ok(())
}
#[test]
fn test_gitignore_respected() -> anyhow::Result<()> {
use std::io::Write;
let temp_dir = tempfile::tempdir()?;
let gitignore_path = temp_dir.path().join(".gitignore");
let mut gitignore = std::fs::File::create(&gitignore_path)?;
gitignore.write_all(b"ignored.rs\n")?;
let ignored_file = temp_dir.path().join("ignored.rs");
std::fs::write(&ignored_file, "let x = 5; \n")?;
let kept_file = temp_dir.path().join("kept.rs");
std::fs::write(&kept_file, "let x = 5; \n")?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(temp_dir.path())?;
let config = ConfigBuilder::new()
.paths(vec![temp_dir.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let files = linter.list_files();
std::env::set_current_dir(original_dir)?;
assert!(
files
.iter()
.any(|p| p.file_name() == Some(std::ffi::OsStr::new("kept.rs")))
);
assert!(
!files
.iter()
.any(|p| p.file_name() == Some(std::ffi::OsStr::new("ignored.rs")))
);
Ok(())
}
#[test]
fn test_effective_path_uses_stdin_file_path() {
let config = ConfigBuilder::new()
.stdin_file_path(Some("src/main.rs".to_string()))
.build();
let linter = Linter::new(&config);
let temp_path = Path::new("/tmp/lint_stdin_12345.rs");
assert_eq!(
linter.effective_path(temp_path, &config),
Path::new("src/main.rs")
);
let normal_path = Path::new("/tmp/other.rs");
assert_eq!(linter.effective_path(normal_path, &config), normal_path);
}
#[test]
fn test_stdin_file_path_per_file_ignores() -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?;
let stdin_temp = temp_dir.path().join("lint_stdin_12345.rs");
std::fs::write(&stdin_temp, "let x = 5; \n")?;
let mut per_file_ignores = HashMap::new();
per_file_ignores.insert(
"src/**/*.rs".to_string(),
vec!["trailing-whitespace".to_string()],
);
let config = ConfigBuilder::new()
.paths(vec![stdin_temp.clone()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.per_file_ignores(per_file_ignores)
.stdin_file_path(Some("src/main.rs".to_string()))
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(&stdin_temp)?;
assert!(
result.messages.is_empty(),
"per-file ignores should match stdin_file_path"
);
assert_eq!(result.file_path, PathBuf::from("src/main.rs"));
Ok(())
}
#[test]
fn test_per_dir_config_overrides_max_line_length() -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?;
let root_file = temp_dir.path().join("root.rs");
std::fs::write(
&root_file,
"let x = \"this is an extremely long string that definitely exceeds the default one hundred character limit\";\n",
)?;
let sub_dir = temp_dir.path().join("src");
std::fs::create_dir(&sub_dir)?;
let sub_file = sub_dir.join("sub.rs");
std::fs::write(
&sub_file,
"let x = \"this is an extremely long string that definitely exceeds the default one hundred character limit\";\n",
)?;
let base_config = ConfigBuilder::new()
.paths(vec![temp_dir.path().to_path_buf()])
.enabled_rules(vec!["line-length".to_string()])
.build();
let mut per_dir = std::collections::HashMap::new();
let sub_cfg = ConfigBuilder::new().max_line_length(Some(200)).build();
per_dir.insert(sub_dir, sub_cfg);
let linter = Linter::new_with_per_dir_configs(&base_config, None, per_dir);
let results = linter.run()?;
let root_result = results
.iter()
.find(|r| r.file_path.ends_with("root.rs"))
.unwrap();
let sub_result = results
.iter()
.find(|r| r.file_path.ends_with("sub.rs"))
.unwrap();
assert!(root_result.messages.iter().any(|m| m.rule == "line-length"));
assert!(!sub_result.messages.iter().any(|m| m.rule == "line-length"));
Ok(())
}
#[test]
fn test_per_dir_config_overrides_severity() -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?;
let root_file = temp_dir.path().join("root.rs");
std::fs::write(&root_file, "")?;
let sub_dir = temp_dir.path().join("src");
std::fs::create_dir(&sub_dir)?;
let sub_file = sub_dir.join("sub.rs");
std::fs::write(&sub_file, "")?;
let base_config = ConfigBuilder::new()
.paths(vec![temp_dir.path().to_path_buf()])
.enabled_rules(vec!["no-empty-file".to_string()])
.build();
let mut per_dir = std::collections::HashMap::new();
let mut sub_cfg = ConfigBuilder::new()
.enabled_rules(vec!["no-empty-file".to_string()])
.build();
sub_cfg
.severity_overrides
.insert("no-empty-file".to_string(), Severity::Error);
per_dir.insert(sub_dir.clone(), sub_cfg);
let linter = Linter::new_with_per_dir_configs(&base_config, None, per_dir);
let results = linter.run()?;
let root_result = results
.iter()
.find(|r| r.file_path.ends_with("root.rs"))
.unwrap();
let sub_result = results
.iter()
.find(|r| r.file_path.ends_with("sub.rs"))
.unwrap();
assert!(
root_result
.messages
.iter()
.any(|m| m.rule == "no-empty-file" && m.severity == Severity::Warning)
);
assert!(
sub_result
.messages
.iter()
.any(|m| m.rule == "no-empty-file" && m.severity == Severity::Error)
);
Ok(())
}
#[test]
fn test_suppression_by_code_works() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=W002\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"W002 should suppress trailing-whitespace"
);
Ok(())
}
#[test]
fn test_block_suppression_by_code_works() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "// lint: disable=W002\nlet x = 5; \n// lint: enable=W002\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"W002 block suppression should work"
);
Ok(())
}
#[test]
fn test_file_level_ignore_by_code_works() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "// lint: ignore-file=W002\nlet x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"W002 file-level ignore should work"
);
Ok(())
}
#[test]
fn test_block_comment_inline_suppression() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; /* lint: ignore=W002 */\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"/* lint: ignore=W002 */ should suppress trailing-whitespace"
);
Ok(())
}
#[test]
fn test_block_comment_file_level_ignore() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "/* lint: ignore-file=W002 */\nlet x = 5; \n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"/* lint: ignore-file=W002 */ should suppress trailing-whitespace"
);
Ok(())
}
#[test]
fn test_block_comment_block_suppression() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "/* lint: disable=W002 */\nlet x = 5; \n/* lint: enable=W002 */\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec!["trailing-whitespace".to_string()])
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"/* lint: disable=W002 */ block suppression should work"
);
Ok(())
}
#[test]
fn test_multiple_inline_ignore_patterns() -> anyhow::Result<()> {
let mut file = NamedTempFile::new()?;
let content = "let x = 5; // lint: ignore=W001,W002\n";
file.write_all(content.as_bytes())?;
file.flush()?;
let config = ConfigBuilder::new()
.paths(vec![file.path().to_path_buf()])
.enabled_rules(vec![
"line-length".to_string(),
"trailing-whitespace".to_string(),
])
.max_line_length(Some(5))
.build();
let linter = Linter::new(&config);
let result = linter.lint_file(file.path())?;
assert!(
result.messages.is_empty(),
"W001,W002 should suppress both line-length and trailing-whitespace"
);
Ok(())
}
}