Skip to main content

harn_rules/
model.rs

1//! The declarative rule data model.
2//!
3//! A rule is the atomic unit the engine consumes: an identity (`id`,
4//! `language`, `severity`, `message`), a `rule` block describing *what to
5//! match* (the atomic tier: `pattern` snippet, `kind`, or `regex`), and an
6//! optional `fix` describing *how to rewrite* it. Relational/composite
7//! matching (#2833) and `where`/`transform` (#2834) extend this model;
8//! this module is the atomic-tier surface they build on.
9
10use serde::Deserialize;
11
12/// Diagnostic severity. Mirrors the `harn-lint` vocabulary so findings can
13/// flow into the same reporting surface.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17    /// Informational; no action required.
18    Info,
19    /// Default — something worth a human's attention.
20    #[default]
21    Warning,
22    /// A problem that should block.
23    Error,
24}
25
26/// What flavor of work a rule performs, derived from its shape rather than
27/// declared: a rule with a `fix` is a codemod; one with a `message` but no
28/// `fix` is a lint; a bare matcher is a search.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RuleKind {
31    /// Find-only: report matches, no diagnostic text, no rewrite.
32    Search,
33    /// Report a diagnostic (`message` + `severity`), no rewrite.
34    Lint,
35    /// Rewrite matches via `fix`.
36    Codemod,
37}
38
39/// The atomic-tier matcher. Exactly one of `pattern` / `kind` / `regex`
40/// must be set; [`Matcher::resolve`] enforces that and yields the typed
41/// [`AtomicMatcher`].
42#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Matcher {
45    /// A code snippet in the target grammar with `$VAR` (single-node) and
46    /// `$$$VAR` (variadic) metavariable holes.
47    pub pattern: Option<String>,
48    /// A bare tree-sitter node kind to match (e.g. `"call_expression"`).
49    pub kind: Option<String>,
50    /// A regular expression matched against node text.
51    pub regex: Option<String>,
52}
53
54/// The resolved, exactly-one atomic matcher.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AtomicMatcher {
57    /// A snippet pattern with metavariable holes.
58    Pattern(String),
59    /// A tree-sitter node kind.
60    Kind(String),
61    /// A regex over node text.
62    Regex(String),
63}
64
65impl Matcher {
66    /// Collapse the optional fields into the single atomic form, rejecting
67    /// the zero-or-many cases. Returns `Err` with a human-readable reason.
68    pub fn resolve(&self) -> Result<AtomicMatcher, String> {
69        let set: Vec<&str> = [
70            self.pattern.as_ref().map(|_| "pattern"),
71            self.kind.as_ref().map(|_| "kind"),
72            self.regex.as_ref().map(|_| "regex"),
73        ]
74        .into_iter()
75        .flatten()
76        .collect();
77        match set.as_slice() {
78            [] => Err("rule block sets none of `pattern` / `kind` / `regex`".into()),
79            [one] => Ok(match *one {
80                "pattern" => AtomicMatcher::Pattern(self.pattern.clone().unwrap()),
81                "kind" => AtomicMatcher::Kind(self.kind.clone().unwrap()),
82                _ => AtomicMatcher::Regex(self.regex.clone().unwrap()),
83            }),
84            many => Err(format!(
85                "rule block sets multiple matchers ({}); set exactly one",
86                many.join(", ")
87            )),
88        }
89    }
90}
91
92/// A single declarative rule.
93#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct Rule {
96    /// Stable identifier (also the diagnostic code).
97    pub id: String,
98    /// Target language name (resolved via `harn_hostlib::ast::Language`).
99    pub language: String,
100    /// Diagnostic severity. Defaults to `warning`.
101    #[serde(default)]
102    pub severity: Severity,
103    /// Human-readable diagnostic message. Empty for search-only rules.
104    #[serde(default)]
105    pub message: String,
106    /// The atomic-tier matcher block.
107    pub rule: Matcher,
108    /// Replacement template. Its presence makes the rule a codemod.
109    #[serde(default)]
110    pub fix: Option<String>,
111}
112
113impl Rule {
114    /// Derive the rule's kind from its shape (see [`RuleKind`]).
115    pub fn kind(&self) -> RuleKind {
116        if self.fix.is_some() {
117            RuleKind::Codemod
118        } else if self.message.is_empty() {
119            RuleKind::Search
120        } else {
121            RuleKind::Lint
122        }
123    }
124
125    /// Parse a single rule from a TOML document.
126    pub fn from_toml_str(text: &str) -> Result<Self, Box<toml::de::Error>> {
127        toml::from_str(text).map_err(Box::new)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn parses_a_codemod_rule() {
137        let rule = Rule::from_toml_str(
138            r#"
139            id = "destructure-default"
140            language = "typescript"
141            severity = "warning"
142            message = "Collapse optional-chain default into a destructuring bind"
143            fix = "{ $KEY: $SRC }"
144
145            [rule]
146            pattern = "$SRC?.$KEY ?? $DEFAULT"
147            "#,
148        )
149        .expect("rule parses");
150        assert_eq!(rule.id, "destructure-default");
151        assert_eq!(rule.language, "typescript");
152        assert_eq!(rule.severity, Severity::Warning);
153        assert_eq!(rule.kind(), RuleKind::Codemod);
154        assert_eq!(
155            rule.rule.resolve().unwrap(),
156            AtomicMatcher::Pattern("$SRC?.$KEY ?? $DEFAULT".into())
157        );
158    }
159
160    #[test]
161    fn severity_defaults_to_warning() {
162        let rule = Rule::from_toml_str(
163            r#"
164            id = "x"
165            language = "rust"
166            [rule]
167            kind = "macro_invocation"
168            "#,
169        )
170        .unwrap();
171        assert_eq!(rule.severity, Severity::Warning);
172        // No message, no fix -> a search rule.
173        assert_eq!(rule.kind(), RuleKind::Search);
174    }
175
176    #[test]
177    fn lint_rule_has_message_no_fix() {
178        let rule = Rule::from_toml_str(
179            r#"
180            id = "todo"
181            language = "rust"
182            message = "Found a TODO"
183            [rule]
184            regex = "TODO"
185            "#,
186        )
187        .unwrap();
188        assert_eq!(rule.kind(), RuleKind::Lint);
189        assert_eq!(
190            rule.rule.resolve().unwrap(),
191            AtomicMatcher::Regex("TODO".into())
192        );
193    }
194
195    #[test]
196    fn rejects_multiple_matchers() {
197        let rule = Rule::from_toml_str(
198            r#"
199            id = "x"
200            language = "rust"
201            [rule]
202            kind = "foo"
203            regex = "bar"
204            "#,
205        )
206        .unwrap();
207        assert!(rule.rule.resolve().is_err());
208    }
209
210    #[test]
211    fn rejects_empty_matcher() {
212        let rule = Rule::from_toml_str(
213            r#"
214            id = "x"
215            language = "rust"
216            [rule]
217            "#,
218        )
219        .unwrap();
220        assert!(rule.rule.resolve().is_err());
221    }
222
223    #[test]
224    fn rejects_unknown_top_level_field() {
225        let err = Rule::from_toml_str(
226            r#"
227            id = "x"
228            language = "rust"
229            bogus = true
230            [rule]
231            kind = "foo"
232            "#,
233        );
234        assert!(err.is_err());
235    }
236}