use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
#[default]
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleKind {
Search,
Lint,
Codemod,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Matcher {
pub pattern: Option<String>,
pub kind: Option<String>,
pub regex: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AtomicMatcher {
Pattern(String),
Kind(String),
Regex(String),
}
impl Matcher {
pub fn resolve(&self) -> Result<AtomicMatcher, String> {
let set: Vec<&str> = [
self.pattern.as_ref().map(|_| "pattern"),
self.kind.as_ref().map(|_| "kind"),
self.regex.as_ref().map(|_| "regex"),
]
.into_iter()
.flatten()
.collect();
match set.as_slice() {
[] => Err("rule block sets none of `pattern` / `kind` / `regex`".into()),
[one] => Ok(match *one {
"pattern" => AtomicMatcher::Pattern(self.pattern.clone().unwrap()),
"kind" => AtomicMatcher::Kind(self.kind.clone().unwrap()),
_ => AtomicMatcher::Regex(self.regex.clone().unwrap()),
}),
many => Err(format!(
"rule block sets multiple matchers ({}); set exactly one",
many.join(", ")
)),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub id: String,
pub language: String,
#[serde(default)]
pub severity: Severity,
#[serde(default)]
pub message: String,
pub rule: Matcher,
#[serde(default)]
pub fix: Option<String>,
}
impl Rule {
pub fn kind(&self) -> RuleKind {
if self.fix.is_some() {
RuleKind::Codemod
} else if self.message.is_empty() {
RuleKind::Search
} else {
RuleKind::Lint
}
}
pub fn from_toml_str(text: &str) -> Result<Self, Box<toml::de::Error>> {
toml::from_str(text).map_err(Box::new)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_codemod_rule() {
let rule = Rule::from_toml_str(
r#"
id = "destructure-default"
language = "typescript"
severity = "warning"
message = "Collapse optional-chain default into a destructuring bind"
fix = "{ $KEY: $SRC }"
[rule]
pattern = "$SRC?.$KEY ?? $DEFAULT"
"#,
)
.expect("rule parses");
assert_eq!(rule.id, "destructure-default");
assert_eq!(rule.language, "typescript");
assert_eq!(rule.severity, Severity::Warning);
assert_eq!(rule.kind(), RuleKind::Codemod);
assert_eq!(
rule.rule.resolve().unwrap(),
AtomicMatcher::Pattern("$SRC?.$KEY ?? $DEFAULT".into())
);
}
#[test]
fn severity_defaults_to_warning() {
let rule = Rule::from_toml_str(
r#"
id = "x"
language = "rust"
[rule]
kind = "macro_invocation"
"#,
)
.unwrap();
assert_eq!(rule.severity, Severity::Warning);
assert_eq!(rule.kind(), RuleKind::Search);
}
#[test]
fn lint_rule_has_message_no_fix() {
let rule = Rule::from_toml_str(
r#"
id = "todo"
language = "rust"
message = "Found a TODO"
[rule]
regex = "TODO"
"#,
)
.unwrap();
assert_eq!(rule.kind(), RuleKind::Lint);
assert_eq!(
rule.rule.resolve().unwrap(),
AtomicMatcher::Regex("TODO".into())
);
}
#[test]
fn rejects_multiple_matchers() {
let rule = Rule::from_toml_str(
r#"
id = "x"
language = "rust"
[rule]
kind = "foo"
regex = "bar"
"#,
)
.unwrap();
assert!(rule.rule.resolve().is_err());
}
#[test]
fn rejects_empty_matcher() {
let rule = Rule::from_toml_str(
r#"
id = "x"
language = "rust"
[rule]
"#,
)
.unwrap();
assert!(rule.rule.resolve().is_err());
}
#[test]
fn rejects_unknown_top_level_field() {
let err = Rule::from_toml_str(
r#"
id = "x"
language = "rust"
bogus = true
[rule]
kind = "foo"
"#,
);
assert!(err.is_err());
}
}