normalize_syntax_rules/
loader.rs1use crate::builtin::BUILTIN_RULES;
9use crate::{Rule, Severity};
10use glob::Pattern;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14pub use normalize_rules_config::{RuleOverride, RulesConfig};
15
16pub fn load_all_rules(project_root: &Path, config: &RulesConfig) -> Vec<Rule> {
20 let mut rules_by_id: HashMap<String, Rule> = HashMap::new();
21
22 for builtin in BUILTIN_RULES {
24 if let Some(rule) = parse_rule_content(builtin.content, builtin.id, true) {
25 rules_by_id.insert(rule.id.clone(), rule);
26 }
27 }
28
29 if let Some(config_dir) = dirs::config_dir() {
31 let user_rules_dir = config_dir.join("normalize").join("rules");
32 for rule in load_rules_from_dir(&user_rules_dir) {
33 rules_by_id.insert(rule.id.clone(), rule);
34 }
35 }
36
37 let project_rules_dir = project_root.join(".normalize").join("rules");
39 for rule in load_rules_from_dir(&project_rules_dir) {
40 rules_by_id.insert(rule.id.clone(), rule);
41 }
42
43 for (rule_id, override_cfg) in &config.rules {
45 if let Some(rule) = rules_by_id.get_mut(rule_id) {
46 if let Some(ref severity_str) = override_cfg.severity
47 && let Ok(severity) = severity_str.parse()
48 {
49 rule.severity = severity;
50 }
51 if let Some(enabled) = override_cfg.enabled {
52 rule.enabled = enabled;
53 }
54 for pattern_str in &override_cfg.allow {
56 if let Ok(pattern) = Pattern::new(pattern_str) {
57 rule.allow.push(pattern);
58 }
59 }
60 for tag in &override_cfg.tags {
62 if !rule.tags.contains(tag) {
63 rule.tags.push(tag.clone());
64 }
65 }
66 }
67 }
68
69 let global_patterns: Vec<Pattern> = config
71 .global_allow
72 .iter()
73 .filter_map(|s| Pattern::new(s).ok())
74 .collect();
75 if !global_patterns.is_empty() {
76 for rule in rules_by_id.values_mut() {
77 rule.allow.extend_from_slice(&global_patterns);
78 }
79 }
80
81 rules_by_id.into_values().collect()
82}
83
84fn load_rules_from_dir(rules_dir: &Path) -> Vec<Rule> {
86 let mut rules = Vec::new();
87
88 if !rules_dir.exists() {
89 return rules;
90 }
91
92 let entries = match std::fs::read_dir(rules_dir) {
93 Ok(e) => e,
94 Err(_) => return rules,
95 };
96
97 for entry in entries.flatten() {
98 let path = entry.path();
99 if path.extension().is_some_and(|e| e == "scm")
100 && let Some(rule) = parse_rule_file(&path)
101 {
102 rules.push(rule);
103 }
104 }
105
106 rules
107}
108
109fn parse_rule_file(path: &Path) -> Option<Rule> {
111 let content = std::fs::read_to_string(path).ok()?;
112 let default_id = path
113 .file_stem()
114 .and_then(|s| s.to_str())
115 .unwrap_or("unknown");
116
117 let mut rule = parse_rule_content(&content, default_id, false)?;
118 rule.source_path = path.to_path_buf();
119 Some(rule)
120}
121
122pub fn parse_rule_content(content: &str, default_id: &str, is_builtin: bool) -> Option<Rule> {
124 let lines: Vec<&str> = content.lines().collect();
125
126 let mut in_frontmatter = false;
127 let mut frontmatter_done = false;
128 let mut frontmatter_lines = Vec::new();
129 let mut doc_lines = Vec::new();
130 let mut query_lines = Vec::new();
131
132 for line in &lines {
133 let trimmed = line.trim();
134 if trimmed == "# ---" {
135 if in_frontmatter {
136 frontmatter_done = true;
137 }
138 in_frontmatter = !in_frontmatter;
139 continue;
140 }
141
142 if in_frontmatter {
143 let fm_line = line.strip_prefix('#').unwrap_or(line).trim_start();
144 frontmatter_lines.push(fm_line);
145 } else if frontmatter_done && query_lines.is_empty() && trimmed.starts_with('#') {
146 let doc_line = line.strip_prefix('#').unwrap_or("").trim_start_matches(' ');
148 doc_lines.push(doc_line);
149 } else if !frontmatter_lines.is_empty()
150 || (frontmatter_lines.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#'))
151 {
152 query_lines.push(*line);
153 }
154 }
155
156 let (frontmatter_str, query_str) = if frontmatter_lines.is_empty() {
157 (String::new(), content.to_string())
158 } else {
159 (frontmatter_lines.join("\n"), query_lines.join("\n"))
160 };
161
162 let doc = if doc_lines.is_empty() {
163 None
164 } else {
165 let text = doc_lines.join("\n").trim().to_string();
166 if text.is_empty() { None } else { Some(text) }
167 };
168
169 let frontmatter: toml::Value = if frontmatter_str.is_empty() {
170 toml::Value::Table(toml::map::Map::new())
171 } else {
172 match toml::from_str(&frontmatter_str) {
173 Ok(v) => v,
174 Err(e) => {
175 eprintln!("Warning: invalid frontmatter: {}", e);
176 return None;
177 }
178 }
179 };
180
181 let id = frontmatter
182 .get("id")
183 .and_then(|v| v.as_str())
184 .map(|s| s.to_string())
185 .unwrap_or_else(|| default_id.to_string());
186
187 let severity = frontmatter
188 .get("severity")
189 .and_then(|v| v.as_str())
190 .and_then(|s| s.parse().ok())
191 .unwrap_or(Severity::Warning);
192
193 let message = frontmatter
194 .get("message")
195 .and_then(|v| v.as_str())
196 .unwrap_or("Rule violation")
197 .to_string();
198
199 let allow: Vec<Pattern> = frontmatter
200 .get("allow")
201 .and_then(|v| v.as_array())
202 .map(|arr| {
203 arr.iter()
204 .filter_map(|v| v.as_str())
205 .filter_map(|s| Pattern::new(s).ok())
206 .collect()
207 })
208 .unwrap_or_default();
209
210 let files: Vec<Pattern> = frontmatter
211 .get("files")
212 .and_then(|v| v.as_array())
213 .map(|arr| {
214 arr.iter()
215 .filter_map(|v| v.as_str())
216 .filter_map(|s| Pattern::new(s).ok())
217 .collect()
218 })
219 .unwrap_or_default();
220
221 let languages: Vec<String> = frontmatter
222 .get("languages")
223 .and_then(|v| v.as_array())
224 .map(|arr| {
225 arr.iter()
226 .filter_map(|v| v.as_str())
227 .map(|s| s.to_string())
228 .collect()
229 })
230 .unwrap_or_default();
231
232 let enabled = frontmatter
233 .get("enabled")
234 .and_then(|v| v.as_bool())
235 .unwrap_or(true);
236
237 let requires: HashMap<String, String> = frontmatter
238 .get("requires")
239 .and_then(|v| v.as_table())
240 .map(|tbl| {
241 tbl.iter()
242 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
243 .collect()
244 })
245 .unwrap_or_default();
246
247 let fix = frontmatter
248 .get("fix")
249 .and_then(|v| v.as_str())
250 .map(|s| s.to_string());
251
252 let tags: Vec<String> = frontmatter
253 .get("tags")
254 .and_then(|v| v.as_array())
255 .map(|arr| {
256 arr.iter()
257 .filter_map(|v| v.as_str())
258 .map(|s| s.to_string())
259 .collect()
260 })
261 .unwrap_or_default();
262
263 let recommended = frontmatter
264 .get("recommended")
265 .and_then(|v| v.as_bool())
266 .unwrap_or(false);
267
268 let applies_in_tests = frontmatter
269 .get("applies_in_tests")
270 .and_then(|v| v.as_bool())
271 .unwrap_or(false);
272
273 Some(Rule {
274 id,
275 query_str: query_str.trim().to_string(),
276 severity,
277 message,
278 allow,
279 files,
280 source_path: PathBuf::new(),
281 languages,
282 enabled,
283 builtin: is_builtin,
284 requires,
285 fix,
286 tags,
287 doc,
288 recommended,
289 applies_in_tests,
290 })
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_rules_config_toml_deserialization() {
299 let toml_str = r#"
302global-allow = ["**/tests/fixtures/**", "**/test/**"]
303
304[rule."rust/foo"]
305severity = "error"
306enabled = true
307allow = ["some/path/**"]
308
309[rule."rust/bar"]
310severity = "warning"
311"#;
312 let config: RulesConfig = toml::from_str(toml_str).expect("failed to parse RulesConfig");
313 assert_eq!(
314 config.global_allow,
315 vec!["**/tests/fixtures/**", "**/test/**"]
316 );
317 assert!(config.rules.contains_key("rust/foo"));
318 assert!(config.rules.contains_key("rust/bar"));
319 assert_eq!(config.rules["rust/foo"].severity.as_deref(), Some("error"));
320 assert_eq!(
321 config.rules["rust/bar"].severity.as_deref(),
322 Some("warning")
323 );
324 }
325
326 #[test]
327 fn test_rules_config_empty_global_allow() {
328 let toml_str = r#"
329[rule."rust/baz"]
330enabled = false
331"#;
332 let config: RulesConfig = toml::from_str(toml_str).expect("failed to parse RulesConfig");
333 assert!(config.global_allow.is_empty());
334 assert!(config.rules.contains_key("rust/baz"));
335 }
336}