use std::fmt;
use std::io::Error as IoError;
use std::path::{Component, Path, PathBuf};
use std::str::Utf8Error;
use ast_grep_language::SupportLang;
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use strum::{Display, EnumIter, EnumString};
use thiserror::Error;
use crate::cross_file::CrossFileKind;
use crate::fix::FixerKind;
use crate::metric::Metric;
use crate::resolution::ResolutionKind;
use crate::source::RuleOrigin;
mod load;
pub use load::{
is_rule_active, load_all_rules, load_builtin_rules, load_custom_rules, load_effective_rules,
load_source_rules, read_fixture, validate_unique_ids,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RuleId(pub String);
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl RuleId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for RuleId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for RuleId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Language {
Rust,
TypeScript,
}
impl Language {
pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
match self {
Language::Rust => &[SupportLang::Rust],
Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
}
}
}
#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Category {
Slop,
Security,
#[default]
Correctness,
}
impl Category {
fn from_rule_path(path: &Path) -> Option<Self> {
match path.components().next()? {
Component::Normal(dir) => dir.to_str()?.parse().ok(),
_ => None,
}
}
}
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
#[strum(serialize_all = "lowercase")]
pub enum FixSafety {
Safe,
Unsafe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixCase {
pub before: String,
pub after: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleTests {
#[serde(default)]
pub should_match: Vec<String>,
#[serde(default)]
pub should_not_match: Vec<String>,
#[serde(default)]
pub should_fix: Vec<FixCase>,
#[serde(default)]
pub should_match_files: Vec<String>,
#[serde(default)]
pub should_not_match_files: Vec<String>,
}
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasonMode {
#[default]
Static,
Generated,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AiCheck {
pub prompt: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub reason: ReasonMode,
#[serde(default)]
pub threshold: Option<f64>,
#[serde(default)]
pub if_true: Option<String>,
#[serde(default)]
pub if_false: Option<String>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: RuleId,
pub language: Language,
pub severity: Severity,
pub message: String,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub category: Option<Category>,
#[serde(default)]
pub fix: Option<String>,
#[serde(default)]
pub rewrite: Option<String>,
#[serde(default)]
pub fixer: Option<FixerKind>,
#[serde(default)]
pub autofix_safe: bool,
#[serde(default)]
pub autofix_unsafe: bool,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub autofix_rule: Value,
#[serde(default)]
pub rule: Value,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub constraints: Value,
#[serde(default)]
pub metric: Option<Metric>,
#[serde(default)]
pub threshold: Option<f64>,
#[serde(default)]
pub min_lines: Option<usize>,
#[serde(default)]
pub cross_file: Option<CrossFileKind>,
#[serde(default)]
pub resolution: Option<ResolutionKind>,
#[serde(skip)]
pub source_dir: Option<PathBuf>,
#[serde(skip)]
pub origin: RuleOrigin,
#[serde(default)]
pub files: Option<Vec<String>>,
#[serde(default)]
pub ignores: Option<Vec<String>>,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub skip_test_code: bool,
#[serde(default)]
pub tests: Option<RuleTests>,
#[serde(default)]
pub ai_check: Option<AiCheck>,
}
impl Rule {
pub fn effective_category(&self) -> Category {
self.category.clone().unwrap_or_default()
}
pub fn is_metric(&self) -> bool {
self.metric.is_some()
}
pub fn metric_spec(&self) -> Option<(Metric, f64)> {
Some((self.metric?, self.threshold?))
}
pub fn is_cross_file(&self) -> bool {
self.cross_file.is_some()
}
pub fn is_resolution(&self) -> bool {
self.resolution.is_some()
}
pub fn fix_safety(&self) -> Option<FixSafety> {
if self.rewrite.is_none() && self.fixer.is_none() {
return None;
}
if self.autofix_safe {
Some(FixSafety::Safe)
} else if self.autofix_unsafe {
Some(FixSafety::Unsafe)
} else {
None
}
}
pub fn is_autofixable(&self) -> bool {
self.fix_safety().is_some()
}
pub fn fix_matcher(&self) -> &Value {
if self.autofix_rule.is_null() {
&self.rule
} else {
&self.autofix_rule
}
}
pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
self.cross_file
}
pub fn resolution_kind(&self) -> Option<ResolutionKind> {
self.resolution
}
}
#[derive(Debug, Error)]
pub enum RuleError {
#[error("failed to parse rule YAML: {0}")]
Parse(#[from] serde_yaml::Error),
#[error("rule '{id}': rule field must not be empty")]
EmptyRule { id: RuleId },
#[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
MetricAndRule { id: RuleId },
#[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
CrossFileAndRule { id: RuleId },
#[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
CrossFileAndMetric { id: RuleId },
#[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
ResolutionAndRule { id: RuleId },
#[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
ResolutionAndMetric { id: RuleId },
#[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
ResolutionAndCrossFile { id: RuleId },
#[error("rule '{id}': a metric rule requires a 'threshold'")]
MissingThreshold { id: RuleId },
#[error("rule '{id}': 'rewrite' and 'fixer' are mutually exclusive")]
RewriteAndFixer { id: RuleId },
#[error("rule '{id}': a 'fixer' needs an ast-grep 'rule' to locate its matches")]
FixerWithoutRule { id: RuleId },
#[error("rule '{id}': 'autofix_safe' and 'autofix_unsafe' are mutually exclusive")]
SafeAndUnsafeFix { id: RuleId },
#[error("rule '{id}': test fixture files are only supported for metric rules")]
FixturesOnAstRule { id: RuleId },
#[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
InvalidFixturePath { id: RuleId, path: String },
#[error("rule '{id}': fixture '{path}' not found")]
FixtureNotFound { id: RuleId, path: String },
#[error("duplicate rule id: '{id}'")]
DuplicateId { id: RuleId },
#[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
SourceReusesBuiltinId { id: RuleId, origin: String },
#[error("failed to read rule file '{path}': {source}")]
Io {
path: String,
#[source]
source: IoError,
},
#[error("rule file '{path}' is not valid UTF-8: {source}")]
Utf8 {
path: String,
#[source]
source: Utf8Error,
},
}
impl RuleError {
fn io(path: &Path, source: IoError) -> Self {
Self::Io {
path: path.display().to_string(),
source,
}
}
}
pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
let rule: Rule = serde_yaml::from_str(yaml)?;
let has_rule = !rule.rule.is_null();
let has_metric = rule.metric.is_some();
let has_cross = rule.cross_file.is_some();
let has_resolution = rule.resolution.is_some();
if has_resolution {
if has_rule {
return Err(RuleError::ResolutionAndRule { id: rule.id });
}
if has_metric {
return Err(RuleError::ResolutionAndMetric { id: rule.id });
}
if has_cross {
return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
}
}
match (has_cross, has_metric, has_rule) {
(true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
(true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
(false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
(false, true, false) if rule.threshold.is_none() => {
return Err(RuleError::MissingThreshold { id: rule.id })
}
(false, false, false) if !has_resolution => {
return Err(RuleError::EmptyRule { id: rule.id })
}
_ => {}
}
if rule.fixer.is_some() {
if rule.rewrite.is_some() {
return Err(RuleError::RewriteAndFixer { id: rule.id });
}
if !has_rule {
return Err(RuleError::FixerWithoutRule { id: rule.id });
}
}
if rule.autofix_safe && rule.autofix_unsafe {
return Err(RuleError::SafeAndUnsafeFix { id: rule.id });
}
if !rule.is_metric() {
if let Some(tests) = &rule.tests {
if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
return Err(RuleError::FixturesOnAstRule { id: rule.id });
}
}
}
Ok(rule)
}
#[cfg(test)]
mod load_tests;
#[cfg(test)]
mod parse_tests;