use std::path::Path;
use crate::rules::{Issue, IssueCategory, IssueSource, Severity};
#[derive(serde::Deserialize, Debug)]
struct RuleFile {
#[serde(rename = "rule", default)]
rules: Vec<CustomRuleDef>,
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct CustomRuleDef {
pub id: String,
pub message: String,
pub pattern: String,
#[serde(default = "default_severity")]
pub severity: String,
#[serde(default = "default_category")]
pub category: String,
pub file_glob: Option<String>,
pub ignore_if: Option<String>,
}
fn default_severity() -> String {
"medium".to_string()
}
fn default_category() -> String {
"perf".to_string()
}
pub struct CompiledRule {
pub def: CustomRuleDef,
pattern: regex::Regex,
ignore_if: Option<regex::Regex>,
}
pub fn load_custom_rules(path: &Path) -> (Vec<CompiledRule>, Vec<String>) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
return (
vec![],
vec![format!("could not read '{}': {e}", path.display())],
);
}
};
let rule_file: RuleFile = match toml::from_str(&text) {
Ok(f) => f,
Err(e) => {
return (
vec![],
vec![format!("could not parse '{}': {e}", path.display())],
);
}
};
let mut compiled = Vec::new();
let mut errors = Vec::new();
for def in rule_file.rules {
let pattern = match regex::Regex::new(&def.pattern) {
Ok(r) => r,
Err(e) => {
errors.push(format!(
"rule '{}': invalid pattern '{}': {e}",
def.id, def.pattern
));
continue;
}
};
let ignore_if = match def.ignore_if.as_deref() {
Some(ign) => match regex::Regex::new(ign) {
Ok(r) => Some(r),
Err(e) => {
errors.push(format!(
"rule '{}': invalid ignore_if '{}': {e}",
def.id, ign
));
None
}
},
None => None,
};
compiled.push(CompiledRule {
def,
pattern,
ignore_if,
});
}
(compiled, errors)
}
pub fn run_custom_rules(rules: &[CompiledRule], source: &str, file_path: &Path) -> Vec<Issue> {
let mut issues = Vec::new();
let applicable: Vec<&CompiledRule> = rules
.iter()
.filter(|r| file_matches_glob(file_path, r.def.file_glob.as_deref()))
.collect();
if applicable.is_empty() {
return issues;
}
for (line_idx, line) in source.lines().enumerate() {
let line_no = (line_idx + 1) as u32;
for rule in &applicable {
if !rule.pattern.is_match(line) {
continue;
}
if let Some(ref ign) = rule.ignore_if {
if ign.is_match(line) {
continue;
}
}
let col = rule
.pattern
.find(line)
.map(|m| m.start() as u32 + 1)
.unwrap_or(1);
issues.push(Issue {
rule: rule.def.id.clone(),
message: rule.def.message.clone(),
file: file_path.to_path_buf(),
line: line_no,
column: col,
severity: parse_severity(&rule.def.severity),
source: IssueSource::ReactPerfAnalyzer,
category: parse_category(&rule.def.category),
});
}
}
issues
}
pub fn find_default_rules_file(base: &Path) -> Option<std::path::PathBuf> {
let filename = "react-perf-rules.toml";
let mut dir = if base.is_file() {
base.parent()?.to_path_buf()
} else {
base.to_path_buf()
};
loop {
let candidate = dir.join(filename);
if candidate.exists() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn parse_severity(s: &str) -> Severity {
match s.to_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"low" => Severity::Low,
"info" => Severity::Info,
_ => Severity::Medium,
}
}
fn parse_category(s: &str) -> IssueCategory {
match s.to_lowercase().as_str() {
"security" => IssueCategory::Security,
_ => IssueCategory::Performance,
}
}
fn file_matches_glob(file: &Path, glob_pattern: Option<&str>) -> bool {
let pattern = match glob_pattern {
Some(p) => p,
None => return true, };
let matcher = match glob::Pattern::new(pattern) {
Ok(m) => m,
Err(_) => return true,
};
matcher.matches_path_with(
file,
glob::MatchOptions {
case_sensitive: true,
require_literal_separator: false,
require_literal_leading_dot: false,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn make_rule(pattern: &str, ignore_if: Option<&str>) -> CompiledRule {
CompiledRule {
def: CustomRuleDef {
id: "test-rule".to_string(),
message: "test message".to_string(),
pattern: pattern.to_string(),
severity: "medium".to_string(),
category: "perf".to_string(),
file_glob: None,
ignore_if: ignore_if.map(str::to_string),
},
pattern: regex::Regex::new(pattern).unwrap(),
ignore_if: ignore_if.map(|p| regex::Regex::new(p).unwrap()),
}
}
#[test]
fn detects_pattern_match() {
let rules = vec![make_rule(r"console\.log\s*\(", None)];
let src = "const x = 1;\nconsole.log(x);\nreturn x;";
let issues = run_custom_rules(&rules, src, Path::new("test.tsx"));
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].line, 2);
}
#[test]
fn ignore_if_suppresses_hit() {
let rules = vec![make_rule(r"console\.log\s*\(", Some(r"// nolint"))];
let src = "console.log(x); // nolint\nconsole.log(y);";
let issues = run_custom_rules(&rules, src, Path::new("test.tsx"));
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].line, 2);
}
#[test]
fn no_match_returns_empty() {
let rules = vec![make_rule(r"eval\s*\(", None)];
let src = "const x = 1 + 2;";
let issues = run_custom_rules(&rules, src, Path::new("test.tsx"));
assert!(issues.is_empty());
}
#[test]
fn file_glob_filters_correctly() {
let mut rule = make_rule(r"TODO", None);
rule.def.file_glob = Some("**/*.test.tsx".to_string());
let issues = run_custom_rules(&[rule], "// TODO: fix this", Path::new("src/App.test.tsx"));
assert_eq!(issues.len(), 1);
}
#[test]
fn toml_roundtrip() {
let toml_src = r#"
[[rule]]
id = "no-console"
message = "No console.log in production code"
pattern = "console\\.log\\s*\\("
severity = "high"
category = "perf"
"#;
let rule_file: RuleFile = toml::from_str(toml_src).unwrap();
assert_eq!(rule_file.rules.len(), 1);
assert_eq!(rule_file.rules[0].id, "no-console");
assert_eq!(rule_file.rules[0].severity, "high");
}
}