use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
#[default]
Warning,
Error,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Safety {
FormatOnly,
BehaviorPreserving,
#[default]
ScopeLocal,
SurfaceChanging,
CapabilityChanging,
NeedsHuman,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Applicability {
MachineApplicable,
Suggestion,
}
impl Applicability {
pub fn as_str(self) -> &'static str {
match self {
Applicability::MachineApplicable => "machine-applicable",
Applicability::Suggestion => "suggestion",
}
}
}
impl Safety {
pub fn as_str(self) -> &'static str {
match self {
Safety::FormatOnly => "format-only",
Safety::BehaviorPreserving => "behavior-preserving",
Safety::ScopeLocal => "scope-local",
Safety::SurfaceChanging => "surface-changing",
Safety::CapabilityChanging => "capability-changing",
Safety::NeedsHuman => "needs-human",
}
}
pub fn applicability(self) -> Applicability {
if self <= Safety::BehaviorPreserving {
Applicability::MachineApplicable
} else {
Applicability::Suggestion
}
}
pub fn is_auto_applicable(self) -> bool {
self.applicability() == Applicability::MachineApplicable
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleKind {
Search,
Lint,
Codemod,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RuleNode {
pub pattern: Option<String>,
pub kind: Option<String>,
pub regex: Option<String>,
pub inside: Option<Box<RuleNode>>,
pub has: Option<Box<RuleNode>>,
pub follows: Option<Box<RuleNode>>,
pub precedes: Option<Box<RuleNode>>,
#[serde(default, alias = "stopBy")]
pub stop_by: Option<StopBy>,
pub field: Option<String>,
pub all: Option<Vec<RuleNode>>,
pub any: Option<Vec<RuleNode>>,
pub not: Option<Box<RuleNode>>,
pub matches: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StopBy {
Keyword(StopKeyword),
Rule(Box<RuleNode>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StopKeyword {
Neighbor,
End,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AtomicMatcher {
Pattern(String),
Kind(String),
Regex(String),
}
impl RuleNode {
pub fn atomic(&self) -> Result<Option<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() {
[] => Ok(None),
[one] => Ok(Some(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 node sets multiple atomic matchers ({}); set at most one",
many.join(", ")
)),
}
}
pub fn is_pure_regex(&self) -> bool {
self.regex.is_some()
&& self.pattern.is_none()
&& self.kind.is_none()
&& self.inside.is_none()
&& self.has.is_none()
&& self.follows.is_none()
&& self.precedes.is_none()
&& self.all.is_none()
&& self.any.is_none()
&& self.not.is_none()
&& self.matches.is_none()
}
pub fn is_empty(&self) -> bool {
self.pattern.is_none()
&& self.kind.is_none()
&& self.regex.is_none()
&& self.inside.is_none()
&& self.has.is_none()
&& self.follows.is_none()
&& self.precedes.is_none()
&& self.all.is_none()
&& self.any.is_none()
&& self.not.is_none()
&& self.matches.is_none()
}
}
#[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,
#[serde(default)]
pub safety: Safety,
pub rule: RuleNode,
#[serde(default)]
pub utils: BTreeMap<String, RuleNode>,
#[serde(default, rename = "where")]
pub where_constraints: Vec<Constraint>,
#[serde(default)]
pub transform: BTreeMap<String, Transform>,
#[serde(default)]
pub fix: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Constraint {
pub metavar: String,
#[serde(default)]
pub regex: Option<String>,
#[serde(default)]
pub comparison: Option<Comparison>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub language: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Comparison {
pub op: String,
pub value: toml::Value,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transform {
pub source: String,
#[serde(default)]
pub replace: Option<ReplaceOp>,
#[serde(default)]
pub substring: Option<SubstringOp>,
#[serde(default)]
pub convert: Option<ConvertOp>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceOp {
pub regex: String,
pub by: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubstringOp {
#[serde(default)]
pub start: Option<i64>,
#[serde(default)]
pub end: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConvertOp {
LowerCamel,
UpperCamel,
Snake,
ScreamingSnake,
Kebab,
Lower,
Upper,
}
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.atomic().unwrap(),
Some(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.atomic().unwrap(),
Some(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.atomic().is_err());
}
#[test]
fn empty_matcher_is_detectable() {
let rule = Rule::from_toml_str(
r#"
id = "x"
language = "rust"
[rule]
"#,
)
.unwrap();
assert_eq!(rule.rule.atomic().unwrap(), None);
assert!(rule.rule.is_empty());
}
#[test]
fn parses_relational_and_composite_keys() {
let rule = Rule::from_toml_str(
r#"
id = "nested"
language = "typescript"
[rule]
pattern = "let $NAME = $INIT"
[rule.inside]
kind = "statement_block"
stopBy = "end"
[rule.not.inside]
kind = "try_statement"
stopBy = "end"
"#,
)
.expect("parses");
assert!(rule.rule.inside.is_some());
assert!(rule.rule.not.is_some());
assert!(rule.rule.not.as_ref().unwrap().inside.is_some());
}
#[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());
}
}