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;
18use crate::resolution::ResolutionKind;
19use crate::source::{ResolvedSource, RuleOrigin};
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct RuleId(pub String);
23
24impl fmt::Display for RuleId {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 f.write_str(&self.0)
27 }
28}
29
30impl RuleId {
31 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34}
35
36impl From<String> for RuleId {
37 fn from(s: String) -> Self {
38 Self(s)
39 }
40}
41
42impl From<&str> for RuleId {
43 fn from(s: &str) -> Self {
44 Self(s.to_string())
45 }
46}
47
48#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50#[strum(serialize_all = "lowercase")]
51pub enum Severity {
52 Error,
53 Warning,
54}
55
56#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58#[strum(serialize_all = "lowercase")]
59pub enum Language {
60 Rust,
61 TypeScript,
62}
63
64impl Language {
65 pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
68 match self {
69 Language::Rust => &[SupportLang::Rust],
70 Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
71 }
72 }
73}
74
75#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77#[strum(serialize_all = "lowercase")]
78pub enum Category {
79 Slop,
80 Security,
81 #[default]
82 Correctness,
83}
84
85impl Category {
86 fn from_rule_path(path: &Path) -> Option<Self> {
90 match path.components().next()? {
91 Component::Normal(dir) => dir.to_str()?.parse().ok(),
92 _ => None,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct FixCase {
102 pub before: String,
103 pub after: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RuleTests {
108 #[serde(default)]
109 pub should_match: Vec<String>,
110 #[serde(default)]
111 pub should_not_match: Vec<String>,
112 #[serde(default)]
116 pub should_fix: Vec<FixCase>,
117 #[serde(default)]
121 pub should_match_files: Vec<String>,
122 #[serde(default)]
123 pub should_not_match_files: Vec<String>,
124}
125
126#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
129#[serde(rename_all = "lowercase")]
130#[strum(serialize_all = "lowercase")]
131pub enum ReasonMode {
132 #[default]
134 Static,
135 Generated,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct AiCheck {
147 pub prompt: String,
151 #[serde(default)]
153 pub model: Option<String>,
154 #[serde(default)]
157 pub reason: ReasonMode,
158 #[serde(default)]
161 pub threshold: Option<f64>,
162 #[serde(default)]
165 pub if_true: Option<String>,
166 #[serde(default)]
169 pub if_false: Option<String>,
170}
171
172fn default_enabled() -> bool {
173 true
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Rule {
178 pub id: RuleId,
179 pub language: Language,
180 pub severity: Severity,
181 pub message: String,
182 #[serde(default)]
183 pub note: Option<String>,
184 #[serde(default)]
185 pub category: Option<Category>,
186 #[serde(default)]
187 pub fix: Option<String>,
188 #[serde(default)]
193 pub rewrite: Option<String>,
194 #[serde(default)]
198 pub autofix_safe: bool,
199 #[serde(default, skip_serializing_if = "Value::is_null")]
205 pub autofix_rule: Value,
206 #[serde(default)]
207 pub rule: Value,
208 #[serde(default, skip_serializing_if = "Value::is_null")]
212 pub constraints: Value,
213 #[serde(default)]
215 pub metric: Option<Metric>,
216 #[serde(default)]
218 pub threshold: Option<f64>,
219 #[serde(default)]
223 pub min_lines: Option<usize>,
224 #[serde(default)]
228 pub cross_file: Option<CrossFileKind>,
229 #[serde(default)]
235 pub resolution: Option<ResolutionKind>,
236 #[serde(skip)]
239 pub source_dir: Option<PathBuf>,
240 #[serde(skip)]
243 pub origin: RuleOrigin,
244 #[serde(default)]
245 pub files: Option<Vec<String>>,
246 #[serde(default)]
247 pub ignores: Option<Vec<String>>,
248 #[serde(default = "default_enabled")]
251 pub enabled: bool,
252 #[serde(default)]
253 pub skip_test_code: bool,
254 #[serde(default)]
255 pub tests: Option<RuleTests>,
256 #[serde(default)]
259 pub ai_check: Option<AiCheck>,
260}
261
262impl Rule {
263 pub fn effective_category(&self) -> Category {
266 self.category.clone().unwrap_or_default()
267 }
268
269 pub fn is_metric(&self) -> bool {
271 self.metric.is_some()
272 }
273
274 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
276 Some((self.metric?, self.threshold?))
277 }
278
279 pub fn is_cross_file(&self) -> bool {
281 self.cross_file.is_some()
282 }
283
284 pub fn is_resolution(&self) -> bool {
286 self.resolution.is_some()
287 }
288
289 pub fn is_autofixable(&self) -> bool {
292 self.autofix_safe && self.rewrite.is_some()
293 }
294
295 pub fn fix_matcher(&self) -> &Value {
298 if self.autofix_rule.is_null() {
299 &self.rule
300 } else {
301 &self.autofix_rule
302 }
303 }
304
305 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
309 self.cross_file
310 }
311
312 pub fn resolution_kind(&self) -> Option<ResolutionKind> {
316 self.resolution
317 }
318}
319
320#[derive(Debug, Error)]
321pub enum RuleError {
322 #[error("failed to parse rule YAML: {0}")]
323 Parse(#[from] serde_yaml::Error),
324
325 #[error("rule '{id}': rule field must not be empty")]
326 EmptyRule { id: RuleId },
327
328 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
329 MetricAndRule { id: RuleId },
330
331 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
332 CrossFileAndRule { id: RuleId },
333
334 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
335 CrossFileAndMetric { id: RuleId },
336
337 #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
338 ResolutionAndRule { id: RuleId },
339
340 #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
341 ResolutionAndMetric { id: RuleId },
342
343 #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
344 ResolutionAndCrossFile { id: RuleId },
345
346 #[error("rule '{id}': a metric rule requires a 'threshold'")]
347 MissingThreshold { id: RuleId },
348
349 #[error("rule '{id}': test fixture files are only supported for metric rules")]
350 FixturesOnAstRule { id: RuleId },
351
352 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
353 InvalidFixturePath { id: RuleId, path: String },
354
355 #[error("rule '{id}': fixture '{path}' not found")]
356 FixtureNotFound { id: RuleId, path: String },
357
358 #[error("duplicate rule id: '{id}'")]
359 DuplicateId { id: RuleId },
360
361 #[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
362 SourceReusesBuiltinId { id: RuleId, origin: String },
363
364 #[error("failed to read rule file '{path}': {source}")]
365 Io {
366 path: String,
367 #[source]
368 source: IoError,
369 },
370
371 #[error("rule file '{path}' is not valid UTF-8: {source}")]
372 Utf8 {
373 path: String,
374 #[source]
375 source: Utf8Error,
376 },
377}
378
379impl RuleError {
380 fn io(path: &Path, source: IoError) -> Self {
381 Self::Io {
382 path: path.display().to_string(),
383 source,
384 }
385 }
386}
387
388pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
393 let rule: Rule = serde_yaml::from_str(yaml)?;
394 let has_rule = !rule.rule.is_null();
395 let has_metric = rule.metric.is_some();
396 let has_cross = rule.cross_file.is_some();
397 let has_resolution = rule.resolution.is_some();
398 if has_resolution {
399 if has_rule {
400 return Err(RuleError::ResolutionAndRule { id: rule.id });
401 }
402 if has_metric {
403 return Err(RuleError::ResolutionAndMetric { id: rule.id });
404 }
405 if has_cross {
406 return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
407 }
408 }
409 match (has_cross, has_metric, has_rule) {
410 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
411 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
412 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
413 (false, true, false) if rule.threshold.is_none() => {
414 return Err(RuleError::MissingThreshold { id: rule.id })
415 }
416 (false, false, false) if !has_resolution => {
417 return Err(RuleError::EmptyRule { id: rule.id })
418 }
419 _ => {}
420 }
421 if !rule.is_metric() {
422 if let Some(tests) = &rule.tests {
423 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
424 return Err(RuleError::FixturesOnAstRule { id: rule.id });
425 }
426 }
427 }
428 Ok(rule)
429}
430
431pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
435 let path = Path::new(relative);
436 let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
437 if path.is_absolute() || traverses {
438 return Err(RuleError::InvalidFixturePath {
439 id: rule.id.clone(),
440 path: relative.to_string(),
441 });
442 }
443
444 match &rule.source_dir {
445 Some(dir) => {
446 let full = dir.join(path);
447 read_to_string(&full).map_err(|e| {
448 if e.kind() == std::io::ErrorKind::NotFound {
449 RuleError::FixtureNotFound {
450 id: rule.id.clone(),
451 path: relative.to_string(),
452 }
453 } else {
454 RuleError::io(&full, e)
455 }
456 })
457 }
458 None => {
459 let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
460 id: rule.id.clone(),
461 path: relative.to_string(),
462 })?;
463 from_utf8(&file.data)
464 .map(str::to_string)
465 .map_err(|e| RuleError::Utf8 {
466 path: relative.to_string(),
467 source: e,
468 })
469 }
470 }
471}
472
473fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
476 let mut rule = parse_rule(yaml)?;
477 if rule.category.is_none() {
478 rule.category = Category::from_rule_path(relative_path);
479 }
480 Ok(rule)
481}
482
483fn is_rule_file(path: &Path) -> bool {
484 matches!(
485 path.extension().and_then(|e| e.to_str()),
486 Some("yml" | "yaml")
487 )
488}
489
490pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
492 BuiltinRules::iter()
493 .filter(|path| is_rule_file(Path::new(path.as_ref())))
494 .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
495 .map(|(path, file)| {
496 let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
497 path: path.to_string(),
498 source: e,
499 })?;
500 parse_rule_at(yaml, Path::new(path.as_ref()))
501 })
502 .collect()
503}
504
505fn load_rules_in_dir(dir: &Path, origin: &RuleOrigin) -> Result<Vec<Rule>, RuleError> {
509 let mut rules = Vec::new();
510 for entry in WalkBuilder::new(dir).standard_filters(false).build() {
513 let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
514 let path = entry.path();
515 let is_file = entry.file_type().is_some_and(|t| t.is_file());
516 if !is_file || !is_rule_file(path) {
517 continue;
518 }
519 let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
520 let relative = path.strip_prefix(dir).unwrap_or(path);
521 let mut rule = parse_rule_at(&yaml, relative)?;
522 rule.source_dir = Some(dir.to_path_buf());
523 rule.origin = origin.clone();
524 rules.push(rule);
525 }
526 Ok(rules)
527}
528
529pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
532 let mut rules = Vec::new();
533 for dir in dirs.iter().filter(|dir| dir.is_dir()) {
534 let origin = RuleOrigin::Local { path: dir.clone() };
535 rules.extend(load_rules_in_dir(dir, &origin)?);
536 }
537 Ok(rules)
538}
539
540pub fn load_source_rules(sources: &[ResolvedSource]) -> Result<Vec<Rule>, RuleError> {
544 let mut rules = Vec::new();
545 for source in sources.iter().filter(|s| s.dir.is_dir()) {
546 rules.extend(load_rules_in_dir(&source.dir, &source.origin)?);
547 }
548 Ok(rules)
549}
550
551fn reject_builtin_id_reuse(external: &[Rule], builtin: &[Rule]) -> Result<(), RuleError> {
554 let builtin_ids: HashSet<(&RuleId, &Language)> =
555 builtin.iter().map(|r| (&r.id, &r.language)).collect();
556 for rule in external {
557 if builtin_ids.contains(&(&rule.id, &rule.language)) {
558 return Err(RuleError::SourceReusesBuiltinId {
559 id: rule.id.clone(),
560 origin: rule.origin.label(),
561 });
562 }
563 }
564 Ok(())
565}
566
567pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
573 let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
574 if listed(&config.rules.enable) {
575 return true;
576 }
577 let ruleset_on = match rule.effective_category() {
578 Category::Slop => config.rulesets.slop,
579 Category::Security => config.rulesets.security,
580 Category::Correctness => config.rulesets.correctness,
581 };
582 ruleset_on && rule.enabled && !listed(&config.rules.disable)
583}
584
585fn load_external_rules(
589 config: &crate::config::Config,
590 sources: &[ResolvedSource],
591 builtin: &[Rule],
592) -> Result<Vec<Rule>, RuleError> {
593 let source_rules = load_source_rules(sources)?;
594 reject_builtin_id_reuse(&source_rules, builtin)?;
595
596 let mut external = load_custom_rules(&config.rules.custom_dirs)?;
597 external.extend(source_rules);
598 Ok(external)
599}
600
601pub fn load_effective_rules(
606 config: &crate::config::Config,
607 sources: &[ResolvedSource],
608) -> Result<Vec<Rule>, RuleError> {
609 let builtin = load_builtin_rules()?;
610 let external = load_external_rules(config, sources, &builtin)?;
611
612 let mut rules: Vec<Rule> = builtin
613 .into_iter()
614 .filter(|rule| is_rule_active(rule, config))
615 .collect();
616 rules.extend(external);
617 validate_unique_ids(&rules)?;
618 Ok(rules)
619}
620
621pub fn load_all_rules(
625 config: &crate::config::Config,
626 sources: &[ResolvedSource],
627) -> Result<Vec<Rule>, RuleError> {
628 let builtin = load_builtin_rules()?;
629 let external = load_external_rules(config, sources, &builtin)?;
630
631 let mut rules = builtin;
632 rules.extend(external);
633 Ok(rules)
634}
635
636pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
640 let mut seen = HashSet::new();
641 for rule in rules {
642 if !seen.insert((&rule.id, &rule.language)) {
643 return Err(RuleError::DuplicateId {
644 id: rule.id.clone(),
645 });
646 }
647 }
648 Ok(())
649}
650
651#[cfg(test)]
652mod load_tests;
653#[cfg(test)]
654mod parse_tests;