use std::collections::BTreeSet;
use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
use diffguard_types::{MatchMode, RuleConfig, Severity};
#[derive(Debug, thiserror::Error)]
pub enum RuleCompileError {
#[error("rule '{rule_id}' has no patterns")]
MissingPatterns { rule_id: String },
#[error("rule '{rule_id}' has invalid regex '{pattern}': {source}")]
InvalidRegex {
rule_id: String,
pattern: String,
source: regex::Error,
},
#[error("rule '{rule_id}' has invalid glob '{glob}': {source}")]
InvalidGlob {
rule_id: String,
glob: String,
source: globset::Error,
},
#[error("rule '{rule_id}' has invalid multiline_window '{value}' (must be >= 2)")]
InvalidMultilineWindow { rule_id: String, value: u32 },
#[error("rule '{rule_id}' depends on unknown rule '{dependency}'")]
UnknownDependency { rule_id: String, dependency: String },
}
#[derive(Debug, Clone)]
pub struct CompiledRule {
pub id: String,
pub severity: Severity,
pub message: String,
pub languages: BTreeSet<String>,
pub patterns: Vec<Regex>,
pub include: Option<GlobSet>,
pub exclude: Option<GlobSet>,
pub ignore_comments: bool,
pub ignore_strings: bool,
pub match_mode: MatchMode,
pub multiline: bool,
pub multiline_window: usize,
pub context_patterns: Vec<Regex>,
pub context_window: usize,
pub escalate_patterns: Vec<Regex>,
pub escalate_window: usize,
pub escalate_to: Option<Severity>,
pub depends_on: BTreeSet<String>,
}
impl CompiledRule {
pub fn applies_to(&self, path: &Path, language: Option<&str>) -> bool {
if self
.include
.as_ref()
.is_some_and(|include| !include.is_match(path))
{
return false;
}
if self
.exclude
.as_ref()
.is_some_and(|exclude| exclude.is_match(path))
{
return false;
}
if !self.languages.is_empty() {
let Some(lang) = language else {
return false;
};
if !self.languages.contains(&lang.to_ascii_lowercase()) {
return false;
}
}
true
}
}
pub fn compile_rules(configs: &[RuleConfig]) -> Result<Vec<CompiledRule>, RuleCompileError> {
let mut out = Vec::with_capacity(configs.len());
let known_rule_ids = configs
.iter()
.map(|cfg| cfg.id.clone())
.collect::<BTreeSet<_>>();
for cfg in configs {
if cfg.patterns.is_empty() {
return Err(RuleCompileError::MissingPatterns {
rule_id: cfg.id.clone(),
});
}
let patterns = compile_pattern_group(&cfg.id, &cfg.patterns)?;
let context_patterns = compile_pattern_group(&cfg.id, &cfg.context_patterns)?;
let escalate_patterns = compile_pattern_group(&cfg.id, &cfg.escalate_patterns)?;
let include = compile_globs(&cfg.paths, &cfg.id)?;
let exclude = compile_globs(&cfg.exclude_paths, &cfg.id)?;
let languages = cfg
.languages
.iter()
.map(|s| s.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
if cfg.multiline
&& let Some(window) = cfg.multiline_window
&& window < 2
{
return Err(RuleCompileError::InvalidMultilineWindow {
rule_id: cfg.id.clone(),
value: window,
});
}
for dependency in &cfg.depends_on {
if !known_rule_ids.contains(dependency) {
return Err(RuleCompileError::UnknownDependency {
rule_id: cfg.id.clone(),
dependency: dependency.clone(),
});
}
}
out.push(CompiledRule {
id: cfg.id.clone(),
severity: cfg.severity,
message: cfg.message.clone(),
languages,
patterns,
include,
exclude,
ignore_comments: cfg.ignore_comments,
ignore_strings: cfg.ignore_strings,
match_mode: cfg.match_mode,
multiline: cfg.multiline,
multiline_window: cfg.multiline_window.unwrap_or(2).max(2) as usize,
context_patterns,
context_window: cfg.context_window.unwrap_or(3) as usize,
escalate_patterns,
escalate_window: cfg.escalate_window.unwrap_or(0) as usize,
escalate_to: cfg.escalate_to,
depends_on: cfg.depends_on.iter().cloned().collect(),
});
}
Ok(out)
}
fn compile_pattern_group(
rule_id: &str,
patterns: &[String],
) -> Result<Vec<Regex>, RuleCompileError> {
let mut out = Vec::with_capacity(patterns.len());
for pattern in patterns {
let compiled = Regex::new(pattern).map_err(|source| RuleCompileError::InvalidRegex {
rule_id: rule_id.to_string(),
pattern: pattern.clone(),
source,
})?;
out.push(compiled);
}
Ok(out)
}
fn compile_globs(globs: &[String], rule_id: &str) -> Result<Option<GlobSet>, RuleCompileError> {
if globs.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for g in globs {
let glob = Glob::new(g).map_err(|e| RuleCompileError::InvalidGlob {
rule_id: rule_id.to_string(),
glob: g.clone(),
source: e,
})?;
builder.add(glob);
}
Ok(Some(builder.build().expect("globset build should succeed")))
}
pub fn detect_language(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?;
match ext.to_ascii_lowercase().as_str() {
"rs" => Some("rust"),
"py" | "pyw" => Some("python"),
"js" | "mjs" | "cjs" | "jsx" => Some("javascript"),
"ts" | "mts" | "cts" | "tsx" => Some("typescript"),
"go" => Some("go"),
"java" => Some("java"),
"kt" | "kts" => Some("kotlin"),
"rb" | "rake" => Some("ruby"),
"c" | "h" => Some("c"),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some("cpp"),
"cs" => Some("csharp"),
"sh" | "bash" | "zsh" | "ksh" | "fish" => Some("shell"),
"swift" => Some("swift"),
"scala" | "sc" => Some("scala"),
"sql" => Some("sql"),
"xml" | "xsl" | "xslt" | "xsd" | "svg" | "xhtml" => Some("xml"),
"html" | "htm" => Some("xml"),
"php" | "phtml" | "php3" | "php4" | "php5" | "php7" | "phps" => Some("php"),
"yaml" | "yml" => Some("yaml"),
"toml" => Some("toml"),
"json" | "jsonc" | "json5" => Some("json"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::too_many_arguments)]
fn test_rule(
id: &str,
severity: Severity,
message: &str,
languages: Vec<&str>,
patterns: Vec<&str>,
paths: Vec<&str>,
exclude_paths: Vec<&str>,
ignore_comments: bool,
ignore_strings: bool,
) -> RuleConfig {
RuleConfig {
id: id.to_string(),
severity,
message: message.to_string(),
languages: languages.into_iter().map(|s| s.to_string()).collect(),
patterns: patterns.into_iter().map(|s| s.to_string()).collect(),
paths: paths.into_iter().map(|s| s.to_string()).collect(),
exclude_paths: exclude_paths.into_iter().map(|s| s.to_string()).collect(),
ignore_comments,
ignore_strings,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
}
}
#[test]
fn compile_and_match_basic_rule() {
let cfg = test_rule(
"x",
Severity::Warn,
"m",
vec!["rust"],
vec!["unwrap"],
vec!["**/*.rs"],
vec!["**/tests/**"],
true,
true,
);
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/lib.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("src/lib.rs"), Some("python")));
assert!(!r.applies_to(Path::new("tests/test.rs"), Some("rust")));
}
#[test]
fn detect_language_rust() {
assert_eq!(detect_language(Path::new("src/lib.rs")), Some("rust"));
}
#[test]
fn detect_language_python() {
assert_eq!(detect_language(Path::new("script.py")), Some("python"));
assert_eq!(detect_language(Path::new("script.pyw")), Some("python"));
}
#[test]
fn detect_language_javascript() {
assert_eq!(detect_language(Path::new("app.js")), Some("javascript"));
assert_eq!(detect_language(Path::new("module.mjs")), Some("javascript"));
assert_eq!(detect_language(Path::new("module.cjs")), Some("javascript"));
assert_eq!(
detect_language(Path::new("component.jsx")),
Some("javascript")
);
}
#[test]
fn detect_language_typescript() {
assert_eq!(detect_language(Path::new("app.ts")), Some("typescript"));
assert_eq!(detect_language(Path::new("module.mts")), Some("typescript"));
assert_eq!(detect_language(Path::new("module.cts")), Some("typescript"));
assert_eq!(
detect_language(Path::new("component.tsx")),
Some("typescript")
);
}
#[test]
fn detect_language_go() {
assert_eq!(detect_language(Path::new("main.go")), Some("go"));
}
#[test]
fn detect_language_java() {
assert_eq!(detect_language(Path::new("Main.java")), Some("java"));
}
#[test]
fn detect_language_kotlin() {
assert_eq!(detect_language(Path::new("Main.kt")), Some("kotlin"));
assert_eq!(detect_language(Path::new("build.kts")), Some("kotlin"));
}
#[test]
fn detect_language_ruby() {
assert_eq!(detect_language(Path::new("script.rb")), Some("ruby"));
assert_eq!(detect_language(Path::new("Rakefile.rake")), Some("ruby"));
}
#[test]
fn detect_language_c() {
assert_eq!(detect_language(Path::new("main.c")), Some("c"));
assert_eq!(detect_language(Path::new("header.h")), Some("c"));
}
#[test]
fn detect_language_cpp() {
assert_eq!(detect_language(Path::new("main.cpp")), Some("cpp"));
assert_eq!(detect_language(Path::new("main.cc")), Some("cpp"));
assert_eq!(detect_language(Path::new("main.cxx")), Some("cpp"));
assert_eq!(detect_language(Path::new("header.hpp")), Some("cpp"));
assert_eq!(detect_language(Path::new("header.hxx")), Some("cpp"));
assert_eq!(detect_language(Path::new("header.hh")), Some("cpp"));
}
#[test]
fn detect_language_csharp() {
assert_eq!(detect_language(Path::new("Program.cs")), Some("csharp"));
}
#[test]
fn detect_language_shell() {
assert_eq!(detect_language(Path::new("script.sh")), Some("shell"));
assert_eq!(detect_language(Path::new("script.bash")), Some("shell"));
assert_eq!(detect_language(Path::new("script.zsh")), Some("shell"));
assert_eq!(detect_language(Path::new("script.ksh")), Some("shell"));
assert_eq!(detect_language(Path::new("script.fish")), Some("shell"));
}
#[test]
fn detect_language_unknown() {
assert_eq!(detect_language(Path::new("file.txt")), None);
assert_eq!(detect_language(Path::new("file.md")), None);
assert_eq!(detect_language(Path::new("file")), None);
}
#[test]
fn detect_language_swift() {
assert_eq!(detect_language(Path::new("app.swift")), Some("swift"));
assert_eq!(detect_language(Path::new("App.SWIFT")), Some("swift"));
}
#[test]
fn detect_language_scala() {
assert_eq!(detect_language(Path::new("app.scala")), Some("scala"));
assert_eq!(detect_language(Path::new("app.sc")), Some("scala"));
assert_eq!(detect_language(Path::new("App.SCALA")), Some("scala"));
}
#[test]
fn detect_language_sql() {
assert_eq!(detect_language(Path::new("query.sql")), Some("sql"));
assert_eq!(detect_language(Path::new("Query.SQL")), Some("sql"));
}
#[test]
fn detect_language_xml() {
assert_eq!(detect_language(Path::new("config.xml")), Some("xml"));
assert_eq!(detect_language(Path::new("style.xsl")), Some("xml"));
assert_eq!(detect_language(Path::new("transform.xslt")), Some("xml"));
assert_eq!(detect_language(Path::new("schema.xsd")), Some("xml"));
assert_eq!(detect_language(Path::new("icon.svg")), Some("xml"));
assert_eq!(detect_language(Path::new("page.xhtml")), Some("xml"));
assert_eq!(detect_language(Path::new("page.html")), Some("xml"));
assert_eq!(detect_language(Path::new("page.htm")), Some("xml"));
}
#[test]
fn detect_language_php() {
assert_eq!(detect_language(Path::new("index.php")), Some("php"));
assert_eq!(detect_language(Path::new("template.phtml")), Some("php"));
assert_eq!(detect_language(Path::new("legacy.php3")), Some("php"));
assert_eq!(detect_language(Path::new("legacy.php4")), Some("php"));
assert_eq!(detect_language(Path::new("legacy.php5")), Some("php"));
assert_eq!(detect_language(Path::new("modern.php7")), Some("php"));
assert_eq!(detect_language(Path::new("highlight.phps")), Some("php"));
}
#[test]
fn detect_language_case_insensitive() {
assert_eq!(detect_language(Path::new("file.RS")), Some("rust"));
assert_eq!(detect_language(Path::new("file.PY")), Some("python"));
assert_eq!(detect_language(Path::new("file.JS")), Some("javascript"));
assert_eq!(detect_language(Path::new("file.TS")), Some("typescript"));
assert_eq!(detect_language(Path::new("file.CPP")), Some("cpp"));
assert_eq!(detect_language(Path::new("file.JSON")), Some("json"));
assert_eq!(detect_language(Path::new("file.YAML")), Some("yaml"));
assert_eq!(detect_language(Path::new("file.TOML")), Some("toml"));
}
#[test]
fn detect_language_yaml_toml_json() {
assert_eq!(detect_language(Path::new("config.yaml")), Some("yaml"));
assert_eq!(detect_language(Path::new("config.yml")), Some("yaml"));
assert_eq!(detect_language(Path::new("config.toml")), Some("toml"));
assert_eq!(detect_language(Path::new("config.json")), Some("json"));
assert_eq!(detect_language(Path::new("config.jsonc")), Some("json"));
assert_eq!(detect_language(Path::new("config.json5")), Some("json"));
}
#[test]
fn overlapping_patterns_first_pattern_wins() {
let cfg = RuleConfig {
id: "test.overlapping".to_string(),
severity: Severity::Warn,
message: "found match".to_string(),
languages: vec![],
patterns: vec![
"foo".to_string(), "foobar".to_string(), ],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
let content = "foobar";
let m = r
.patterns
.iter()
.find_map(|p| p.find(content))
.expect("Expected a pattern to match");
assert_eq!(m.start(), 0);
assert_eq!(m.end(), 3);
assert_eq!(&content[m.start()..m.end()], "foo");
}
#[test]
fn overlapping_rules_first_rule_wins() {
let configs = vec![
RuleConfig {
id: "rule.first".to_string(),
severity: Severity::Warn,
message: "first rule".to_string(),
languages: vec![],
patterns: vec!["error".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
},
RuleConfig {
id: "rule.second".to_string(),
severity: Severity::Error,
message: "second rule".to_string(),
languages: vec![],
patterns: vec!["error".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
},
];
let rules = compile_rules(&configs).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].id, "rule.first");
assert_eq!(rules[1].id, "rule.second");
}
#[test]
fn overlapping_patterns_specific_vs_general() {
let cfg = RuleConfig {
id: "test.general_first".to_string(),
severity: Severity::Warn,
message: "found".to_string(),
languages: vec![],
patterns: vec![
r"\w+".to_string(), r"specific".to_string(), ],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
let content = "specific";
let m = r.patterns[0]
.find(content)
.expect("Expected specific pattern to match");
assert_eq!(&content[m.start()..m.end()], "specific");
}
#[test]
fn glob_pattern_recursive_wildcard() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec!["**/*.rs".to_string()],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("lib.rs"), None));
assert!(r.applies_to(Path::new("src/lib.rs"), None));
assert!(r.applies_to(Path::new("src/foo/bar/lib.rs"), None));
assert!(r.applies_to(Path::new("deeply/nested/path/to/file.rs"), None));
assert!(!r.applies_to(Path::new("src/lib.py"), None));
assert!(!r.applies_to(Path::new("src/lib.rs.bak"), None));
}
#[test]
fn glob_pattern_specific_directory() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec!["src/**/*.ts".to_string()],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/app.ts"), None));
assert!(r.applies_to(Path::new("src/components/Button.ts"), None));
assert!(r.applies_to(Path::new("src/a/b/c/d.ts"), None));
assert!(!r.applies_to(Path::new("app.ts"), None));
assert!(!r.applies_to(Path::new("lib/app.ts"), None));
assert!(!r.applies_to(Path::new("tests/app.ts"), None));
}
#[test]
fn glob_pattern_exclude_test_directories() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec!["**/*.rs".to_string()],
exclude_paths: vec!["**/test/**".to_string(), "**/tests/**".to_string()],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/lib.rs"), None));
assert!(r.applies_to(Path::new("src/foo/bar.rs"), None));
assert!(!r.applies_to(Path::new("test/lib.rs"), None));
assert!(!r.applies_to(Path::new("tests/lib.rs"), None));
assert!(!r.applies_to(Path::new("src/test/lib.rs"), None));
assert!(!r.applies_to(Path::new("src/tests/lib.rs"), None));
assert!(!r.applies_to(Path::new("foo/test/bar.rs"), None));
assert!(!r.applies_to(Path::new("foo/tests/bar.rs"), None));
}
#[test]
fn glob_pattern_multiple_extensions() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec![
"**/*.js".to_string(),
"**/*.ts".to_string(),
"**/*.jsx".to_string(),
"**/*.tsx".to_string(),
],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/app.js"), None));
assert!(r.applies_to(Path::new("src/app.ts"), None));
assert!(r.applies_to(Path::new("src/App.jsx"), None));
assert!(r.applies_to(Path::new("src/App.tsx"), None));
assert!(!r.applies_to(Path::new("src/app.py"), None));
assert!(!r.applies_to(Path::new("src/app.rs"), None));
}
#[test]
fn glob_pattern_exclude_specific_files() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec!["**/*.ts".to_string()],
exclude_paths: vec!["**/*.test.ts".to_string(), "**/*.spec.ts".to_string()],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/app.ts"), None));
assert!(r.applies_to(Path::new("src/utils/helper.ts"), None));
assert!(!r.applies_to(Path::new("src/app.test.ts"), None));
assert!(!r.applies_to(Path::new("src/app.spec.ts"), None));
assert!(!r.applies_to(Path::new("src/utils/helper.test.ts"), None));
assert!(!r.applies_to(Path::new("src/utils/helper.spec.ts"), None));
}
#[test]
fn glob_pattern_no_include_matches_all() {
let cfg = RuleConfig {
id: "test.glob".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["x".to_string()],
paths: vec![], exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("anything.txt"), None));
assert!(r.applies_to(Path::new("src/lib.rs"), None));
assert!(r.applies_to(Path::new("deeply/nested/file.py"), None));
}
#[test]
fn language_filter_empty_matches_all() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![], patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("file.rs"), Some("rust")));
assert!(r.applies_to(Path::new("file.py"), Some("python")));
assert!(r.applies_to(Path::new("file.js"), Some("javascript")));
assert!(r.applies_to(Path::new("file.txt"), None));
}
#[test]
fn language_filter_single_language() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["rust".to_string()],
patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("file.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("file.py"), Some("python")));
assert!(!r.applies_to(Path::new("file.js"), Some("javascript")));
assert!(!r.applies_to(Path::new("file.txt"), None));
}
#[test]
fn language_filter_multiple_languages() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["javascript".to_string(), "typescript".to_string()],
patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("file.js"), Some("javascript")));
assert!(r.applies_to(Path::new("file.ts"), Some("typescript")));
assert!(!r.applies_to(Path::new("file.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("file.py"), Some("python")));
}
#[test]
fn language_filter_case_insensitive() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["RUST".to_string()], patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("file.rs"), Some("rust")));
assert!(r.applies_to(Path::new("file.rs"), Some("Rust")));
assert!(r.applies_to(Path::new("file.rs"), Some("RUST")));
}
#[test]
fn language_filter_with_path_filter_combined() {
let cfg = RuleConfig {
id: "test.combined".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["rust".to_string()],
patterns: vec!["x".to_string()],
paths: vec!["src/**/*.rs".to_string()],
exclude_paths: vec!["**/tests/**".to_string()],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("src/lib.rs"), Some("rust")));
assert!(r.applies_to(Path::new("src/foo/bar.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("src/lib.rs"), Some("python")));
assert!(!r.applies_to(Path::new("lib/lib.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("src/tests/lib.rs"), Some("rust")));
assert!(!r.applies_to(Path::new("src/lib.rs"), None));
}
#[test]
fn language_filter_unknown_language_in_config() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["customlang".to_string()],
patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(r.applies_to(Path::new("file.custom"), Some("customlang")));
assert!(!r.applies_to(Path::new("file.rs"), Some("rust")));
}
#[test]
fn language_filter_none_language_with_filter() {
let cfg = RuleConfig {
id: "test.lang".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec!["rust".to_string()],
patterns: vec!["x".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let rules = compile_rules(&[cfg]).unwrap();
let r = &rules[0];
assert!(!r.applies_to(Path::new("file.txt"), None));
assert!(!r.applies_to(Path::new("Makefile"), None));
assert!(!r.applies_to(Path::new("README.md"), None));
}
#[test]
fn compile_rejects_invalid_multiline_window() {
let cfg = RuleConfig {
id: "test.multiline".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["a".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: true,
multiline_window: Some(1),
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec![],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let err = compile_rules(&[cfg]).expect_err("window < 2 should fail");
match err {
RuleCompileError::InvalidMultilineWindow { rule_id, value } => {
assert_eq!(rule_id, "test.multiline");
assert_eq!(value, 1);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn compile_rejects_unknown_dependency() {
let cfg = RuleConfig {
id: "test.dependent".to_string(),
severity: Severity::Warn,
message: "m".to_string(),
languages: vec![],
patterns: vec!["a".to_string()],
paths: vec![],
exclude_paths: vec![],
ignore_comments: false,
ignore_strings: false,
match_mode: Default::default(),
multiline: false,
multiline_window: None,
context_patterns: vec![],
context_window: None,
escalate_patterns: vec![],
escalate_window: None,
escalate_to: None,
depends_on: vec!["missing.rule".to_string()],
help: None,
url: None,
tags: vec![],
test_cases: vec![],
};
let err = compile_rules(&[cfg]).expect_err("unknown dependency should fail");
match err {
RuleCompileError::UnknownDependency {
rule_id,
dependency,
} => {
assert_eq!(rule_id, "test.dependent");
assert_eq!(dependency, "missing.rule");
}
other => panic!("unexpected error: {other:?}"),
}
}
}