Skip to main content

varar_core/
matcher.rs

1//! Matches a sentence against a registry's compiled expressions — port of
2//! `matcher.ts` / `Matcher.java`. Unanchored substring scan per step, then
3//! greedy left-to-right non-overlap resolution. All returned offsets are UTF-16
4//! (regex byte offsets converted at [`Hit`] construction).
5
6use crate::offsets::utf16_index;
7use crate::registry::{FormatFn, Registry, StepRegistration};
8use crate::value::Value;
9use regex::Regex;
10use std::rc::Rc;
11
12/// UTF-16 start/end of one captured parameter within the sentence.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct ParamSpan {
15    pub start: usize,
16    pub end: usize,
17}
18
19/// One successful expression match inside a sentence. `formats` aligns 1:1 with
20/// `args` (`None` where the parameter type has no formatter).
21#[derive(Clone)]
22pub struct Hit {
23    pub expression: String,
24    pub step_def: Rc<StepRegistration>,
25    pub match_start: usize,
26    pub match_end: usize,
27    pub args: Vec<Value>,
28    pub param_spans: Vec<ParamSpan>,
29    pub formats: Vec<Option<FormatFn>>,
30}
31
32/// Two or more hits that start at the same position with equal length.
33#[derive(Clone)]
34pub struct AmbiguityCollision {
35    pub match_start: usize,
36    pub match_end: usize,
37    pub candidates: Vec<Hit>,
38}
39
40/// The tagged result of [`resolve_hits`].
41pub enum ResolvedSteps {
42    /// The greedy, left-to-right, non-overlapping selection.
43    Ok(Vec<Hit>),
44    /// Every same-start/same-length tie that blocked selection.
45    Ambiguous(Vec<AmbiguityCollision>),
46}
47
48/// Every expression match found anywhere in `sentence`, one unanchored scan per
49/// registered step, in registration order. Port of `findHits`. Regex byte offsets
50/// are converted to UTF-16 at [`Hit`] construction.
51pub fn find_hits(sentence: &str, registry: &Registry) -> Vec<Hit> {
52    let mut hits = Vec::new();
53    for step in &registry.steps {
54        let Ok(unanchored) = Regex::new(&strip_anchors(step.compiled.regexp_source())) else {
55            continue;
56        };
57        for m in unanchored.find_iter(sentence) {
58            let matched_text = &sentence[m.start()..m.end()];
59            let arguments = step.compiled.match_whole(matched_text).unwrap_or_default();
60
61            let mut args = Vec::with_capacity(arguments.len());
62            let mut param_spans = Vec::new();
63            let mut formats = Vec::with_capacity(arguments.len());
64            for arg in arguments {
65                formats.push(registry.formats.get(&arg.parameter_type_name).cloned());
66                if let Some((gs, ge)) = arg.group {
67                    param_spans.push(ParamSpan {
68                        start: utf16_index(sentence, m.start() + gs),
69                        end: utf16_index(sentence, m.start() + ge),
70                    });
71                }
72                args.push(arg.value);
73            }
74
75            hits.push(Hit {
76                expression: step.expression.clone(),
77                step_def: step.clone(),
78                match_start: utf16_index(sentence, m.start()),
79                match_end: utf16_index(sentence, m.end()),
80                args,
81                param_spans,
82                formats,
83            });
84        }
85    }
86    hits
87}
88
89/// Strips a compiled expression's `^...$` anchors so an unanchored scan can find
90/// it anywhere in the sentence.
91fn strip_anchors(source: &str) -> String {
92    let s = source.strip_prefix('^').unwrap_or(source);
93    let s = s.strip_suffix('$').unwrap_or(s);
94    s.to_string()
95}
96
97/// Selects the greedy, left-to-right, non-overlapping subset of `hits`, or
98/// reports every same-start/same-length ambiguity. Port of `resolveHits`.
99pub fn resolve_hits(hits: Vec<Hit>) -> ResolvedSteps {
100    if hits.is_empty() {
101        return ResolvedSteps::Ok(Vec::new());
102    }
103    let mut sorted = hits;
104    // Sort by matchStart ascending, then by length descending (stable).
105    sorted.sort_by(|a, b| {
106        a.match_start
107            .cmp(&b.match_start)
108            .then_with(|| (b.match_end - b.match_start).cmp(&(a.match_end - a.match_start)))
109    });
110
111    let mut collisions = Vec::new();
112    let mut i = 0;
113    while i < sorted.len() {
114        let here_start = sorted[i].match_start;
115        let here_len = sorted[i].match_end - sorted[i].match_start;
116        let mut j = i + 1;
117        while j < sorted.len()
118            && sorted[j].match_start == here_start
119            && sorted[j].match_end - sorted[j].match_start == here_len
120        {
121            j += 1;
122        }
123        if j - i > 1 {
124            collisions.push(AmbiguityCollision {
125                match_start: here_start,
126                match_end: sorted[i].match_end,
127                candidates: sorted[i..j].to_vec(),
128            });
129        }
130        i = j;
131    }
132    if !collisions.is_empty() {
133        return ResolvedSteps::Ambiguous(collisions);
134    }
135
136    let mut steps = Vec::new();
137    let mut cursor: isize = -1;
138    for hit in sorted {
139        if (hit.match_start as isize) < cursor {
140            continue;
141        }
142        cursor = hit.match_end as isize;
143        steps.push(hit);
144    }
145    ResolvedSteps::Ok(steps)
146}