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;
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct RuleId(pub String);
22
23impl fmt::Display for RuleId {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.write_str(&self.0)
26 }
27}
28
29impl RuleId {
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33}
34
35impl From<String> for RuleId {
36 fn from(s: String) -> Self {
37 Self(s)
38 }
39}
40
41impl From<&str> for RuleId {
42 fn from(s: &str) -> Self {
43 Self(s.to_string())
44 }
45}
46
47#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49#[strum(serialize_all = "lowercase")]
50pub enum Severity {
51 Error,
52 Warning,
53}
54
55#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57#[strum(serialize_all = "lowercase")]
58pub enum Language {
59 Rust,
60 TypeScript,
61}
62
63impl Language {
64 pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
67 match self {
68 Language::Rust => &[SupportLang::Rust],
69 Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
70 }
71 }
72}
73
74#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76#[strum(serialize_all = "lowercase")]
77pub enum Category {
78 Slop,
79 Security,
80 #[default]
81 Correctness,
82}
83
84impl Category {
85 fn from_rule_path(path: &Path) -> Option<Self> {
89 match path.components().next()? {
90 Component::Normal(dir) => dir.to_str()?.parse().ok(),
91 _ => None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct FixCase {
101 pub before: String,
102 pub after: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct RuleTests {
107 #[serde(default)]
108 pub should_match: Vec<String>,
109 #[serde(default)]
110 pub should_not_match: Vec<String>,
111 #[serde(default)]
115 pub should_fix: Vec<FixCase>,
116 #[serde(default)]
120 pub should_match_files: Vec<String>,
121 #[serde(default)]
122 pub should_not_match_files: Vec<String>,
123}
124
125#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
128#[serde(rename_all = "lowercase")]
129#[strum(serialize_all = "lowercase")]
130pub enum ReasonMode {
131 #[default]
133 Static,
134 Generated,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct AiCheck {
146 pub prompt: String,
150 #[serde(default)]
152 pub model: Option<String>,
153 #[serde(default)]
156 pub reason: ReasonMode,
157 #[serde(default)]
160 pub threshold: Option<f64>,
161 #[serde(default)]
164 pub if_true: Option<String>,
165 #[serde(default)]
168 pub if_false: Option<String>,
169}
170
171fn default_enabled() -> bool {
172 true
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Rule {
177 pub id: RuleId,
178 pub language: Language,
179 pub severity: Severity,
180 pub message: String,
181 #[serde(default)]
182 pub note: Option<String>,
183 #[serde(default)]
184 pub category: Option<Category>,
185 #[serde(default)]
186 pub fix: Option<String>,
187 #[serde(default)]
192 pub rewrite: Option<String>,
193 #[serde(default)]
197 pub autofix_safe: bool,
198 #[serde(default, skip_serializing_if = "Value::is_null")]
204 pub autofix_rule: Value,
205 #[serde(default)]
206 pub rule: Value,
207 #[serde(default, skip_serializing_if = "Value::is_null")]
211 pub constraints: Value,
212 #[serde(default)]
214 pub metric: Option<Metric>,
215 #[serde(default)]
217 pub threshold: Option<f64>,
218 #[serde(default)]
222 pub min_lines: Option<usize>,
223 #[serde(default)]
227 pub cross_file: Option<CrossFileKind>,
228 #[serde(default)]
234 pub resolution: Option<ResolutionKind>,
235 #[serde(skip)]
238 pub source_dir: Option<PathBuf>,
239 #[serde(default)]
240 pub files: Option<Vec<String>>,
241 #[serde(default)]
242 pub ignores: Option<Vec<String>>,
243 #[serde(default = "default_enabled")]
246 pub enabled: bool,
247 #[serde(default)]
248 pub skip_test_code: bool,
249 #[serde(default)]
250 pub tests: Option<RuleTests>,
251 #[serde(default)]
254 pub ai_check: Option<AiCheck>,
255}
256
257impl Rule {
258 pub fn effective_category(&self) -> Category {
261 self.category.clone().unwrap_or_default()
262 }
263
264 pub fn is_metric(&self) -> bool {
266 self.metric.is_some()
267 }
268
269 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
271 Some((self.metric?, self.threshold?))
272 }
273
274 pub fn is_cross_file(&self) -> bool {
276 self.cross_file.is_some()
277 }
278
279 pub fn is_resolution(&self) -> bool {
281 self.resolution.is_some()
282 }
283
284 pub fn is_autofixable(&self) -> bool {
287 self.autofix_safe && self.rewrite.is_some()
288 }
289
290 pub fn fix_matcher(&self) -> &Value {
293 if self.autofix_rule.is_null() {
294 &self.rule
295 } else {
296 &self.autofix_rule
297 }
298 }
299
300 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
304 self.cross_file
305 }
306
307 pub fn resolution_kind(&self) -> Option<ResolutionKind> {
311 self.resolution
312 }
313}
314
315#[derive(Debug, Error)]
316pub enum RuleError {
317 #[error("failed to parse rule YAML: {0}")]
318 Parse(#[from] serde_yaml::Error),
319
320 #[error("rule '{id}': rule field must not be empty")]
321 EmptyRule { id: RuleId },
322
323 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
324 MetricAndRule { id: RuleId },
325
326 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
327 CrossFileAndRule { id: RuleId },
328
329 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
330 CrossFileAndMetric { id: RuleId },
331
332 #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
333 ResolutionAndRule { id: RuleId },
334
335 #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
336 ResolutionAndMetric { id: RuleId },
337
338 #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
339 ResolutionAndCrossFile { id: RuleId },
340
341 #[error("rule '{id}': a metric rule requires a 'threshold'")]
342 MissingThreshold { id: RuleId },
343
344 #[error("rule '{id}': test fixture files are only supported for metric rules")]
345 FixturesOnAstRule { id: RuleId },
346
347 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
348 InvalidFixturePath { id: RuleId, path: String },
349
350 #[error("rule '{id}': fixture '{path}' not found")]
351 FixtureNotFound { id: RuleId, path: String },
352
353 #[error("duplicate rule id: '{id}'")]
354 DuplicateId { id: RuleId },
355
356 #[error("failed to read rule file '{path}': {source}")]
357 Io {
358 path: String,
359 #[source]
360 source: IoError,
361 },
362
363 #[error("rule file '{path}' is not valid UTF-8: {source}")]
364 Utf8 {
365 path: String,
366 #[source]
367 source: Utf8Error,
368 },
369}
370
371impl RuleError {
372 fn io(path: &Path, source: IoError) -> Self {
373 Self::Io {
374 path: path.display().to_string(),
375 source,
376 }
377 }
378}
379
380pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
385 let rule: Rule = serde_yaml::from_str(yaml)?;
386 let has_rule = !rule.rule.is_null();
387 let has_metric = rule.metric.is_some();
388 let has_cross = rule.cross_file.is_some();
389 let has_resolution = rule.resolution.is_some();
390 if has_resolution {
391 if has_rule {
392 return Err(RuleError::ResolutionAndRule { id: rule.id });
393 }
394 if has_metric {
395 return Err(RuleError::ResolutionAndMetric { id: rule.id });
396 }
397 if has_cross {
398 return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
399 }
400 }
401 match (has_cross, has_metric, has_rule) {
402 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
403 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
404 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
405 (false, true, false) if rule.threshold.is_none() => {
406 return Err(RuleError::MissingThreshold { id: rule.id })
407 }
408 (false, false, false) if !has_resolution => {
409 return Err(RuleError::EmptyRule { id: rule.id })
410 }
411 _ => {}
412 }
413 if !rule.is_metric() {
414 if let Some(tests) = &rule.tests {
415 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
416 return Err(RuleError::FixturesOnAstRule { id: rule.id });
417 }
418 }
419 }
420 Ok(rule)
421}
422
423pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
427 let path = Path::new(relative);
428 let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
429 if path.is_absolute() || traverses {
430 return Err(RuleError::InvalidFixturePath {
431 id: rule.id.clone(),
432 path: relative.to_string(),
433 });
434 }
435
436 match &rule.source_dir {
437 Some(dir) => {
438 let full = dir.join(path);
439 read_to_string(&full).map_err(|e| {
440 if e.kind() == std::io::ErrorKind::NotFound {
441 RuleError::FixtureNotFound {
442 id: rule.id.clone(),
443 path: relative.to_string(),
444 }
445 } else {
446 RuleError::io(&full, e)
447 }
448 })
449 }
450 None => {
451 let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
452 id: rule.id.clone(),
453 path: relative.to_string(),
454 })?;
455 from_utf8(&file.data)
456 .map(str::to_string)
457 .map_err(|e| RuleError::Utf8 {
458 path: relative.to_string(),
459 source: e,
460 })
461 }
462 }
463}
464
465fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
468 let mut rule = parse_rule(yaml)?;
469 if rule.category.is_none() {
470 rule.category = Category::from_rule_path(relative_path);
471 }
472 Ok(rule)
473}
474
475fn is_rule_file(path: &Path) -> bool {
476 matches!(
477 path.extension().and_then(|e| e.to_str()),
478 Some("yml" | "yaml")
479 )
480}
481
482pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
484 BuiltinRules::iter()
485 .filter(|path| is_rule_file(Path::new(path.as_ref())))
486 .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
487 .map(|(path, file)| {
488 let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
489 path: path.to_string(),
490 source: e,
491 })?;
492 parse_rule_at(yaml, Path::new(path.as_ref()))
493 })
494 .collect()
495}
496
497pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
500 let mut rules = Vec::new();
501 for dir in dirs.iter().filter(|dir| dir.is_dir()) {
502 for entry in WalkBuilder::new(dir).standard_filters(false).build() {
505 let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
506 let path = entry.path();
507 let is_file = entry.file_type().is_some_and(|t| t.is_file());
508 if !is_file || !is_rule_file(path) {
509 continue;
510 }
511 let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
512 let relative = path.strip_prefix(dir).unwrap_or(path);
513 let mut rule = parse_rule_at(&yaml, relative)?;
514 rule.source_dir = Some(dir.clone());
515 rules.push(rule);
516 }
517 }
518 Ok(rules)
519}
520
521pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
527 let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
528 if listed(&config.rules.enable) {
529 return true;
530 }
531 let ruleset_on = match rule.effective_category() {
532 Category::Slop => config.rulesets.slop,
533 Category::Security => config.rulesets.security,
534 Category::Correctness => config.rulesets.correctness,
535 };
536 ruleset_on && rule.enabled && !listed(&config.rules.disable)
537}
538
539pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
542 let mut rules: Vec<Rule> = load_builtin_rules()?
543 .into_iter()
544 .filter(|rule| is_rule_active(rule, config))
545 .collect();
546
547 let custom = load_custom_rules(&config.rules.custom_dirs)?;
548 rules.extend(custom);
549 validate_unique_ids(&rules)?;
550 Ok(rules)
551}
552
553pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
556 let mut rules = load_builtin_rules()?;
557 let custom = load_custom_rules(&config.rules.custom_dirs)?;
558 rules.extend(custom);
559 Ok(rules)
560}
561
562pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
566 let mut seen = HashSet::new();
567 for rule in rules {
568 if !seen.insert((&rule.id, &rule.language)) {
569 return Err(RuleError::DuplicateId {
570 id: rule.id.clone(),
571 });
572 }
573 }
574 Ok(())
575}
576
577#[cfg(test)]
578mod load_tests;
579#[cfg(test)]
580mod parse_tests;