1use std::fmt;
2use std::io::Error as IoError;
3use std::path::{Component, Path, PathBuf};
4use std::str::Utf8Error;
5
6use ast_grep_language::SupportLang;
7use serde::{Deserialize, Serialize};
8use serde_yaml::Value;
9use strum::{Display, EnumIter, EnumString};
10use thiserror::Error;
11
12use crate::cross_file::CrossFileKind;
13use crate::fix::FixerKind;
14use crate::metric::Metric;
15use crate::resolution::ResolutionKind;
16use crate::source::RuleOrigin;
17
18mod load;
19
20pub use load::{
21 is_rule_active, load_all_rules, load_builtin_rules, load_custom_rules, load_effective_rules,
22 load_source_rules, read_fixture, validate_unique_ids,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct RuleId(pub String);
27
28impl fmt::Display for RuleId {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 f.write_str(&self.0)
31 }
32}
33
34impl RuleId {
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl From<String> for RuleId {
41 fn from(s: String) -> Self {
42 Self(s)
43 }
44}
45
46impl From<&str> for RuleId {
47 fn from(s: &str) -> Self {
48 Self(s.to_string())
49 }
50}
51
52#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54#[strum(serialize_all = "lowercase")]
55pub enum Severity {
56 Error,
57 Warning,
58}
59
60#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62#[strum(serialize_all = "lowercase")]
63pub enum Language {
64 Rust,
65 TypeScript,
66}
67
68impl Language {
69 pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
72 match self {
73 Language::Rust => &[SupportLang::Rust],
74 Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
75 }
76 }
77}
78
79#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81#[strum(serialize_all = "lowercase")]
82pub enum Category {
83 Slop,
84 Security,
85 #[default]
86 Correctness,
87}
88
89impl Category {
90 fn from_rule_path(path: &Path) -> Option<Self> {
94 match path.components().next()? {
95 Component::Normal(dir) => dir.to_str()?.parse().ok(),
96 _ => None,
97 }
98 }
99}
100
101#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
104#[strum(serialize_all = "lowercase")]
105pub enum FixSafety {
106 Safe,
107 Unsafe,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct FixCase {
115 pub before: String,
116 pub after: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RuleTests {
121 #[serde(default)]
122 pub should_match: Vec<String>,
123 #[serde(default)]
124 pub should_not_match: Vec<String>,
125 #[serde(default)]
129 pub should_fix: Vec<FixCase>,
130 #[serde(default)]
134 pub should_match_files: Vec<String>,
135 #[serde(default)]
136 pub should_not_match_files: Vec<String>,
137}
138
139#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142#[serde(rename_all = "lowercase")]
143#[strum(serialize_all = "lowercase")]
144pub enum ReasonMode {
145 #[default]
147 Static,
148 Generated,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct AiCheck {
160 pub prompt: String,
164 #[serde(default)]
166 pub model: Option<String>,
167 #[serde(default)]
170 pub reason: ReasonMode,
171 #[serde(default)]
174 pub threshold: Option<f64>,
175 #[serde(default)]
178 pub if_true: Option<String>,
179 #[serde(default)]
182 pub if_false: Option<String>,
183}
184
185fn default_enabled() -> bool {
186 true
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Rule {
191 pub id: RuleId,
192 pub language: Language,
193 pub severity: Severity,
194 pub message: String,
195 #[serde(default)]
196 pub note: Option<String>,
197 #[serde(default)]
198 pub category: Option<Category>,
199 #[serde(default)]
200 pub fix: Option<String>,
201 #[serde(default)]
206 pub rewrite: Option<String>,
207 #[serde(default)]
212 pub fixer: Option<FixerKind>,
213 #[serde(default)]
220 pub autofix_safe: bool,
221 #[serde(default)]
226 pub autofix_unsafe: bool,
227 #[serde(default, skip_serializing_if = "Value::is_null")]
236 pub autofix_rule: Value,
237 #[serde(default)]
238 pub rule: Value,
239 #[serde(default, skip_serializing_if = "Value::is_null")]
243 pub constraints: Value,
244 #[serde(default)]
246 pub metric: Option<Metric>,
247 #[serde(default)]
249 pub threshold: Option<f64>,
250 #[serde(default)]
254 pub min_lines: Option<usize>,
255 #[serde(default)]
259 pub cross_file: Option<CrossFileKind>,
260 #[serde(default)]
266 pub resolution: Option<ResolutionKind>,
267 #[serde(skip)]
270 pub source_dir: Option<PathBuf>,
271 #[serde(skip)]
274 pub origin: RuleOrigin,
275 #[serde(default)]
276 pub files: Option<Vec<String>>,
277 #[serde(default)]
278 pub ignores: Option<Vec<String>>,
279 #[serde(default = "default_enabled")]
282 pub enabled: bool,
283 #[serde(default)]
284 pub skip_test_code: bool,
285 #[serde(default)]
286 pub tests: Option<RuleTests>,
287 #[serde(default)]
290 pub ai_check: Option<AiCheck>,
291}
292
293impl Rule {
294 pub fn effective_category(&self) -> Category {
297 self.category.clone().unwrap_or_default()
298 }
299
300 pub fn is_metric(&self) -> bool {
302 self.metric.is_some()
303 }
304
305 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
307 Some((self.metric?, self.threshold?))
308 }
309
310 pub fn is_cross_file(&self) -> bool {
312 self.cross_file.is_some()
313 }
314
315 pub fn is_resolution(&self) -> bool {
317 self.resolution.is_some()
318 }
319
320 pub fn fix_safety(&self) -> Option<FixSafety> {
325 if self.rewrite.is_none() && self.fixer.is_none() {
326 return None;
327 }
328 if self.autofix_safe {
329 Some(FixSafety::Safe)
330 } else if self.autofix_unsafe {
331 Some(FixSafety::Unsafe)
332 } else {
333 None
334 }
335 }
336
337 pub fn is_autofixable(&self) -> bool {
339 self.fix_safety().is_some()
340 }
341
342 pub fn fix_matcher(&self) -> &Value {
345 if self.autofix_rule.is_null() {
346 &self.rule
347 } else {
348 &self.autofix_rule
349 }
350 }
351
352 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
356 self.cross_file
357 }
358
359 pub fn resolution_kind(&self) -> Option<ResolutionKind> {
363 self.resolution
364 }
365}
366
367#[derive(Debug, Error)]
368pub enum RuleError {
369 #[error("failed to parse rule YAML: {0}")]
370 Parse(#[from] serde_yaml::Error),
371
372 #[error("rule '{id}': rule field must not be empty")]
373 EmptyRule { id: RuleId },
374
375 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
376 MetricAndRule { id: RuleId },
377
378 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
379 CrossFileAndRule { id: RuleId },
380
381 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
382 CrossFileAndMetric { id: RuleId },
383
384 #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
385 ResolutionAndRule { id: RuleId },
386
387 #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
388 ResolutionAndMetric { id: RuleId },
389
390 #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
391 ResolutionAndCrossFile { id: RuleId },
392
393 #[error("rule '{id}': a metric rule requires a 'threshold'")]
394 MissingThreshold { id: RuleId },
395
396 #[error("rule '{id}': 'rewrite' and 'fixer' are mutually exclusive")]
397 RewriteAndFixer { id: RuleId },
398
399 #[error("rule '{id}': a 'fixer' needs an ast-grep 'rule' to locate its matches")]
400 FixerWithoutRule { id: RuleId },
401
402 #[error("rule '{id}': 'autofix_safe' and 'autofix_unsafe' are mutually exclusive")]
403 SafeAndUnsafeFix { id: RuleId },
404
405 #[error("rule '{id}': test fixture files are only supported for metric rules")]
406 FixturesOnAstRule { id: RuleId },
407
408 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
409 InvalidFixturePath { id: RuleId, path: String },
410
411 #[error("rule '{id}': fixture '{path}' not found")]
412 FixtureNotFound { id: RuleId, path: String },
413
414 #[error("duplicate rule id: '{id}'")]
415 DuplicateId { id: RuleId },
416
417 #[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
418 SourceReusesBuiltinId { id: RuleId, origin: String },
419
420 #[error("failed to read rule file '{path}': {source}")]
421 Io {
422 path: String,
423 #[source]
424 source: IoError,
425 },
426
427 #[error("rule file '{path}' is not valid UTF-8: {source}")]
428 Utf8 {
429 path: String,
430 #[source]
431 source: Utf8Error,
432 },
433}
434
435impl RuleError {
436 fn io(path: &Path, source: IoError) -> Self {
437 Self::Io {
438 path: path.display().to_string(),
439 source,
440 }
441 }
442}
443
444pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
449 let rule: Rule = serde_yaml::from_str(yaml)?;
450 let has_rule = !rule.rule.is_null();
451 let has_metric = rule.metric.is_some();
452 let has_cross = rule.cross_file.is_some();
453 let has_resolution = rule.resolution.is_some();
454 if has_resolution {
455 if has_rule {
456 return Err(RuleError::ResolutionAndRule { id: rule.id });
457 }
458 if has_metric {
459 return Err(RuleError::ResolutionAndMetric { id: rule.id });
460 }
461 if has_cross {
462 return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
463 }
464 }
465 match (has_cross, has_metric, has_rule) {
466 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
467 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
468 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
469 (false, true, false) if rule.threshold.is_none() => {
470 return Err(RuleError::MissingThreshold { id: rule.id })
471 }
472 (false, false, false) if !has_resolution => {
473 return Err(RuleError::EmptyRule { id: rule.id })
474 }
475 _ => {}
476 }
477 if rule.fixer.is_some() {
478 if rule.rewrite.is_some() {
479 return Err(RuleError::RewriteAndFixer { id: rule.id });
480 }
481 if !has_rule {
482 return Err(RuleError::FixerWithoutRule { id: rule.id });
483 }
484 }
485 if rule.autofix_safe && rule.autofix_unsafe {
486 return Err(RuleError::SafeAndUnsafeFix { id: rule.id });
487 }
488 if !rule.is_metric() {
489 if let Some(tests) = &rule.tests {
490 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
491 return Err(RuleError::FixturesOnAstRule { id: rule.id });
492 }
493 }
494 }
495 Ok(rule)
496}
497
498#[cfg(test)]
499mod load_tests;
500#[cfg(test)]
501mod parse_tests;