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 std::collections::BTreeMap;
11
12use serde::Deserialize;
13
14/// Diagnostic severity. Mirrors the `harn-lint` vocabulary so findings can
15/// flow into the same reporting surface.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Severity {
19    /// Informational; no action required.
20    Info,
21    /// Default — something worth a human's attention.
22    #[default]
23    Warning,
24    /// A problem that should block.
25    Error,
26}
27
28impl Severity {
29    /// The stable lowercase name (the inverse of the `Deserialize` rename),
30    /// used for diagnostics and JSON surfaces.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Severity::Info => "info",
34            Severity::Warning => "warning",
35            Severity::Error => "error",
36        }
37    }
38}
39
40/// How risky a rule's `fix` is, mapped onto the host's edit-safety taxonomy.
41/// Ordered least → most dangerous; the codemod runner auto-applies only the
42/// two safest tiers (see [`Safety::applicability`]).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Safety {
46    /// Whitespace / formatting only.
47    FormatOnly,
48    /// Semantics-preserving rewrite.
49    BehaviorPreserving,
50    /// Changes behavior, but only within the matched scope. **Default** —
51    /// conservative, so an undeclared codemod does not silently auto-apply.
52    #[default]
53    ScopeLocal,
54    /// Changes an externally-visible surface (a signature, an export).
55    SurfaceChanging,
56    /// Changes capabilities / effects (I/O, permissions).
57    CapabilityChanging,
58    /// Always requires a human in the loop.
59    NeedsHuman,
60}
61
62/// Whether a fix may be auto-applied (clippy/ESLint `machine-applicable`)
63/// or is opt-in only (`suggestion`).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Applicability {
66    /// Safe to auto-apply (`format-only` / `behavior-preserving`).
67    MachineApplicable,
68    /// Preview / opt-in only.
69    Suggestion,
70}
71
72impl Applicability {
73    /// The stable name used for diagnostics and JSON surfaces.
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Applicability::MachineApplicable => "machine-applicable",
77            Applicability::Suggestion => "suggestion",
78        }
79    }
80}
81
82impl Safety {
83    /// The stable kebab-case name (the inverse of the `Deserialize` rename),
84    /// used for diagnostics and JSON surfaces.
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Safety::FormatOnly => "format-only",
88            Safety::BehaviorPreserving => "behavior-preserving",
89            Safety::ScopeLocal => "scope-local",
90            Safety::SurfaceChanging => "surface-changing",
91            Safety::CapabilityChanging => "capability-changing",
92            Safety::NeedsHuman => "needs-human",
93        }
94    }
95
96    /// The applicability tier this safety level maps to. `format-only` and
97    /// `behavior-preserving` are machine-applicable; everything riskier is a
98    /// suggestion.
99    pub fn applicability(self) -> Applicability {
100        if self <= Safety::BehaviorPreserving {
101            Applicability::MachineApplicable
102        } else {
103            Applicability::Suggestion
104        }
105    }
106
107    /// True when the runner may auto-apply this rule's fix without an
108    /// explicit opt-in.
109    pub fn is_auto_applicable(self) -> bool {
110        self.applicability() == Applicability::MachineApplicable
111    }
112}
113
114/// What flavor of work a rule performs, derived from its shape rather than
115/// declared: a rule with a `fix` is a codemod; one with a `message` but no
116/// `fix` is a lint; a bare matcher is a search.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RuleKind {
119    /// Find-only: report matches, no diagnostic text, no rewrite.
120    Search,
121    /// Report a diagnostic (`message` + `severity`), no rewrite.
122    Lint,
123    /// Rewrite matches via `fix`.
124    Codemod,
125}
126
127/// The atomic-tier matcher. Exactly one of `pattern` / `kind` / `regex`
128/// must be set on a node that carries one; [`RuleNode::atomic`] resolves it.
129///
130/// A `RuleNode` is the recursive matching algebra: an optional **atomic**
131/// leaf (`pattern` / `kind` / `regex`), **relational** constraints
132/// (`inside` / `has` / `follows` / `precedes`, each a sub-node tuned by
133/// `stop_by` / `field`), and **composite** combinators (`all` / `any` /
134/// `not` / `matches`). Every key set on a node is ANDed: the node matches a
135/// tree-sitter node iff its atomic part matches *and* every relational and
136/// composite part holds.
137#[derive(Debug, Clone, Default, Deserialize)]
138pub struct RuleNode {
139    /// A code snippet in the target grammar with `$VAR` metavariable holes.
140    pub pattern: Option<String>,
141    /// A bare tree-sitter node kind (e.g. `"call_expression"`).
142    pub kind: Option<String>,
143    /// A regular expression matched against node text.
144    pub regex: Option<String>,
145    /// A raw tree-sitter query (S-expression), matched directly against the
146    /// tree — an escape hatch from `pattern` snippet lowering for structural
147    /// selections a snippet cannot express (e.g. capturing an anonymous
148    /// keyword token). It **must** bind the matched node to `@__match`
149    /// ([`crate::pattern::ROOT_CAPTURE`]); every other named capture becomes a
150    /// metavar binding, usable in `fix` and selectable via the rule's
151    /// `fixTarget` for surgical, sub-node rewrites.
152    pub query: Option<String>,
153
154    /// The node must be **inside** a node matching this sub-rule (ancestor).
155    pub inside: Option<Box<RuleNode>>,
156    /// The node must **have** a descendant matching this sub-rule.
157    pub has: Option<Box<RuleNode>>,
158    /// The node must **follow** a node matching this sub-rule (earlier).
159    pub follows: Option<Box<RuleNode>>,
160    /// The node must **precede** a node matching this sub-rule (later).
161    pub precedes: Option<Box<RuleNode>>,
162
163    /// Relational reach (used when this node is an `inside`/`has`/… target):
164    /// `neighbor` (direct only, default), `end` (transitive), or a rule that
165    /// halts the walk. (TOML `stopBy` or `stop_by`.)
166    #[serde(default, alias = "stopBy")]
167    pub stop_by: Option<StopBy>,
168    /// Restrict an `inside`/`has` relation to a specific tree-sitter field.
169    pub field: Option<String>,
170
171    /// Every sub-rule must match the node.
172    pub all: Option<Vec<RuleNode>>,
173    /// At least one sub-rule must match the node.
174    pub any: Option<Vec<RuleNode>>,
175    /// The sub-rule must NOT match the node.
176    pub not: Option<Box<RuleNode>>,
177    /// Reference a utility rule by id (resolved from `[utils]`).
178    pub matches: Option<String>,
179}
180
181/// How far a relational op (`inside` / `has` / `follows` / `precedes`)
182/// walks the tree looking for a match.
183#[derive(Debug, Clone, Deserialize)]
184#[serde(untagged)]
185pub enum StopBy {
186    /// `"neighbor"` (direct parent/child/sibling only) or `"end"`
187    /// (transitive — walk to the tree boundary).
188    Keyword(StopKeyword),
189    /// Walk until a node matching this rule is reached, then stop.
190    Rule(Box<RuleNode>),
191}
192
193/// The keyword forms of [`StopBy`].
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum StopKeyword {
197    /// Only the immediate neighbor (default).
198    Neighbor,
199    /// Transitive — walk all the way to the tree boundary.
200    End,
201}
202
203/// The resolved, exactly-one atomic matcher.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum AtomicMatcher {
206    /// A snippet pattern with metavariable holes.
207    Pattern(String),
208    /// A tree-sitter node kind.
209    Kind(String),
210    /// A regex over node text.
211    Regex(String),
212    /// A raw tree-sitter query, used verbatim (must bind `@__match`).
213    RawQuery(String),
214}
215
216impl RuleNode {
217    /// Resolve this node's atomic leaf. `Ok(None)` when the node is purely
218    /// relational/composite; `Err` when more than one atomic key is set.
219    pub fn atomic(&self) -> Result<Option<AtomicMatcher>, String> {
220        let set: Vec<&str> = [
221            self.pattern.as_ref().map(|_| "pattern"),
222            self.kind.as_ref().map(|_| "kind"),
223            self.regex.as_ref().map(|_| "regex"),
224            self.query.as_ref().map(|_| "query"),
225        ]
226        .into_iter()
227        .flatten()
228        .collect();
229        match set.as_slice() {
230            [] => Ok(None),
231            [one] => Ok(Some(match *one {
232                "pattern" => AtomicMatcher::Pattern(self.pattern.clone().unwrap()),
233                "kind" => AtomicMatcher::Kind(self.kind.clone().unwrap()),
234                "query" => AtomicMatcher::RawQuery(self.query.clone().unwrap()),
235                _ => AtomicMatcher::Regex(self.regex.clone().unwrap()),
236            })),
237            many => Err(format!(
238                "rule node sets multiple atomic matchers ({}); set at most one",
239                many.join(", ")
240            )),
241        }
242    }
243
244    /// True when `regex` is the only key set — a top-level grep-style rule
245    /// that scans source text rather than the tree.
246    pub fn is_pure_regex(&self) -> bool {
247        self.regex.is_some()
248            && self.pattern.is_none()
249            && self.kind.is_none()
250            && self.query.is_none()
251            && self.inside.is_none()
252            && self.has.is_none()
253            && self.follows.is_none()
254            && self.precedes.is_none()
255            && self.all.is_none()
256            && self.any.is_none()
257            && self.not.is_none()
258            && self.matches.is_none()
259    }
260
261    /// True when the node sets no matching keys at all (an empty node, which
262    /// is a rule authoring error).
263    pub fn is_empty(&self) -> bool {
264        self.pattern.is_none()
265            && self.kind.is_none()
266            && self.regex.is_none()
267            && self.query.is_none()
268            && self.inside.is_none()
269            && self.has.is_none()
270            && self.follows.is_none()
271            && self.precedes.is_none()
272            && self.all.is_none()
273            && self.any.is_none()
274            && self.not.is_none()
275            && self.matches.is_none()
276    }
277}
278
279/// A single declarative rule.
280#[derive(Debug, Clone, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct Rule {
283    /// Stable identifier (also the diagnostic code).
284    pub id: String,
285    /// Target language name (resolved via `harn_hostlib::ast::Language`).
286    pub language: String,
287    /// Diagnostic severity. Defaults to `warning`.
288    #[serde(default)]
289    pub severity: Severity,
290    /// Human-readable diagnostic message. Empty for search-only rules.
291    #[serde(default)]
292    pub message: String,
293    /// How risky the `fix` is. Gates auto-apply. Defaults to `scope-local`.
294    #[serde(default)]
295    pub safety: Safety,
296    /// The matcher block (atomic / relational / composite algebra).
297    pub rule: RuleNode,
298    /// Local utility rules referenced by `matches`, keyed by id.
299    /// (TOML `[utils.NAME]`.)
300    #[serde(default)]
301    pub utils: BTreeMap<String, RuleNode>,
302    /// Predicates on captured metavars; a match survives only when every
303    /// constraint holds. (TOML `[[where]]`.)
304    #[serde(default, rename = "where")]
305    pub where_constraints: Vec<Constraint>,
306    /// Derived metavars synthesized from captured ones before `fix`
307    /// interpolation, keyed by the new metavar name. (TOML `[transform.X]`.)
308    #[serde(default)]
309    pub transform: BTreeMap<String, Transform>,
310    /// Replacement template. Its presence makes the rule a codemod.
311    #[serde(default)]
312    pub fix: Option<String>,
313    /// When set, the `fix` replaces only the span of this named capture
314    /// instead of the whole matched node — enabling surgical, sub-node
315    /// rewrites (e.g. swap a binding's keyword while preserving its type
316    /// annotation and initializer). Names a capture bound by a raw `query`
317    /// or a `$VAR` metavar; the capture must be present on every match.
318    /// Defaults to the whole match. (TOML `fixTarget` / `target`.)
319    #[serde(default, alias = "fixTarget", alias = "target")]
320    pub fix_target: Option<String>,
321}
322
323/// A `where` predicate on a captured metavar. Exactly one of `regex` /
324/// `comparison` / `pattern` / `resolves_to` / `type` is set.
325#[derive(Debug, Clone, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct Constraint {
328    /// The metavar this constraint applies to (without the leading `$`).
329    pub metavar: String,
330    /// The metavar's text must match this regex.
331    #[serde(default)]
332    pub regex: Option<String>,
333    /// The metavar's text, parsed as a number, must satisfy this
334    /// comparison (Semgrep `metavariable-comparison`).
335    #[serde(default)]
336    pub comparison: Option<Comparison>,
337    /// A sub-pattern (Semgrep `metavariable-pattern`) run against the
338    /// metavar's captured text; the constraint holds when it matches.
339    #[serde(default)]
340    pub pattern: Option<String>,
341    /// Harn-only semantic filter: the captured node must resolve to a
342    /// declaration/binding matching this identity.
343    #[serde(default, alias = "resolvesTo")]
344    pub resolves_to: Option<ResolvedBindingConstraint>,
345    /// Harn-only semantic filter: the capture's attributed type must equal
346    /// this label.
347    #[serde(default, rename = "type")]
348    pub type_: Option<String>,
349    /// Optional language override for `pattern` — lets a captured string
350    /// literal be matched in a different grammar than the host file.
351    #[serde(default)]
352    pub language: Option<String>,
353}
354
355/// A Harn resolved-binding predicate for [`Constraint::resolves_to`].
356#[derive(Debug, Clone, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct ResolvedBindingConstraint {
359    /// Exact resolved id (`<kind>:<name>@<line>:<column>`), if supplied.
360    #[serde(default)]
361    pub id: Option<String>,
362    /// Binding/declaration name.
363    #[serde(default)]
364    pub name: Option<String>,
365    /// Binding kind (`fn`, `param`, `let`, ...).
366    #[serde(default)]
367    pub kind: Option<String>,
368    /// 1-based declaration/binding line.
369    #[serde(default)]
370    pub line: Option<usize>,
371    /// 1-based declaration/binding column.
372    #[serde(default)]
373    pub column: Option<usize>,
374}
375
376/// A numeric/string comparison for a [`Constraint`].
377#[derive(Debug, Clone, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct Comparison {
380    /// One of `<` `<=` `>` `>=` `==` `!=`.
381    pub op: String,
382    /// The right-hand side. Numbers compare numerically; strings/bools
383    /// compare with `==` / `!=` only.
384    pub value: toml::Value,
385}
386
387/// A metavar transform: read `source`, apply exactly one operation, bind
388/// the result under a new metavar name (the map key).
389#[derive(Debug, Clone, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct Transform {
392    /// The source metavar name (without `$`) whose text is transformed.
393    pub source: String,
394    /// Regex find/replace.
395    #[serde(default)]
396    pub replace: Option<ReplaceOp>,
397    /// A character-index slice.
398    #[serde(default)]
399    pub substring: Option<SubstringOp>,
400    /// A case conversion.
401    #[serde(default)]
402    pub convert: Option<ConvertOp>,
403}
404
405/// Regex find/replace transform op.
406#[derive(Debug, Clone, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct ReplaceOp {
409    /// The regex to find.
410    pub regex: String,
411    /// The replacement (supports `$1` capture refs).
412    pub by: String,
413}
414
415/// Character-slice transform op. Indices are 0-based char offsets; a
416/// negative or omitted bound clamps to the string end.
417#[derive(Debug, Clone, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct SubstringOp {
420    /// Inclusive start char index (default 0).
421    #[serde(default)]
422    pub start: Option<i64>,
423    /// Exclusive end char index (default: end of string).
424    #[serde(default)]
425    pub end: Option<i64>,
426}
427
428/// Case-conversion transform op.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
430#[serde(rename_all = "snake_case")]
431pub enum ConvertOp {
432    /// `lowerCamelCase`.
433    LowerCamel,
434    /// `UpperCamelCase`.
435    UpperCamel,
436    /// `snake_case`.
437    Snake,
438    /// `SCREAMING_SNAKE_CASE`.
439    ScreamingSnake,
440    /// `kebab-case`.
441    Kebab,
442    /// `lowercase`.
443    Lower,
444    /// `UPPERCASE`.
445    Upper,
446}
447
448impl Rule {
449    /// Derive the rule's kind from its shape (see [`RuleKind`]).
450    pub fn kind(&self) -> RuleKind {
451        if self.fix.is_some() {
452            RuleKind::Codemod
453        } else if self.message.is_empty() {
454            RuleKind::Search
455        } else {
456            RuleKind::Lint
457        }
458    }
459
460    /// Parse a single rule from a TOML document.
461    pub fn from_toml_str(text: &str) -> Result<Self, Box<toml::de::Error>> {
462        toml::from_str(text).map_err(Box::new)
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn parses_a_codemod_rule() {
472        let rule = Rule::from_toml_str(
473            r#"
474            id = "destructure-default"
475            language = "typescript"
476            severity = "warning"
477            message = "Collapse optional-chain default into a destructuring bind"
478            fix = "{ $KEY: $SRC }"
479
480            [rule]
481            pattern = "$SRC?.$KEY ?? $DEFAULT"
482            "#,
483        )
484        .expect("rule parses");
485        assert_eq!(rule.id, "destructure-default");
486        assert_eq!(rule.language, "typescript");
487        assert_eq!(rule.severity, Severity::Warning);
488        assert_eq!(rule.kind(), RuleKind::Codemod);
489        assert_eq!(
490            rule.rule.atomic().unwrap(),
491            Some(AtomicMatcher::Pattern("$SRC?.$KEY ?? $DEFAULT".into()))
492        );
493    }
494
495    #[test]
496    fn severity_defaults_to_warning() {
497        let rule = Rule::from_toml_str(
498            r#"
499            id = "x"
500            language = "rust"
501            [rule]
502            kind = "macro_invocation"
503            "#,
504        )
505        .unwrap();
506        assert_eq!(rule.severity, Severity::Warning);
507        // No message, no fix -> a search rule.
508        assert_eq!(rule.kind(), RuleKind::Search);
509    }
510
511    #[test]
512    fn lint_rule_has_message_no_fix() {
513        let rule = Rule::from_toml_str(
514            r#"
515            id = "todo"
516            language = "rust"
517            message = "Found a TODO"
518            [rule]
519            regex = "TODO"
520            "#,
521        )
522        .unwrap();
523        assert_eq!(rule.kind(), RuleKind::Lint);
524        assert_eq!(
525            rule.rule.atomic().unwrap(),
526            Some(AtomicMatcher::Regex("TODO".into()))
527        );
528    }
529
530    #[test]
531    fn rejects_multiple_matchers() {
532        let rule = Rule::from_toml_str(
533            r#"
534            id = "x"
535            language = "rust"
536            [rule]
537            kind = "foo"
538            regex = "bar"
539            "#,
540        )
541        .unwrap();
542        assert!(rule.rule.atomic().is_err());
543    }
544
545    #[test]
546    fn empty_matcher_is_detectable() {
547        let rule = Rule::from_toml_str(
548            r#"
549            id = "x"
550            language = "rust"
551            [rule]
552            "#,
553        )
554        .unwrap();
555        // An empty node sets no atomic key (Ok(None)) and is flagged empty.
556        assert_eq!(rule.rule.atomic().unwrap(), None);
557        assert!(rule.rule.is_empty());
558    }
559
560    #[test]
561    fn parses_relational_and_composite_keys() {
562        let rule = Rule::from_toml_str(
563            r#"
564            id = "nested"
565            language = "typescript"
566            [rule]
567            pattern = "let $NAME = $INIT"
568            [rule.inside]
569            kind = "statement_block"
570            stopBy = "end"
571            [rule.not.inside]
572            kind = "try_statement"
573            stopBy = "end"
574            "#,
575        )
576        .expect("parses");
577        assert!(rule.rule.inside.is_some());
578        assert!(rule.rule.not.is_some());
579        assert!(rule.rule.not.as_ref().unwrap().inside.is_some());
580    }
581
582    #[test]
583    fn rejects_unknown_top_level_field() {
584        let err = Rule::from_toml_str(
585            r#"
586            id = "x"
587            language = "rust"
588            bogus = true
589            [rule]
590            kind = "foo"
591            "#,
592        );
593        assert!(err.is_err());
594    }
595
596    #[test]
597    fn parses_semantic_where_constraints() {
598        let rule = Rule::from_toml_str(
599            r#"
600            id = "x"
601            language = "harn"
602            [rule]
603            pattern = "$FN($ARG)"
604
605            [[where]]
606            metavar = "FN"
607            resolvesTo = { name = "target", kind = "fn", line = 1, column = 4 }
608
609            [[where]]
610            metavar = "ARG"
611            type = "int"
612            "#,
613        )
614        .unwrap();
615        assert_eq!(rule.where_constraints.len(), 2);
616        let resolved = rule.where_constraints[0].resolves_to.as_ref().unwrap();
617        assert_eq!(resolved.name.as_deref(), Some("target"));
618        assert_eq!(resolved.kind.as_deref(), Some("fn"));
619        assert_eq!(resolved.line, Some(1));
620        assert_eq!(resolved.column, Some(4));
621        assert_eq!(rule.where_constraints[1].type_.as_deref(), Some("int"));
622    }
623}