normalize_syntax_rules/
loader.rs1use 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#[derive(Debug, Clone, Deserialize, Serialize, Default, Merge, schemars::JsonSchema)]
20#[serde(transparent)]
21pub struct RulesConfig(pub HashMap<String, RuleOverride>);
22
23#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
25#[serde(default)]
26pub struct RuleOverride {
27 pub severity: Option<String>,
29 pub enabled: Option<bool>,
31 #[serde(default)]
33 pub allow: Vec<String>,
34}
35
36pub fn load_all_rules(project_root: &Path, config: &RulesConfig) -> Vec<Rule> {
40 let mut rules_by_id: HashMap<String, Rule> = HashMap::new();
41
42 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 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 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 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 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 rules_by_id.into_values().filter(|r| r.enabled).collect()
85}
86
87fn 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
112fn 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
125pub 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}