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 moss)
5//! 2. User global rules (`~/.config/moss/rules/*.scm`)
6//! 3. Project rules (`.normalize/rules/*.scm`)
7
8use crate::builtin::BUILTIN_RULES;
9use crate::{Rule, Severity};
10use glob::Pattern;
11use normalize_derive::Merge;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16/// Configuration for syntax rules analysis.
17/// Maps rule ID to per-rule configuration.
18/// e.g., { "rust/unnecessary-let" = { severity = "warning" } }
19#[derive(Debug, Clone, Deserialize, Serialize, Default, Merge, schemars::JsonSchema)]
20#[serde(transparent)]
21pub struct RulesConfig(pub HashMap<String, RuleOverride>);
22
23/// Per-rule configuration override.
24#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
25#[serde(default)]
26pub struct RuleOverride {
27    /// Override the rule's severity.
28    pub severity: Option<String>,
29    /// Enable or disable the rule.
30    pub enabled: Option<bool>,
31    /// Additional file patterns to allow (skip) for this rule.
32    #[serde(default)]
33    pub allow: Vec<String>,
34}
35
36/// Load all rules from all sources, merged by ID.
37/// Order: builtins → ~/.config/moss/rules/ → .normalize/rules/
38/// Then applies config overrides (severity, disable).
39pub fn load_all_rules(project_root: &Path, config: &RulesConfig) -> Vec<Rule> {
40    let mut rules_by_id: HashMap<String, Rule> = HashMap::new();
41
42    // 1. Load embedded builtins
43    for builtin in BUILTIN_RULES {
44        if let Some(rule) = parse_rule_content(builtin.content, builtin.id, true) {
45            rules_by_id.insert(rule.id.clone(), rule);
46        }
47    }
48
49    // 2. Load user global rules (~/.config/moss/rules/)
50    if let Some(config_dir) = dirs::config_dir() {
51        let user_rules_dir = config_dir.join("moss").join("rules");
52        for rule in load_rules_from_dir(&user_rules_dir) {
53            rules_by_id.insert(rule.id.clone(), rule);
54        }
55    }
56
57    // 3. Load project rules (.normalize/rules/)
58    let project_rules_dir = project_root.join(".normalize").join("rules");
59    for rule in load_rules_from_dir(&project_rules_dir) {
60        rules_by_id.insert(rule.id.clone(), rule);
61    }
62
63    // 4. Apply config overrides
64    for (rule_id, override_cfg) in &config.0 {
65        if let Some(rule) = rules_by_id.get_mut(rule_id) {
66            if let Some(ref severity_str) = override_cfg.severity {
67                if let Ok(severity) = severity_str.parse() {
68                    rule.severity = severity;
69                }
70            }
71            if let Some(enabled) = override_cfg.enabled {
72                rule.enabled = enabled;
73            }
74            // Merge additional allow patterns from config
75            for pattern_str in &override_cfg.allow {
76                if let Ok(pattern) = Pattern::new(pattern_str) {
77                    rule.allow.push(pattern);
78                }
79            }
80        }
81    }
82
83    // Filter out disabled rules
84    rules_by_id.into_values().filter(|r| r.enabled).collect()
85}
86
87/// Load rules from a directory.
88fn load_rules_from_dir(rules_dir: &Path) -> Vec<Rule> {
89    let mut rules = Vec::new();
90
91    if !rules_dir.exists() {
92        return rules;
93    }
94
95    let entries = match std::fs::read_dir(rules_dir) {
96        Ok(e) => e,
97        Err(_) => return rules,
98    };
99
100    for entry in entries.flatten() {
101        let path = entry.path();
102        if path.extension().map(|e| e == "scm").unwrap_or(false) {
103            if let Some(rule) = parse_rule_file(&path) {
104                rules.push(rule);
105            }
106        }
107    }
108
109    rules
110}
111
112/// Parse a rule file with TOML frontmatter.
113fn parse_rule_file(path: &Path) -> Option<Rule> {
114    let content = std::fs::read_to_string(path).ok()?;
115    let default_id = path
116        .file_stem()
117        .and_then(|s| s.to_str())
118        .unwrap_or("unknown");
119
120    let mut rule = parse_rule_content(&content, default_id, false)?;
121    rule.source_path = path.to_path_buf();
122    Some(rule)
123}
124
125/// Parse rule content string with TOML frontmatter.
126pub fn parse_rule_content(content: &str, default_id: &str, is_builtin: bool) -> Option<Rule> {
127    let lines: Vec<&str> = content.lines().collect();
128
129    let mut in_frontmatter = false;
130    let mut frontmatter_lines = Vec::new();
131    let mut query_lines = Vec::new();
132
133    for line in &lines {
134        let trimmed = line.trim();
135        if trimmed == "# ---" {
136            if in_frontmatter {
137                in_frontmatter = false;
138            } else {
139                in_frontmatter = true;
140            }
141            continue;
142        }
143
144        if in_frontmatter {
145            let fm_line = line.strip_prefix('#').unwrap_or(line).trim_start();
146            frontmatter_lines.push(fm_line);
147        } else if !in_frontmatter && !frontmatter_lines.is_empty() {
148            query_lines.push(*line);
149        } else if frontmatter_lines.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('#') {
150            query_lines.push(*line);
151        }
152    }
153
154    let (frontmatter_str, query_str) = if frontmatter_lines.is_empty() {
155        (String::new(), content.to_string())
156    } else {
157        (frontmatter_lines.join("\n"), query_lines.join("\n"))
158    };
159
160    let frontmatter: toml::Value = if frontmatter_str.is_empty() {
161        toml::Value::Table(toml::map::Map::new())
162    } else {
163        match toml::from_str(&frontmatter_str) {
164            Ok(v) => v,
165            Err(e) => {
166                eprintln!("Warning: invalid frontmatter: {}", e);
167                return None;
168            }
169        }
170    };
171
172    let id = frontmatter
173        .get("id")
174        .and_then(|v| v.as_str())
175        .map(|s| s.to_string())
176        .unwrap_or_else(|| default_id.to_string());
177
178    let severity = frontmatter
179        .get("severity")
180        .and_then(|v| v.as_str())
181        .and_then(|s| s.parse().ok())
182        .unwrap_or(Severity::Warning);
183
184    let message = frontmatter
185        .get("message")
186        .and_then(|v| v.as_str())
187        .unwrap_or("Rule violation")
188        .to_string();
189
190    let allow: Vec<Pattern> = frontmatter
191        .get("allow")
192        .and_then(|v| v.as_array())
193        .map(|arr| {
194            arr.iter()
195                .filter_map(|v| v.as_str())
196                .filter_map(|s| Pattern::new(s).ok())
197                .collect()
198        })
199        .unwrap_or_default();
200
201    let languages: Vec<String> = frontmatter
202        .get("languages")
203        .and_then(|v| v.as_array())
204        .map(|arr| {
205            arr.iter()
206                .filter_map(|v| v.as_str())
207                .map(|s| s.to_string())
208                .collect()
209        })
210        .unwrap_or_default();
211
212    let enabled = frontmatter
213        .get("enabled")
214        .and_then(|v| v.as_bool())
215        .unwrap_or(true);
216
217    let requires: HashMap<String, String> = frontmatter
218        .get("requires")
219        .and_then(|v| v.as_table())
220        .map(|tbl| {
221            tbl.iter()
222                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
223                .collect()
224        })
225        .unwrap_or_default();
226
227    let fix = frontmatter
228        .get("fix")
229        .and_then(|v| v.as_str())
230        .map(|s| s.to_string());
231
232    Some(Rule {
233        id,
234        query_str: query_str.trim().to_string(),
235        severity,
236        message,
237        allow,
238        source_path: PathBuf::new(),
239        languages,
240        enabled,
241        builtin: is_builtin,
242        requires,
243        fix,
244    })
245}