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::metric::Metric;
14use crate::resolution::ResolutionKind;
15use crate::source::RuleOrigin;
16
17mod load;
18
19pub use load::{
20 is_rule_active, load_all_rules, load_builtin_rules, load_custom_rules, load_effective_rules,
21 load_source_rules, read_fixture, validate_unique_ids,
22};
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct RuleId(pub String);
26
27impl fmt::Display for RuleId {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 f.write_str(&self.0)
30 }
31}
32
33impl RuleId {
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl From<String> for RuleId {
40 fn from(s: String) -> Self {
41 Self(s)
42 }
43}
44
45impl From<&str> for RuleId {
46 fn from(s: &str) -> Self {
47 Self(s.to_string())
48 }
49}
50
51#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53#[strum(serialize_all = "lowercase")]
54pub enum Severity {
55 Error,
56 Warning,
57}
58
59#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61#[strum(serialize_all = "lowercase")]
62pub enum Language {
63 Rust,
64 TypeScript,
65}
66
67impl Language {
68 pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
71 match self {
72 Language::Rust => &[SupportLang::Rust],
73 Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
74 }
75 }
76}
77
78#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80#[strum(serialize_all = "lowercase")]
81pub enum Category {
82 Slop,
83 Security,
84 #[default]
85 Correctness,
86}
87
88impl Category {
89 fn from_rule_path(path: &Path) -> Option<Self> {
93 match path.components().next()? {
94 Component::Normal(dir) => dir.to_str()?.parse().ok(),
95 _ => None,
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct FixCase {
105 pub before: String,
106 pub after: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RuleTests {
111 #[serde(default)]
112 pub should_match: Vec<String>,
113 #[serde(default)]
114 pub should_not_match: Vec<String>,
115 #[serde(default)]
119 pub should_fix: Vec<FixCase>,
120 #[serde(default)]
124 pub should_match_files: Vec<String>,
125 #[serde(default)]
126 pub should_not_match_files: Vec<String>,
127}
128
129#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
132#[serde(rename_all = "lowercase")]
133#[strum(serialize_all = "lowercase")]
134pub enum ReasonMode {
135 #[default]
137 Static,
138 Generated,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct AiCheck {
150 pub prompt: String,
154 #[serde(default)]
156 pub model: Option<String>,
157 #[serde(default)]
160 pub reason: ReasonMode,
161 #[serde(default)]
164 pub threshold: Option<f64>,
165 #[serde(default)]
168 pub if_true: Option<String>,
169 #[serde(default)]
172 pub if_false: Option<String>,
173}
174
175fn default_enabled() -> bool {
176 true
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Rule {
181 pub id: RuleId,
182 pub language: Language,
183 pub severity: Severity,
184 pub message: String,
185 #[serde(default)]
186 pub note: Option<String>,
187 #[serde(default)]
188 pub category: Option<Category>,
189 #[serde(default)]
190 pub fix: Option<String>,
191 #[serde(default)]
196 pub rewrite: Option<String>,
197 #[serde(default)]
203 pub autofix_safe: bool,
204 #[serde(default, skip_serializing_if = "Value::is_null")]
213 pub autofix_rule: Value,
214 #[serde(default)]
215 pub rule: Value,
216 #[serde(default, skip_serializing_if = "Value::is_null")]
220 pub constraints: Value,
221 #[serde(default)]
223 pub metric: Option<Metric>,
224 #[serde(default)]
226 pub threshold: Option<f64>,
227 #[serde(default)]
231 pub min_lines: Option<usize>,
232 #[serde(default)]
236 pub cross_file: Option<CrossFileKind>,
237 #[serde(default)]
243 pub resolution: Option<ResolutionKind>,
244 #[serde(skip)]
247 pub source_dir: Option<PathBuf>,
248 #[serde(skip)]
251 pub origin: RuleOrigin,
252 #[serde(default)]
253 pub files: Option<Vec<String>>,
254 #[serde(default)]
255 pub ignores: Option<Vec<String>>,
256 #[serde(default = "default_enabled")]
259 pub enabled: bool,
260 #[serde(default)]
261 pub skip_test_code: bool,
262 #[serde(default)]
263 pub tests: Option<RuleTests>,
264 #[serde(default)]
267 pub ai_check: Option<AiCheck>,
268}
269
270impl Rule {
271 pub fn effective_category(&self) -> Category {
274 self.category.clone().unwrap_or_default()
275 }
276
277 pub fn is_metric(&self) -> bool {
279 self.metric.is_some()
280 }
281
282 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
284 Some((self.metric?, self.threshold?))
285 }
286
287 pub fn is_cross_file(&self) -> bool {
289 self.cross_file.is_some()
290 }
291
292 pub fn is_resolution(&self) -> bool {
294 self.resolution.is_some()
295 }
296
297 pub fn is_autofixable(&self) -> bool {
302 self.autofix_safe && self.rewrite.is_some()
303 }
304
305 pub fn fix_matcher(&self) -> &Value {
308 if self.autofix_rule.is_null() {
309 &self.rule
310 } else {
311 &self.autofix_rule
312 }
313 }
314
315 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
319 self.cross_file
320 }
321
322 pub fn resolution_kind(&self) -> Option<ResolutionKind> {
326 self.resolution
327 }
328}
329
330#[derive(Debug, Error)]
331pub enum RuleError {
332 #[error("failed to parse rule YAML: {0}")]
333 Parse(#[from] serde_yaml::Error),
334
335 #[error("rule '{id}': rule field must not be empty")]
336 EmptyRule { id: RuleId },
337
338 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
339 MetricAndRule { id: RuleId },
340
341 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
342 CrossFileAndRule { id: RuleId },
343
344 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
345 CrossFileAndMetric { id: RuleId },
346
347 #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
348 ResolutionAndRule { id: RuleId },
349
350 #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
351 ResolutionAndMetric { id: RuleId },
352
353 #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
354 ResolutionAndCrossFile { id: RuleId },
355
356 #[error("rule '{id}': a metric rule requires a 'threshold'")]
357 MissingThreshold { id: RuleId },
358
359 #[error("rule '{id}': test fixture files are only supported for metric rules")]
360 FixturesOnAstRule { id: RuleId },
361
362 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
363 InvalidFixturePath { id: RuleId, path: String },
364
365 #[error("rule '{id}': fixture '{path}' not found")]
366 FixtureNotFound { id: RuleId, path: String },
367
368 #[error("duplicate rule id: '{id}'")]
369 DuplicateId { id: RuleId },
370
371 #[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
372 SourceReusesBuiltinId { id: RuleId, origin: String },
373
374 #[error("failed to read rule file '{path}': {source}")]
375 Io {
376 path: String,
377 #[source]
378 source: IoError,
379 },
380
381 #[error("rule file '{path}' is not valid UTF-8: {source}")]
382 Utf8 {
383 path: String,
384 #[source]
385 source: Utf8Error,
386 },
387}
388
389impl RuleError {
390 fn io(path: &Path, source: IoError) -> Self {
391 Self::Io {
392 path: path.display().to_string(),
393 source,
394 }
395 }
396}
397
398pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
403 let rule: Rule = serde_yaml::from_str(yaml)?;
404 let has_rule = !rule.rule.is_null();
405 let has_metric = rule.metric.is_some();
406 let has_cross = rule.cross_file.is_some();
407 let has_resolution = rule.resolution.is_some();
408 if has_resolution {
409 if has_rule {
410 return Err(RuleError::ResolutionAndRule { id: rule.id });
411 }
412 if has_metric {
413 return Err(RuleError::ResolutionAndMetric { id: rule.id });
414 }
415 if has_cross {
416 return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
417 }
418 }
419 match (has_cross, has_metric, has_rule) {
420 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
421 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
422 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
423 (false, true, false) if rule.threshold.is_none() => {
424 return Err(RuleError::MissingThreshold { id: rule.id })
425 }
426 (false, false, false) if !has_resolution => {
427 return Err(RuleError::EmptyRule { id: rule.id })
428 }
429 _ => {}
430 }
431 if !rule.is_metric() {
432 if let Some(tests) = &rule.tests {
433 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
434 return Err(RuleError::FixturesOnAstRule { id: rule.id });
435 }
436 }
437 }
438 Ok(rule)
439}
440
441#[cfg(test)]
442mod load_tests;
443#[cfg(test)]
444mod parse_tests;