Skip to main content

normalize_syntax_rules/
loader.rs

1//! Rule loading from multiple sources.
2//!
3//! Rules are loaded in this order (later overrides earlier by `id`):
4//! 1. Embedded builtins (compiled into normalize)
5//! 2. User global rules (`~/.config/normalize/rules/*.scm`)
6//! 3. Project rules (`.normalize/rules/*.scm`)
7
8use 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
16/// Load all rules from all sources, merged by ID.
17/// Order: builtins → ~/.config/normalize/rules/ → .normalize/rules/
18/// Then applies config overrides (severity, disable).
19pub fn load_all_rules(project_root: &Path, config: &RulesConfig) -> Vec<Rule> {
20    let mut rules_by_id: HashMap<String, Rule> = HashMap::new();
21
22    // 1. Load embedded builtins
23    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    // 2. Load user global rules (~/.config/normalize/rules/)
30    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    // 3. Load project rules (.normalize/rules/)
38    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    // 4. Apply config overrides
44    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            // Merge additional allow patterns from config
55            for pattern_str in &override_cfg.allow {
56                if let Ok(pattern) = Pattern::new(pattern_str) {
57                    rule.allow.push(pattern);
58                }
59            }
60            // Append additional tags from config (additive, does not replace)
61            for tag in &override_cfg.tags {
62                if !rule.tags.contains(tag) {
63                    rule.tags.push(tag.clone());
64                }
65            }
66        }
67    }
68
69    // 5. Apply global allow patterns to every rule
70    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
84/// Load rules from a directory.
85fn 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
109/// Parse a rule file with TOML frontmatter.
110fn 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
122/// Parse rule content string with TOML frontmatter.
123pub 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            // Doc block: comment lines after frontmatter, before query
147            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        // Keys with '/' must be quoted in TOML table headers.
300        // In normalize.toml this appears as [rules.rule."rust/foo"].
301        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}