Skip to main content

harn_rules/
engine.rs

1//! Compile a [`Rule`] into a runnable matcher and run it against source.
2//!
3//! The atomic tier supports three matcher forms, all reduced to a single
4//! [`RuleMatch`] stream:
5//!
6//! - `pattern` → compiled to a tree-sitter query via [`crate::pattern`].
7//! - `kind` → the trivial query `(<kind>) @__match`.
8//! - `regex` → a text regex over the source, yielding spans with no AST
9//!   metavar bindings.
10
11use std::collections::BTreeMap;
12
13use harn_hostlib::ast::Language;
14use serde::Serialize;
15
16use crate::constraint::CompiledConstraint;
17use crate::error::RulesError;
18use crate::evaluator::CompiledRuleTree;
19use crate::fix::{interpolate, splice, AppliedEdit};
20use crate::model::{Applicability, Rule, Safety, Severity};
21use crate::semantic::enrich_harn_matches;
22use crate::transform::CompiledTransform;
23
24/// A byte + row/col span. Rows/cols are 0-based, matching the rest of the
25/// Harn AST wire format.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27pub struct Span {
28    /// Start byte offset.
29    pub start_byte: usize,
30    /// End byte offset (exclusive).
31    pub end_byte: usize,
32    /// 0-based start row.
33    pub start_row: usize,
34    /// 0-based start column.
35    pub start_col: usize,
36    /// 0-based end row.
37    pub end_row: usize,
38    /// 0-based end column.
39    pub end_col: usize,
40}
41
42impl Span {
43    pub(crate) fn of(node: tree_sitter::Node<'_>) -> Self {
44        let start = node.start_position();
45        let end = node.end_position();
46        Span {
47            start_byte: node.start_byte(),
48            end_byte: node.end_byte(),
49            start_row: start.row,
50            start_col: start.column,
51            end_row: end.row,
52            end_col: end.column,
53        }
54    }
55}
56
57/// Semantic metadata for a Harn capture. Populated for Harn sources when the
58/// engine can resolve the captured node to a local declaration/binding or infer
59/// a simple static type.
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
61pub struct BindingMetadata {
62    /// Resolved Harn declaration/binding identity for this capture.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub resolved: Option<ResolvedBinding>,
65    /// Static type label for this capture.
66    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
67    pub ty: Option<String>,
68}
69
70impl BindingMetadata {
71    /// True when no semantic metadata is available.
72    pub fn is_empty(&self) -> bool {
73        self.resolved.is_none() && self.ty.is_none()
74    }
75}
76
77/// A resolved Harn declaration or binding identity.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct ResolvedBinding {
80    /// Stable id: `<kind>:<name>@<line>:<column>` (1-based line/column).
81    pub id: String,
82    /// Binding name.
83    pub name: String,
84    /// Binding kind (`fn`, `pipeline`, `tool`, `struct`, `type`, `let`,
85    /// `var`, `const`, `param`, ...).
86    pub kind: String,
87    /// Span of the declaration/binding name.
88    #[serde(flatten)]
89    pub span: Span,
90}
91
92/// A metavariable binding: the captured text plus where it lives.
93#[derive(Debug, Clone)]
94pub struct Binding {
95    /// The captured source text.
96    pub text: String,
97    /// The captured node's span.
98    pub span: Span,
99    /// Optional Harn semantic metadata for this capture.
100    pub metadata: BindingMetadata,
101}
102
103impl Binding {
104    pub(crate) fn new(text: String, span: Span) -> Self {
105        Binding {
106            text,
107            span,
108            metadata: BindingMetadata::default(),
109        }
110    }
111}
112
113/// One match of a rule against a file.
114#[derive(Debug, Clone)]
115pub struct RuleMatch {
116    /// The rule that produced this match.
117    pub rule_id: String,
118    /// The whole matched range (the pattern root, or the regex span).
119    pub span: Span,
120    /// The matched source text.
121    pub text: String,
122    /// Metavar bindings, keyed by name (without the leading `$`). Empty for
123    /// `kind` and `regex` matchers.
124    pub bindings: BTreeMap<String, Binding>,
125}
126
127/// The result of applying a codemod rule's `fix` to a source string.
128#[derive(Debug, Clone)]
129pub struct CodemodResult {
130    /// The rewritten source (equals the input when nothing matched).
131    pub rewritten: String,
132    /// The per-match edits that were spliced in, in document order.
133    pub edits: Vec<AppliedEdit>,
134    /// Whether the rewrite changed the source.
135    pub changed: bool,
136    /// The rule's declared safety tier.
137    pub safety: Safety,
138    /// Whether the fix may be auto-applied or is opt-in only.
139    pub applicability: Applicability,
140    /// Whether re-running the fix on `rewritten` yields no further change
141    /// (a fix should reach a fixed point).
142    pub idempotent: bool,
143}
144
145/// A rule whose matcher has been compiled and is ready to run.
146pub struct CompiledRule {
147    rule_id: String,
148    language: Language,
149    execution: Execution,
150    /// `where` predicates; a match survives only when all hold.
151    constraints: Vec<CompiledConstraint>,
152    /// `transform` definitions: (new metavar name, compiled transform).
153    transforms: Vec<(String, CompiledTransform)>,
154    /// The `fix` replacement template, if this is a codemod.
155    fix: Option<String>,
156    /// When set, the `fix` replaces only this capture's span (surgical
157    /// sub-node rewrite) rather than the whole matched node.
158    fix_target: Option<String>,
159    /// The fix's safety tier (gates auto-apply).
160    safety: Safety,
161    /// The diagnostic message (empty for search-only rules).
162    message: String,
163    /// The diagnostic severity.
164    severity: Severity,
165}
166
167/// A diagnostic produced by running a rule — the mapping surface onto the
168/// linter's `LintDiagnostic` / `FixEdit` (Epic C / the LSP reuse this).
169#[derive(Debug, Clone)]
170pub struct Diagnostic {
171    /// The rule id (also the diagnostic code).
172    pub rule_id: String,
173    /// The diagnostic message.
174    pub message: String,
175    /// The severity.
176    pub severity: Severity,
177    /// The flagged span.
178    pub span: Span,
179    /// Whether a fix, if present, is auto-applicable or a suggestion.
180    pub applicability: Applicability,
181    /// The interpolated fix replacement for this match, if the rule has a
182    /// `fix`.
183    pub fix: Option<String>,
184}
185
186enum Execution {
187    /// A top-level pure-`regex` rule: scan the raw source text (grep-style),
188    /// independent of the tree. Its match span is the regex match range.
189    SourceRegex(regex::Regex),
190    /// The full matching algebra (atomic + relational + composite).
191    Tree(Box<CompiledRuleTree>),
192}
193
194impl CompiledRule {
195    /// Resolve the rule's language and grammar, then compile its matcher.
196    pub fn compile(rule: &Rule) -> Result<Self, RulesError> {
197        let language =
198            Language::from_name(&rule.language).ok_or_else(|| RulesError::UnknownLanguage {
199                rule: rule.id.clone(),
200                language: rule.language.clone(),
201            })?;
202
203        // A top-level pure-`regex` rule greps the source text directly; any
204        // other shape (pattern / kind / relational / composite) compiles to
205        // the tree-walking algebra.
206        let execution = if rule.rule.is_pure_regex() {
207            let pattern = rule.rule.regex.as_ref().expect("pure regex");
208            Execution::SourceRegex(regex::Regex::new(pattern).map_err(|err| {
209                RulesError::PatternCompile {
210                    rule: rule.id.clone(),
211                    message: format!("invalid regex `{pattern}`: {err}"),
212                }
213            })?)
214        } else {
215            Execution::Tree(Box::new(CompiledRuleTree::compile(
216                &rule.id,
217                language,
218                &rule.rule,
219                &rule.utils,
220            )?))
221        };
222
223        let constraints = rule
224            .where_constraints
225            .iter()
226            .map(|c| CompiledConstraint::compile(&rule.id, language, c))
227            .collect::<Result<Vec<_>, _>>()?;
228
229        let transforms = rule
230            .transform
231            .iter()
232            .map(|(name, t)| {
233                CompiledTransform::compile(&rule.id, name, t).map(|c| (name.clone(), c))
234            })
235            .collect::<Result<Vec<_>, _>>()?;
236
237        Ok(CompiledRule {
238            rule_id: rule.id.clone(),
239            language,
240            execution,
241            constraints,
242            transforms,
243            fix: rule.fix.clone(),
244            fix_target: rule.fix_target.clone(),
245            safety: rule.safety,
246            message: rule.message.clone(),
247            severity: rule.severity,
248        })
249    }
250
251    /// The language this rule targets.
252    pub fn language(&self) -> Language {
253        self.language
254    }
255
256    /// The fix's declared safety tier.
257    pub fn safety(&self) -> Safety {
258        self.safety
259    }
260
261    /// Whether this rule's fix may be auto-applied (machine-applicable) or
262    /// is opt-in only (suggestion).
263    pub fn applicability(&self) -> Applicability {
264        self.safety.applicability()
265    }
266
267    /// The rule's id.
268    pub fn id(&self) -> &str {
269        &self.rule_id
270    }
271
272    /// The rule's diagnostic severity (the default for any diagnostic the
273    /// rule produces).
274    pub fn severity(&self) -> Severity {
275        self.severity
276    }
277
278    /// The rule's static diagnostic message (empty for a search-only rule).
279    pub fn message(&self) -> &str {
280        &self.message
281    }
282
283    /// Run the compiled rule against `source`, returning matches in
284    /// document order. Matches that fail any `where` constraint are dropped.
285    pub fn run(&self, source: &str) -> Result<Vec<RuleMatch>, RulesError> {
286        let mut matches = match &self.execution {
287            Execution::SourceRegex(regex) => self.run_regex(regex, source),
288            Execution::Tree(tree) => tree
289                .find(&self.rule_id, self.language, source)?
290                .into_iter()
291                .map(|m| RuleMatch {
292                    rule_id: self.rule_id.clone(),
293                    span: m.span,
294                    text: m.text,
295                    bindings: m.bindings,
296                })
297                .collect(),
298        };
299        if self.language == Language::Harn && !matches.is_empty() {
300            enrich_harn_matches(source, &mut matches).map_err(|message| {
301                RulesError::SourceParse {
302                    rule: self.rule_id.clone(),
303                    message,
304                }
305            })?;
306        }
307        if !self.constraints.is_empty() {
308            matches.retain(|m| self.satisfies_constraints(m));
309        }
310        Ok(matches)
311    }
312
313    /// True when every `where` constraint holds for this match. A
314    /// constraint whose metavar is unbound (not captured) fails closed.
315    fn satisfies_constraints(&self, m: &RuleMatch) -> bool {
316        self.constraints
317            .iter()
318            .all(|c| m.bindings.get(&c.metavar).is_some_and(|b| c.evaluate(b)))
319    }
320
321    /// Apply this codemod rule's `fix` to `source`, returning the rewritten
322    /// text plus the per-match edits. Each match's `fix` template is
323    /// interpolated from its captured metavars plus any `transform`-derived
324    /// ones. Errors if the rule has no `fix`.
325    ///
326    /// This computes the preview only — it does not enforce the safety gate.
327    /// Use [`CompiledRule::auto_apply`] to refuse non-machine-applicable
328    /// fixes, or [`CompiledRule::apply_checked`] to also assert idempotency.
329    pub fn apply(&self, source: &str) -> Result<CodemodResult, RulesError> {
330        let (rewritten, edits) = self.rewrite(source)?;
331        let changed = rewritten != source;
332        // Idempotency: re-running the fix on its own output must not change
333        // it further (the fix should reach a fixed point).
334        let (twice, _) = self.rewrite(&rewritten)?;
335        let idempotent = twice == rewritten;
336        Ok(CodemodResult {
337            rewritten,
338            edits,
339            changed,
340            safety: self.safety,
341            applicability: self.applicability(),
342            idempotent,
343        })
344    }
345
346    /// Like [`CompiledRule::apply`], but refuses to apply a fix whose
347    /// `safety` is above the machine-applicable threshold (`scope-local` and
348    /// riskier). This is the gate `harn codemod --apply` uses by default.
349    pub fn auto_apply(&self, source: &str) -> Result<CodemodResult, RulesError> {
350        if !self.safety.is_auto_applicable() {
351            return Err(RulesError::NotAutoApplicable {
352                rule: self.rule_id.clone(),
353                safety: format!("{:?}", self.safety),
354            });
355        }
356        self.apply(source)
357    }
358
359    /// Like [`CompiledRule::apply`], but fails if the fix is not idempotent.
360    /// Used by the codemod runner and the rule-test harness to assert a fix
361    /// reaches a fixed point.
362    pub fn apply_checked(&self, source: &str) -> Result<CodemodResult, RulesError> {
363        let result = self.apply(source)?;
364        if !result.idempotent {
365            return Err(RulesError::NotIdempotent {
366                rule: self.rule_id.clone(),
367            });
368        }
369        Ok(result)
370    }
371
372    /// Run the rule and produce one [`Diagnostic`] per match — the surface
373    /// the linter (Epic C) and the LSP convert into `LintDiagnostic` /
374    /// `FixEdit`. Each diagnostic carries the interpolated fix (if any) and
375    /// its applicability tier.
376    pub fn diagnostics(&self, source: &str) -> Result<Vec<Diagnostic>, RulesError> {
377        let applicability = self.applicability();
378        let matches = self.run(source)?;
379        matches
380            .iter()
381            .map(|m| {
382                // With a `fixTarget`, the fix replaces only the target
383                // capture's span, so the diagnostic (which the LSP applies
384                // `fix` over) is anchored there; otherwise the whole match.
385                let span = if self.fix.is_some() && self.fix_target.is_some() {
386                    self.edit_target(m)?.0
387                } else {
388                    m.span
389                };
390                Ok(Diagnostic {
391                    rule_id: self.rule_id.clone(),
392                    message: self.message.clone(),
393                    severity: self.severity,
394                    span,
395                    applicability,
396                    fix: self.fix.as_ref().map(|template| {
397                        let vars = self.metavars_for(m);
398                        interpolate(template, &vars)
399                    }),
400                })
401            })
402            .collect()
403    }
404
405    /// The core rewrite: run the rule and splice each match's interpolated
406    /// fix. Returns the rewritten text and the edits.
407    fn rewrite(&self, source: &str) -> Result<(String, Vec<AppliedEdit>), RulesError> {
408        let template = self
409            .fix
410            .as_ref()
411            .ok_or_else(|| RulesError::PatternCompile {
412                rule: self.rule_id.clone(),
413                message: "apply requires a `fix` template; this rule has none".into(),
414            })?;
415
416        let matches = dedupe_overlapping(self.run(source)?);
417        let edits: Vec<AppliedEdit> = matches
418            .iter()
419            .map(|m| {
420                let vars = self.metavars_for(m);
421                let (span, before) = self.edit_target(m)?;
422                Ok(AppliedEdit {
423                    span,
424                    before,
425                    replacement: interpolate(template, &vars),
426                })
427            })
428            .collect::<Result<_, RulesError>>()?;
429        Ok((splice(source, &edits), edits))
430    }
431
432    /// The span + original text a match's `fix` replaces. Honors `fixTarget`
433    /// (surgical sub-node rewrite); defaults to the whole matched node.
434    fn edit_target(&self, m: &RuleMatch) -> Result<(Span, String), RulesError> {
435        match &self.fix_target {
436            None => Ok((m.span, m.text.clone())),
437            Some(name) => {
438                let binding =
439                    m.bindings
440                        .get(name)
441                        .ok_or_else(|| RulesError::PatternCompile {
442                            rule: self.rule_id.clone(),
443                            message: format!(
444                                "fixTarget `{name}` is not bound by this match; name a capture the matcher always binds"
445                            ),
446                        })?;
447                Ok((binding.span, binding.text.clone()))
448            }
449        }
450    }
451
452    /// Build the full metavar map for a match: captured bindings plus the
453    /// `transform`-synthesized metavars (which may shadow captures).
454    fn metavars_for(&self, m: &RuleMatch) -> BTreeMap<String, String> {
455        let mut vars: BTreeMap<String, String> = m
456            .bindings
457            .iter()
458            .map(|(name, binding)| (name.clone(), binding.text.clone()))
459            .collect();
460        for (name, transform) in &self.transforms {
461            let input = m
462                .bindings
463                .get(&transform.source)
464                .map(|b| b.text.as_str())
465                .unwrap_or("");
466            vars.insert(name.clone(), transform.apply(input));
467        }
468        vars
469    }
470
471    fn run_regex(&self, regex: &regex::Regex, source: &str) -> Vec<RuleMatch> {
472        let mut matches = Vec::new();
473        // `find_iter` yields non-overlapping matches in ascending byte order, so
474        // a single forward-walking cursor computes every row/col without
475        // rescanning from the start of the document for each match (which made
476        // this O(matches × len) — quadratic on files with many hits).
477        let mut cursor = RowColCursor::new(source);
478        for m in regex.find_iter(source) {
479            let (start_row, start_col) = cursor.advance_to(m.start());
480            let (end_row, end_col) = cursor.advance_to(m.end());
481            matches.push(RuleMatch {
482                rule_id: self.rule_id.clone(),
483                span: Span {
484                    start_byte: m.start(),
485                    end_byte: m.end(),
486                    start_row,
487                    start_col,
488                    end_row,
489                    end_col,
490                },
491                text: m.as_str().to_string(),
492                bindings: BTreeMap::new(),
493            });
494        }
495        matches
496    }
497}
498
499/// Drop matches that overlap an already-kept match, keeping the **outermost**
500/// (and, among equal extents, the first). A tree query naturally yields nested
501/// matches — e.g. `$X + $Y` matches both `(a+b)+c` and its inner `a+b` — and
502/// splicing both would apply overlapping byte edits, corrupting the output or
503/// panicking `replace_range` on a stale offset. A codemod must rewrite each
504/// region once, so we keep the enclosing match and discard anything nested in
505/// or straddling it. Matches are returned in document (start-byte) order.
506fn dedupe_overlapping(mut matches: Vec<RuleMatch>) -> Vec<RuleMatch> {
507    // Outermost-first: smallest start, then largest end (widest span wins a tie
508    // on start). After filtering we restore document order.
509    matches.sort_by(|a, b| {
510        a.span
511            .start_byte
512            .cmp(&b.span.start_byte)
513            .then(b.span.end_byte.cmp(&a.span.end_byte))
514    });
515    let mut kept: Vec<RuleMatch> = Vec::with_capacity(matches.len());
516    let mut covered_to = 0usize; // exclusive end of the last kept span
517    for m in matches {
518        // Keep only when this match starts at or after the end of the last kept
519        // one (adjacency is fine; any earlier start means it is nested in, or
520        // straddles, an already-kept span).
521        if m.span.start_byte >= covered_to {
522            covered_to = m.span.end_byte.max(covered_to);
523            kept.push(m);
524        }
525    }
526    kept
527}
528
529/// Forward-only cursor that maps byte offsets to `(row, col)` while walking a
530/// document at most once. The regex matcher has no tree-sitter node to read
531/// positions from and visits offsets in ascending order, so advancing this
532/// cursor avoids the per-match rescan a stateless lookup would cost.
533struct RowColCursor<'a> {
534    source: &'a str,
535    byte: usize,
536    row: usize,
537    col: usize,
538}
539
540impl<'a> RowColCursor<'a> {
541    fn new(source: &'a str) -> Self {
542        Self {
543            source,
544            byte: 0,
545            row: 0,
546            col: 0,
547        }
548    }
549
550    /// Advance to `target` (which must be `>=` the current position and a char
551    /// boundary) and return the row/col there. `row` counts preceding newlines;
552    /// `col` counts characters since the last newline.
553    fn advance_to(&mut self, target: usize) -> (usize, usize) {
554        for ch in self.source[self.byte..target].chars() {
555            if ch == '\n' {
556                self.row += 1;
557                self.col = 0;
558            } else {
559                self.col += 1;
560            }
561        }
562        self.byte = target;
563        (self.row, self.col)
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::model::Rule;
571
572    fn rule(toml: &str) -> CompiledRule {
573        let parsed = Rule::from_toml_str(toml).expect("rule parses");
574        CompiledRule::compile(&parsed).expect("rule compiles")
575    }
576
577    #[test]
578    fn pattern_rule_binds_metavars() {
579        let compiled = rule(
580            r#"
581            id = "destructure-default"
582            language = "typescript"
583            fix = "{ $KEY: $SRC }"
584            [rule]
585            pattern = "$SRC?.$KEY ?? $DEFAULT"
586            "#,
587        );
588        let matches = compiled
589            .run("const a = cfg?.timeout ?? 30;\nconst b = opts?.retries ?? 3;\n")
590            .unwrap();
591        assert_eq!(matches.len(), 2);
592        assert_eq!(matches[0].bindings["SRC"].text, "cfg");
593        assert_eq!(matches[0].bindings["KEY"].text, "timeout");
594        assert_eq!(matches[0].bindings["DEFAULT"].text, "30");
595        assert_eq!(matches[1].bindings["SRC"].text, "opts");
596        // The match span covers the whole expression.
597        assert_eq!(matches[0].text, "cfg?.timeout ?? 30");
598        assert_eq!(matches[0].span.start_row, 0);
599        assert_eq!(matches[1].span.start_row, 1);
600    }
601
602    #[test]
603    fn nested_matches_do_not_corrupt_or_panic_on_apply() {
604        // `$X + $Y` matches the outer `(a+b)+c` AND the inner `a+b` (distinct
605        // spans). Without overlap resolution, splicing both byte-edits panics
606        // `replace_range` on a stale offset or corrupts the output. The engine
607        // must keep the outermost match and rewrite the region exactly once.
608        let compiled = rule(
609            r#"
610            id = "sum-binop"
611            language = "typescript"
612            fix = "sum($X, $Y)"
613            [rule]
614            pattern = "$X + $Y"
615            "#,
616        );
617        // More than one match exists (outer + inner) before dedup.
618        assert!(compiled.run("const z = a + b + c;\n").unwrap().len() >= 2);
619        let result = compiled.apply("const z = a + b + c;\n").unwrap();
620        // Exactly one outer rewrite; inner `a + b` survives verbatim inside $X.
621        assert_eq!(result.rewritten, "const z = sum(a + b, c);\n");
622        assert_eq!(result.edits.len(), 1);
623        assert!(result.changed);
624    }
625
626    #[test]
627    fn dedupe_overlapping_keeps_outermost_in_document_order() {
628        let span = |s: usize, e: usize| Span {
629            start_byte: s,
630            end_byte: e,
631            start_row: 0,
632            start_col: s,
633            end_row: 0,
634            end_col: e,
635        };
636        let m = |s: usize, e: usize| RuleMatch {
637            rule_id: "r".into(),
638            span: span(s, e),
639            text: String::new(),
640            bindings: BTreeMap::new(),
641        };
642        // outer [0,9) contains inner [0,5); [10,14) is disjoint.
643        let kept = dedupe_overlapping(vec![m(0, 5), m(0, 9), m(10, 14)]);
644        let spans: Vec<_> = kept
645            .iter()
646            .map(|m| (m.span.start_byte, m.span.end_byte))
647            .collect();
648        assert_eq!(spans, vec![(0, 9), (10, 14)]);
649    }
650
651    #[test]
652    fn kind_rule_matches_node_kind() {
653        let compiled = rule(
654            r#"
655            id = "find-calls"
656            language = "python"
657            [rule]
658            kind = "call"
659            "#,
660        );
661        let matches = compiled.run("print(x)\nlog(y)\n").unwrap();
662        assert_eq!(matches.len(), 2);
663        assert_eq!(matches[0].text, "print(x)");
664        assert!(matches[0].bindings.is_empty());
665    }
666
667    #[test]
668    fn regex_rule_matches_text() {
669        let compiled = rule(
670            r#"
671            id = "todo"
672            language = "rust"
673            message = "Found a TODO"
674            [rule]
675            regex = "TODO\\(\\w+\\)"
676            "#,
677        );
678        let matches = compiled
679            .run("fn f() {\n    // TODO(ken) fix\n    // todo lower\n}\n")
680            .unwrap();
681        assert_eq!(matches.len(), 1);
682        assert_eq!(matches[0].text, "TODO(ken)");
683        assert_eq!(matches[0].span.start_row, 1);
684    }
685
686    #[test]
687    fn raw_query_with_fix_target_rewrites_only_the_capture() {
688        // Surgical sub-node rewrite: swap the `let` keyword to `const` while
689        // preserving the type annotation and initializer — the failure mode a
690        // whole-match `pattern`+`fix` cannot avoid (it drops the un-captured
691        // `: Int`). The raw query roots the match at `let_binding` (`@__match`)
692        // and captures the keyword token (`@kw`); `fixTarget` splices only it.
693        let compiled = rule(
694            r#"
695            id = "let-to-const"
696            language = "harn"
697            fix = "const"
698            fixTarget = "kw"
699            [rule]
700            query = '(let_binding "let" @kw) @__match'
701            "#,
702        );
703        let result = compiled
704            .apply("fn f() {\n  let x: Int = 1\n  let y = 2\n}\n")
705            .unwrap();
706        assert_eq!(
707            result.rewritten,
708            "fn f() {\n  const x: Int = 1\n  const y = 2\n}\n"
709        );
710        assert_eq!(result.edits.len(), 2);
711        // Each edit replaced exactly the 3-byte `let` keyword, not the binding.
712        assert!(result.edits.iter().all(|e| e.before == "let"));
713        assert!(result.changed);
714        // Re-running is a no-op (there are no more `let` keywords).
715        assert!(compiled.apply(&result.rewritten).unwrap().rewritten == result.rewritten);
716    }
717
718    #[test]
719    fn raw_query_scales_to_many_bindings() {
720        // Scale-correctness companion to the O(N^2) enrich fix (semantic.rs's
721        // `exact_named_node` used to re-scan every sibling per binding, which
722        // hung `harn codemod` on real 2700-line connector files). A pure
723        // wall-clock assertion would be flaky, so this instead pins the
724        // *behaviour* the fix must preserve at scale: rewriting every binding in
725        // a large flat module produces exactly N surgical edits and a fully
726        // migrated result. (The perf win itself is exercised by the codemod on
727        // real files, not timed here.)
728        let compiled = rule(
729            r#"
730            id = "let-to-const"
731            language = "harn"
732            fix = "const"
733            fixTarget = "kw"
734            [rule]
735            query = '(let_binding "let" @kw) @__match'
736            "#,
737        );
738        let n = 200;
739        let mut src = String::from("fn f() {\n");
740        for i in 0..n {
741            src.push_str(&format!("  let v{i} = {i}\n"));
742        }
743        src.push_str("}\n");
744        let result = compiled.apply(&src).unwrap();
745        assert_eq!(result.edits.len(), n, "every binding keyword rewritten");
746        assert!(result.edits.iter().all(|e| e.before == "let"));
747        assert!(!result.rewritten.contains("let v"));
748        assert_eq!(result.rewritten.matches("const v").count(), n);
749    }
750
751    #[test]
752    fn raw_query_without_root_capture_is_a_compile_error() {
753        let parsed = Rule::from_toml_str(
754            r#"
755            id = "no-root"
756            language = "harn"
757            fix = "const"
758            [rule]
759            query = '(let_binding "let" @kw)'
760            "#,
761        )
762        .unwrap();
763        let compiled = CompiledRule::compile(&parsed);
764        assert!(
765            matches!(&compiled, Err(RulesError::PatternCompile { message, .. }) if message.contains("__match")),
766            "expected a missing-@__match compile error"
767        );
768    }
769
770    #[test]
771    fn fix_target_naming_an_unbound_capture_errors_on_apply() {
772        let compiled = rule(
773            r#"
774            id = "bad-target"
775            language = "harn"
776            fix = "const"
777            fixTarget = "missing"
778            [rule]
779            query = '(let_binding "let" @kw) @__match'
780            "#,
781        );
782        let result = compiled.apply("fn f() {\n  let x = 1\n}\n");
783        assert!(
784            matches!(&result, Err(RulesError::PatternCompile { message, .. }) if message.contains("missing")),
785            "expected an unbound-fixTarget error"
786        );
787    }
788
789    #[test]
790    fn unknown_language_is_an_error() {
791        let parsed = Rule::from_toml_str(
792            r#"
793            id = "x"
794            language = "cobol"
795            [rule]
796            kind = "foo"
797            "#,
798        )
799        .unwrap();
800        assert!(matches!(
801            CompiledRule::compile(&parsed),
802            Err(RulesError::UnknownLanguage { .. })
803        ));
804    }
805
806    #[test]
807    fn invalid_pattern_surfaces_compile_error() {
808        let parsed = Rule::from_toml_str(
809            r#"
810            id = "x"
811            language = "typescript"
812            [rule]
813            pattern = "foo($$$ARGS)"
814            "#,
815        )
816        .unwrap();
817        assert!(matches!(
818            CompiledRule::compile(&parsed),
819            Err(RulesError::PatternCompile { .. })
820        ));
821    }
822
823    #[test]
824    fn harn_resolves_same_named_call_sites_by_binding_identity() {
825        let compiled = rule(
826            r#"
827            id = "top-level-target"
828            language = "harn"
829            [rule]
830            pattern = "$FN($ARG)"
831
832            [[where]]
833            metavar = "FN"
834            resolvesTo = { name = "target", kind = "fn", line = 1 }
835            "#,
836        );
837        let source = r"fn target(value: int) -> int {
838  return value
839}
840
841fn call_shadowed(target: fn(int) -> int) {
842  target(1)
843}
844
845fn call_global() {
846  target(2)
847}
848";
849        let matches = compiled.run(source).unwrap();
850        assert_eq!(matches.len(), 1);
851        assert_eq!(matches[0].text, "target(2)");
852        let binding = &matches[0].bindings["FN"];
853        let resolved = binding.metadata.resolved.as_ref().unwrap();
854        assert_eq!(resolved.name, "target");
855        assert_eq!(resolved.kind, "fn");
856        assert_eq!(resolved.span.start_row, 0);
857        assert_eq!(binding.metadata.ty.as_deref(), Some("fn(int) -> int"));
858    }
859
860    #[test]
861    fn harn_capture_type_constraint_filters_matches() {
862        let compiled = rule(
863            r#"
864            id = "int-logs"
865            language = "harn"
866            [rule]
867            pattern = "log($VALUE)"
868
869            [[where]]
870            metavar = "VALUE"
871            type = "int"
872            "#,
873        );
874        let source = r#"fn main() {
875  let count: int = 1
876  let label: string = "one"
877  log(count)
878  log(label)
879}
880"#;
881        let matches = compiled.run(source).unwrap();
882        assert_eq!(matches.len(), 1);
883        let value = &matches[0].bindings["VALUE"];
884        assert_eq!(value.text, "count");
885        assert_eq!(value.metadata.ty.as_deref(), Some("int"));
886        assert_eq!(
887            value
888                .metadata
889                .resolved
890                .as_ref()
891                .map(|resolved| resolved.kind.as_str()),
892            Some("let")
893        );
894    }
895
896    #[test]
897    fn harn_initializer_uses_outer_binding_scope() {
898        let compiled = rule(
899            r#"
900            id = "outer-initializer"
901            language = "harn"
902            [rule]
903            pattern = "log($VALUE)"
904
905            [[where]]
906            metavar = "VALUE"
907            resolvesTo = { name = "value", kind = "let", line = 2 }
908            "#,
909        );
910        let source = r"fn main() {
911  let value: int = 1
912  if true {
913    let value: string = log(value)
914  }
915}
916";
917        let matches = compiled.run(source).unwrap();
918        assert_eq!(matches.len(), 1);
919        let value = &matches[0].bindings["VALUE"];
920        assert_eq!(value.text, "value");
921        assert_eq!(
922            value
923                .metadata
924                .resolved
925                .as_ref()
926                .map(|resolved| resolved.span.start_row),
927            Some(1)
928        );
929    }
930}