Skip to main content

harn_rules/
evaluator.rs

1//! The relational + composite matching algebra (#2833).
2//!
3//! A [`crate::model::RuleNode`] compiles to a [`CompiledNode`] tree, which
4//! the evaluator walks against a parsed source tree. A node matches a
5//! tree-sitter node iff its **atomic** leaf matches *and* every
6//! **relational** (`inside` / `has` / `follows` / `precedes`) and
7//! **composite** (`all` / `any` / `not` / `matches`) part holds — every set
8//! key is ANDed.
9//!
10//! Candidates for the top rule are seeded cheaply (the atomic pattern query
11//! in one pass, or a kind/regex/whole-tree walk); each candidate is then
12//! checked in full. Relational sub-rules run their own atomic match rooted
13//! at the ancestor/descendant/sibling under test.
14
15use std::collections::BTreeMap;
16
17use harn_hostlib::ast::{api, Language};
18use regex::Regex;
19use streaming_iterator::StreamingIterator;
20use tree_sitter::{Node, Query, QueryCursor};
21
22use crate::engine::{Binding, Span};
23use crate::error::RulesError;
24use crate::model::{AtomicMatcher, RuleNode, StopBy, StopKeyword};
25use crate::pattern::{compile_pattern, ROOT_CAPTURE};
26
27/// Metavar bindings accumulated while matching a node.
28type Bindings = BTreeMap<String, Binding>;
29
30/// One node that matched the top rule, with its bindings.
31pub struct EvalMatch {
32    /// The matched node's span.
33    pub span: Span,
34    /// The matched node's text.
35    pub text: String,
36    /// Metavar bindings (captured + threaded from relational sub-matches).
37    pub bindings: Bindings,
38}
39
40/// A compiled rule tree: the top node plus the utility rules `matches`
41/// references.
42pub struct CompiledRuleTree {
43    top: CompiledNode,
44    utils: BTreeMap<String, CompiledNode>,
45}
46
47struct CompiledNode {
48    atomic: Option<CompiledAtomic>,
49    inside: Option<Box<CompiledRel>>,
50    has: Option<Box<CompiledRel>>,
51    follows: Option<Box<CompiledRel>>,
52    precedes: Option<Box<CompiledRel>>,
53    all: Vec<CompiledNode>,
54    any: Vec<CompiledNode>,
55    not: Option<Box<CompiledNode>>,
56    matches: Option<String>,
57}
58
59struct CompiledRel {
60    node: CompiledNode,
61    stop_by: CompiledStopBy,
62    field: Option<String>,
63}
64
65enum CompiledStopBy {
66    Neighbor,
67    End,
68    Rule(Box<CompiledNode>),
69}
70
71enum CompiledAtomic {
72    Query { query: Query, metavars: Vec<String> },
73    Kind(String),
74    Regex(Regex),
75}
76
77impl CompiledRuleTree {
78    /// Compile a rule's `[rule]` node and its `[utils]` into a runnable
79    /// tree.
80    pub fn compile(
81        rule_id: &str,
82        language: Language,
83        top: &RuleNode,
84        utils: &BTreeMap<String, RuleNode>,
85    ) -> Result<Self, RulesError> {
86        if top.is_empty() {
87            return Err(RulesError::PatternCompile {
88                rule: rule_id.to_string(),
89                message: "rule node is empty (no atomic / relational / composite key)".into(),
90            });
91        }
92        let compiled_utils = utils
93            .iter()
94            .map(|(id, node)| Ok((id.clone(), compile_node(rule_id, language, node)?)))
95            .collect::<Result<BTreeMap<_, _>, RulesError>>()?;
96        Ok(CompiledRuleTree {
97            top: compile_node(rule_id, language, top)?,
98            utils: compiled_utils,
99        })
100    }
101
102    /// Find every node matching the top rule, in document order.
103    pub fn find(
104        &self,
105        rule_id: &str,
106        language: Language,
107        source: &str,
108    ) -> Result<Vec<EvalMatch>, RulesError> {
109        let tree = api::parse_tree(source, language).map_err(|err| RulesError::SourceParse {
110            rule: rule_id.to_string(),
111            message: err.to_string(),
112        })?;
113        let ctx = Ctx {
114            source,
115            utils: &self.utils,
116        };
117        let root = tree.root_node();
118
119        let mut seen: BTreeMap<(usize, usize), EvalMatch> = BTreeMap::new();
120        for node in seed_candidates(&self.top, &ctx, root) {
121            if let Some(bindings) = node_satisfies(&self.top, node, &ctx) {
122                let key = (node.start_byte(), node.end_byte());
123                seen.entry(key).or_insert_with(|| EvalMatch {
124                    span: Span::of(node),
125                    text: ctx.text(node),
126                    bindings,
127                });
128            }
129        }
130        Ok(seen.into_values().collect())
131    }
132}
133
134/// Per-run evaluation context.
135struct Ctx<'a> {
136    source: &'a str,
137    utils: &'a BTreeMap<String, CompiledNode>,
138}
139
140impl Ctx<'_> {
141    #[expect(
142        clippy::string_slice,
143        reason = "tree-sitter node ranges are char-aligned byte offsets into the parsed source"
144    )]
145    fn text(&self, node: Node<'_>) -> String {
146        self.source[node.start_byte()..node.end_byte()].to_string()
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Compilation
152// ---------------------------------------------------------------------------
153
154fn compile_node(
155    rule_id: &str,
156    language: Language,
157    node: &RuleNode,
158) -> Result<CompiledNode, RulesError> {
159    let mkerr = |message: String| RulesError::PatternCompile {
160        rule: rule_id.to_string(),
161        message,
162    };
163
164    let atomic = match node.atomic().map_err(mkerr)? {
165        None => None,
166        Some(AtomicMatcher::Pattern(snippet)) => {
167            let ts_language = language
168                .ts_language()
169                .ok_or_else(|| mkerr(format!("grammar for `{}` unavailable", language.name())))?;
170            let compiled =
171                compile_pattern(&snippet, language).map_err(|m| mkerr(format!("pattern: {m}")))?;
172            let query = Query::new(&ts_language, &compiled.query).map_err(|e| {
173                RulesError::QueryRejected {
174                    rule: rule_id.to_string(),
175                    message: e.to_string(),
176                    query: compiled.query.clone(),
177                }
178            })?;
179            Some(CompiledAtomic::Query {
180                query,
181                metavars: compiled.metavars,
182            })
183        }
184        Some(AtomicMatcher::Kind(kind)) => Some(CompiledAtomic::Kind(kind)),
185        Some(AtomicMatcher::Regex(re)) => Some(CompiledAtomic::Regex(
186            Regex::new(&re).map_err(|e| mkerr(format!("regex `{re}` invalid: {e}")))?,
187        )),
188        Some(AtomicMatcher::RawQuery(raw)) => {
189            let ts_language = language
190                .ts_language()
191                .ok_or_else(|| mkerr(format!("grammar for `{}` unavailable", language.name())))?;
192            let query = Query::new(&ts_language, &raw).map_err(|e| RulesError::QueryRejected {
193                rule: rule_id.to_string(),
194                message: e.to_string(),
195                query: raw.clone(),
196            })?;
197            // A raw query must mark the matched node with `@__match`: it is the
198            // node re-rooted by `node_satisfies` (so the query re-validates from
199            // the candidate) and the default replaced span. Every *other* named
200            // capture is recorded as a metavar binding (with its span), so it is
201            // available to `fix` interpolation and selectable via `fixTarget`.
202            if !query.capture_names().contains(&ROOT_CAPTURE) {
203                return Err(mkerr(format!(
204                    "raw `query` must bind the matched node to `@{ROOT_CAPTURE}`"
205                )));
206            }
207            let metavars = query
208                .capture_names()
209                .iter()
210                .filter(|name| **name != ROOT_CAPTURE)
211                .map(|name| name.to_string())
212                .collect();
213            Some(CompiledAtomic::Query { query, metavars })
214        }
215    };
216
217    let rel = |sub: &Option<Box<RuleNode>>| -> Result<Option<Box<CompiledRel>>, RulesError> {
218        match sub {
219            None => Ok(None),
220            Some(n) => Ok(Some(Box::new(compile_rel(rule_id, language, n)?))),
221        }
222    };
223
224    let compile_list = |list: &Option<Vec<RuleNode>>| -> Result<Vec<CompiledNode>, RulesError> {
225        list.iter()
226            .flatten()
227            .map(|n| compile_node(rule_id, language, n))
228            .collect()
229    };
230
231    Ok(CompiledNode {
232        atomic,
233        inside: rel(&node.inside)?,
234        has: rel(&node.has)?,
235        follows: rel(&node.follows)?,
236        precedes: rel(&node.precedes)?,
237        all: compile_list(&node.all)?,
238        any: compile_list(&node.any)?,
239        not: match &node.not {
240            None => None,
241            Some(n) => Some(Box::new(compile_node(rule_id, language, n)?)),
242        },
243        matches: node.matches.clone(),
244    })
245}
246
247fn compile_rel(
248    rule_id: &str,
249    language: Language,
250    node: &RuleNode,
251) -> Result<CompiledRel, RulesError> {
252    let stop_by = match &node.stop_by {
253        None | Some(StopBy::Keyword(StopKeyword::Neighbor)) => CompiledStopBy::Neighbor,
254        Some(StopBy::Keyword(StopKeyword::End)) => CompiledStopBy::End,
255        Some(StopBy::Rule(r)) => {
256            CompiledStopBy::Rule(Box::new(compile_node(rule_id, language, r)?))
257        }
258    };
259    Ok(CompiledRel {
260        node: compile_node(rule_id, language, node)?,
261        stop_by,
262        field: node.field.clone(),
263    })
264}
265
266// ---------------------------------------------------------------------------
267// Evaluation
268// ---------------------------------------------------------------------------
269
270/// Seed candidate nodes for the top rule. The atomic pattern query runs in
271/// one pass; kind/regex/composite-only rules fall back to a tree walk.
272fn seed_candidates<'t>(top: &CompiledNode, ctx: &Ctx<'_>, root: Node<'t>) -> Vec<Node<'t>> {
273    match &top.atomic {
274        Some(CompiledAtomic::Query { query, .. }) => {
275            let mut out = Vec::new();
276            let mut seen = std::collections::HashSet::new();
277            let root_index = root_capture_index(query);
278            let mut cursor = QueryCursor::new();
279            let mut it = cursor.matches(query, root, ctx.source.as_bytes());
280            while let Some(m) = it.next() {
281                for cap in m.captures {
282                    if Some(cap.index) == root_index && seen.insert(cap.node.id()) {
283                        out.push(cap.node);
284                    }
285                }
286            }
287            out
288        }
289        Some(CompiledAtomic::Kind(kind)) => {
290            let mut out = Vec::new();
291            for_each_named_descendant(root, &mut |n| {
292                if n.kind() == kind {
293                    out.push(n);
294                }
295            });
296            out
297        }
298        Some(CompiledAtomic::Regex(_)) | None => {
299            let mut out = Vec::new();
300            for_each_named_descendant(root, &mut |n| out.push(n));
301            out
302        }
303    }
304}
305
306/// Full match check for a specific `node`: atomic + relational + composite.
307fn node_satisfies(cnode: &CompiledNode, node: Node<'_>, ctx: &Ctx<'_>) -> Option<Bindings> {
308    let mut bindings = Bindings::new();
309
310    if let Some(atomic) = &cnode.atomic {
311        merge(&mut bindings, atomic_match(atomic, node, ctx)?);
312    }
313    if let Some(rel) = &cnode.inside {
314        merge(&mut bindings, eval_inside(rel, node, ctx)?);
315    }
316    if let Some(rel) = &cnode.has {
317        merge(&mut bindings, eval_has(rel, node, ctx)?);
318    }
319    if let Some(rel) = &cnode.follows {
320        merge(&mut bindings, eval_sibling(rel, node, ctx, Dir::Before)?);
321    }
322    if let Some(rel) = &cnode.precedes {
323        merge(&mut bindings, eval_sibling(rel, node, ctx, Dir::After)?);
324    }
325    for sub in &cnode.all {
326        merge(&mut bindings, node_satisfies(sub, node, ctx)?);
327    }
328    if !cnode.any.is_empty() {
329        let matched = cnode
330            .any
331            .iter()
332            .find_map(|sub| node_satisfies(sub, node, ctx));
333        merge(&mut bindings, matched?);
334    }
335    if let Some(not) = &cnode.not {
336        if node_satisfies(not, node, ctx).is_some() {
337            return None;
338        }
339    }
340    if let Some(id) = &cnode.matches {
341        let util = ctx.utils.get(id)?;
342        merge(&mut bindings, node_satisfies(util, node, ctx)?);
343    }
344
345    Some(bindings)
346}
347
348fn atomic_match(atomic: &CompiledAtomic, node: Node<'_>, ctx: &Ctx<'_>) -> Option<Bindings> {
349    match atomic {
350        CompiledAtomic::Kind(kind) => (node.kind() == kind).then(Bindings::new),
351        CompiledAtomic::Regex(re) => re.is_match(&ctx.text(node)).then(Bindings::new),
352        CompiledAtomic::Query { query, metavars } => {
353            let root_index = root_capture_index(query);
354            let names: Vec<&str> = query.capture_names().to_vec();
355            let mut cursor = QueryCursor::new();
356            let mut it = cursor.matches(query, node, ctx.source.as_bytes());
357            while let Some(m) = it.next() {
358                // The pattern must match `node` itself, not a descendant.
359                let roots_here = m
360                    .captures
361                    .iter()
362                    .any(|c| Some(c.index) == root_index && c.node.id() == node.id());
363                if !roots_here {
364                    continue;
365                }
366                let mut bindings = Bindings::new();
367                for cap in m.captures {
368                    let name = names[cap.index as usize];
369                    if metavars.iter().any(|mv| mv == name) {
370                        bindings.entry(name.to_string()).or_insert_with(|| {
371                            Binding::new(ctx.text(cap.node), Span::of(cap.node))
372                        });
373                    }
374                }
375                return Some(bindings);
376            }
377            None
378        }
379    }
380}
381
382fn eval_inside(rel: &CompiledRel, node: Node<'_>, ctx: &Ctx<'_>) -> Option<Bindings> {
383    let mut current = node.parent();
384    let mut child = node;
385    while let Some(ancestor) = current {
386        if let CompiledStopBy::Rule(stop) = &rel.stop_by {
387            if node_satisfies(stop, ancestor, ctx).is_some()
388                && node_satisfies(&rel.node, ancestor, ctx).is_none()
389            {
390                // Hit the stop boundary without matching.
391                return None;
392            }
393        }
394        if let Some(b) = node_satisfies(&rel.node, ancestor, ctx) {
395            if field_ok(rel.field.as_deref(), ancestor, child) {
396                return Some(b);
397            }
398        }
399        if matches!(rel.stop_by, CompiledStopBy::Neighbor) {
400            return None;
401        }
402        child = ancestor;
403        current = ancestor.parent();
404    }
405    None
406}
407
408fn eval_has(rel: &CompiledRel, node: Node<'_>, ctx: &Ctx<'_>) -> Option<Bindings> {
409    let neighbor = matches!(rel.stop_by, CompiledStopBy::Neighbor);
410    let mut found: Option<Bindings> = None;
411    let mut cursor = node.walk();
412    let children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
413    for child in children {
414        if let Some(b) = node_satisfies(&rel.node, child, ctx) {
415            if field_ok(rel.field.as_deref(), node, child) {
416                found = Some(b);
417                break;
418            }
419        }
420        if !neighbor {
421            if let Some(b) = eval_has(rel, child, ctx) {
422                found = Some(b);
423                break;
424            }
425        }
426    }
427    found
428}
429
430enum Dir {
431    Before,
432    After,
433}
434
435fn eval_sibling(rel: &CompiledRel, node: Node<'_>, ctx: &Ctx<'_>, dir: Dir) -> Option<Bindings> {
436    let neighbor = matches!(rel.stop_by, CompiledStopBy::Neighbor);
437    let mut sib = match dir {
438        Dir::Before => node.prev_named_sibling(),
439        Dir::After => node.next_named_sibling(),
440    };
441    while let Some(s) = sib {
442        if let Some(b) = node_satisfies(&rel.node, s, ctx) {
443            return Some(b);
444        }
445        if neighbor {
446            return None;
447        }
448        sib = match dir {
449            Dir::Before => s.prev_named_sibling(),
450            Dir::After => s.next_named_sibling(),
451        };
452    }
453    None
454}
455
456/// When a relation names a `field`, the related child must sit in that
457/// field of the parent. `parent` is the ancestor; `child` is the node on
458/// the path toward the matched node.
459fn field_ok(field: Option<&str>, parent: Node<'_>, child: Node<'_>) -> bool {
460    match field {
461        None => true,
462        Some(name) => parent
463            .child_by_field_name(name)
464            .is_some_and(|f| f.id() == child.id()),
465    }
466}
467
468fn merge(into: &mut Bindings, from: Bindings) {
469    for (k, v) in from {
470        into.entry(k).or_insert(v);
471    }
472}
473
474fn root_capture_index(query: &Query) -> Option<u32> {
475    query
476        .capture_names()
477        .iter()
478        .position(|n| *n == ROOT_CAPTURE)
479        .map(|i| i as u32)
480}
481
482fn for_each_named_descendant<'t>(node: Node<'t>, f: &mut impl FnMut(Node<'t>)) {
483    let mut cursor = node.walk();
484    for child in node.named_children(&mut cursor) {
485        f(child);
486        for_each_named_descendant(child, f);
487    }
488}