1use std::collections::HashSet;
2use std::fmt;
3use std::fs::read_to_string;
4use std::io::Error as IoError;
5use std::path::{Component, Path, PathBuf};
6use std::str::{from_utf8, Utf8Error};
7
8use ast_grep_language::SupportLang;
9use ignore::WalkBuilder;
10use serde::{Deserialize, Serialize};
11use serde_yaml::Value;
12use slopguard_rules::BuiltinRules;
13use strum::{Display, EnumIter, EnumString};
14use thiserror::Error;
15
16use crate::cross_file::CrossFileKind;
17use crate::metric::Metric;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct RuleId(pub String);
21
22impl fmt::Display for RuleId {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 f.write_str(&self.0)
25 }
26}
27
28impl RuleId {
29 pub fn as_str(&self) -> &str {
30 &self.0
31 }
32}
33
34impl From<String> for RuleId {
35 fn from(s: String) -> Self {
36 Self(s)
37 }
38}
39
40impl From<&str> for RuleId {
41 fn from(s: &str) -> Self {
42 Self(s.to_string())
43 }
44}
45
46#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48#[strum(serialize_all = "lowercase")]
49pub enum Severity {
50 Error,
51 Warning,
52}
53
54#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56#[strum(serialize_all = "lowercase")]
57pub enum Language {
58 Rust,
59 TypeScript,
60}
61
62impl Language {
63 pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
66 match self {
67 Language::Rust => &[SupportLang::Rust],
68 Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
69 }
70 }
71}
72
73#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75#[strum(serialize_all = "lowercase")]
76pub enum Category {
77 Slop,
78 Security,
79 #[default]
80 Correctness,
81}
82
83impl Category {
84 fn from_rule_path(path: &Path) -> Option<Self> {
88 match path.components().next()? {
89 Component::Normal(dir) => dir.to_str()?.parse().ok(),
90 _ => None,
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct FixCase {
100 pub before: String,
101 pub after: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RuleTests {
106 #[serde(default)]
107 pub should_match: Vec<String>,
108 #[serde(default)]
109 pub should_not_match: Vec<String>,
110 #[serde(default)]
114 pub should_fix: Vec<FixCase>,
115 #[serde(default)]
119 pub should_match_files: Vec<String>,
120 #[serde(default)]
121 pub should_not_match_files: Vec<String>,
122}
123
124#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
127#[serde(rename_all = "lowercase")]
128#[strum(serialize_all = "lowercase")]
129pub enum ReasonMode {
130 #[default]
132 Static,
133 Generated,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct AiCheck {
145 pub prompt: String,
149 #[serde(default)]
151 pub model: Option<String>,
152 #[serde(default)]
155 pub reason: ReasonMode,
156 #[serde(default)]
159 pub threshold: Option<f64>,
160 #[serde(default)]
163 pub if_true: Option<String>,
164 #[serde(default)]
167 pub if_false: Option<String>,
168}
169
170fn default_enabled() -> bool {
171 true
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Rule {
176 pub id: RuleId,
177 pub language: Language,
178 pub severity: Severity,
179 pub message: String,
180 #[serde(default)]
181 pub note: Option<String>,
182 #[serde(default)]
183 pub category: Option<Category>,
184 #[serde(default)]
185 pub fix: Option<String>,
186 #[serde(default)]
191 pub rewrite: Option<String>,
192 #[serde(default)]
196 pub autofix_safe: bool,
197 #[serde(default, skip_serializing_if = "Value::is_null")]
203 pub autofix_rule: Value,
204 #[serde(default)]
205 pub rule: Value,
206 #[serde(default, skip_serializing_if = "Value::is_null")]
210 pub constraints: Value,
211 #[serde(default)]
213 pub metric: Option<Metric>,
214 #[serde(default)]
216 pub threshold: Option<f64>,
217 #[serde(default)]
221 pub min_lines: Option<usize>,
222 #[serde(default)]
226 pub cross_file: Option<CrossFileKind>,
227 #[serde(skip)]
230 pub source_dir: Option<PathBuf>,
231 #[serde(default)]
232 pub files: Option<Vec<String>>,
233 #[serde(default)]
234 pub ignores: Option<Vec<String>>,
235 #[serde(default = "default_enabled")]
238 pub enabled: bool,
239 #[serde(default)]
240 pub skip_test_code: bool,
241 #[serde(default)]
242 pub tests: Option<RuleTests>,
243 #[serde(default)]
246 pub ai_check: Option<AiCheck>,
247}
248
249impl Rule {
250 pub fn effective_category(&self) -> Category {
253 self.category.clone().unwrap_or_default()
254 }
255
256 pub fn is_metric(&self) -> bool {
258 self.metric.is_some()
259 }
260
261 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
263 Some((self.metric?, self.threshold?))
264 }
265
266 pub fn is_cross_file(&self) -> bool {
268 self.cross_file.is_some()
269 }
270
271 pub fn is_autofixable(&self) -> bool {
274 self.autofix_safe && self.rewrite.is_some()
275 }
276
277 pub fn fix_matcher(&self) -> &Value {
280 if self.autofix_rule.is_null() {
281 &self.rule
282 } else {
283 &self.autofix_rule
284 }
285 }
286
287 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
291 self.cross_file
292 }
293}
294
295#[derive(Debug, Error)]
296pub enum RuleError {
297 #[error("failed to parse rule YAML: {0}")]
298 Parse(#[from] serde_yaml::Error),
299
300 #[error("rule '{id}': rule field must not be empty")]
301 EmptyRule { id: RuleId },
302
303 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
304 MetricAndRule { id: RuleId },
305
306 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
307 CrossFileAndRule { id: RuleId },
308
309 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
310 CrossFileAndMetric { id: RuleId },
311
312 #[error("rule '{id}': a metric rule requires a 'threshold'")]
313 MissingThreshold { id: RuleId },
314
315 #[error("rule '{id}': test fixture files are only supported for metric rules")]
316 FixturesOnAstRule { id: RuleId },
317
318 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
319 InvalidFixturePath { id: RuleId, path: String },
320
321 #[error("rule '{id}': fixture '{path}' not found")]
322 FixtureNotFound { id: RuleId, path: String },
323
324 #[error("duplicate rule id: '{id}'")]
325 DuplicateId { id: RuleId },
326
327 #[error("failed to read rule file '{path}': {source}")]
328 Io {
329 path: String,
330 #[source]
331 source: IoError,
332 },
333
334 #[error("rule file '{path}' is not valid UTF-8: {source}")]
335 Utf8 {
336 path: String,
337 #[source]
338 source: Utf8Error,
339 },
340}
341
342impl RuleError {
343 fn io(path: &Path, source: IoError) -> Self {
344 Self::Io {
345 path: path.display().to_string(),
346 source,
347 }
348 }
349}
350
351pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
356 let rule: Rule = serde_yaml::from_str(yaml)?;
357 let has_rule = !rule.rule.is_null();
358 let has_metric = rule.metric.is_some();
359 let has_cross = rule.cross_file.is_some();
360 match (has_cross, has_metric, has_rule) {
361 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
362 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
363 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
364 (false, true, false) if rule.threshold.is_none() => {
365 return Err(RuleError::MissingThreshold { id: rule.id })
366 }
367 (false, false, false) => return Err(RuleError::EmptyRule { id: rule.id }),
368 _ => {}
369 }
370 if !rule.is_metric() {
371 if let Some(tests) = &rule.tests {
372 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
373 return Err(RuleError::FixturesOnAstRule { id: rule.id });
374 }
375 }
376 }
377 Ok(rule)
378}
379
380pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
384 let path = Path::new(relative);
385 let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
386 if path.is_absolute() || traverses {
387 return Err(RuleError::InvalidFixturePath {
388 id: rule.id.clone(),
389 path: relative.to_string(),
390 });
391 }
392
393 match &rule.source_dir {
394 Some(dir) => {
395 let full = dir.join(path);
396 read_to_string(&full).map_err(|e| {
397 if e.kind() == std::io::ErrorKind::NotFound {
398 RuleError::FixtureNotFound {
399 id: rule.id.clone(),
400 path: relative.to_string(),
401 }
402 } else {
403 RuleError::io(&full, e)
404 }
405 })
406 }
407 None => {
408 let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
409 id: rule.id.clone(),
410 path: relative.to_string(),
411 })?;
412 from_utf8(&file.data)
413 .map(str::to_string)
414 .map_err(|e| RuleError::Utf8 {
415 path: relative.to_string(),
416 source: e,
417 })
418 }
419 }
420}
421
422fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
425 let mut rule = parse_rule(yaml)?;
426 if rule.category.is_none() {
427 rule.category = Category::from_rule_path(relative_path);
428 }
429 Ok(rule)
430}
431
432fn is_rule_file(path: &Path) -> bool {
433 matches!(
434 path.extension().and_then(|e| e.to_str()),
435 Some("yml" | "yaml")
436 )
437}
438
439pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
441 BuiltinRules::iter()
442 .filter(|path| is_rule_file(Path::new(path.as_ref())))
443 .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
444 .map(|(path, file)| {
445 let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
446 path: path.to_string(),
447 source: e,
448 })?;
449 parse_rule_at(yaml, Path::new(path.as_ref()))
450 })
451 .collect()
452}
453
454pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
457 let mut rules = Vec::new();
458 for dir in dirs.iter().filter(|dir| dir.is_dir()) {
459 for entry in WalkBuilder::new(dir).standard_filters(false).build() {
462 let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
463 let path = entry.path();
464 let is_file = entry.file_type().is_some_and(|t| t.is_file());
465 if !is_file || !is_rule_file(path) {
466 continue;
467 }
468 let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
469 let relative = path.strip_prefix(dir).unwrap_or(path);
470 let mut rule = parse_rule_at(&yaml, relative)?;
471 rule.source_dir = Some(dir.clone());
472 rules.push(rule);
473 }
474 }
475 Ok(rules)
476}
477
478pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
484 let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
485 if listed(&config.rules.enable) {
486 return true;
487 }
488 let ruleset_on = match rule.effective_category() {
489 Category::Slop => config.rulesets.slop,
490 Category::Security => config.rulesets.security,
491 Category::Correctness => config.rulesets.correctness,
492 };
493 ruleset_on && rule.enabled && !listed(&config.rules.disable)
494}
495
496pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
499 let mut rules: Vec<Rule> = load_builtin_rules()?
500 .into_iter()
501 .filter(|rule| is_rule_active(rule, config))
502 .collect();
503
504 let custom = load_custom_rules(&config.rules.custom_dirs)?;
505 rules.extend(custom);
506 validate_unique_ids(&rules)?;
507 Ok(rules)
508}
509
510pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
513 let mut rules = load_builtin_rules()?;
514 let custom = load_custom_rules(&config.rules.custom_dirs)?;
515 rules.extend(custom);
516 Ok(rules)
517}
518
519pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
523 let mut seen = HashSet::new();
524 for rule in rules {
525 if !seen.insert((&rule.id, &rule.language)) {
526 return Err(RuleError::DuplicateId {
527 id: rule.id.clone(),
528 });
529 }
530 }
531 Ok(())
532}
533
534#[cfg(test)]
535mod load_tests;
536#[cfg(test)]
537mod parse_tests;