Skip to main content

varar_core/
expression.rs

1//! Cucumber-expression matching — the owned layer over the `cucumber-expressions`
2//! crate's grammar parser. Replaces `io.cucumber.cucumberexpressions`. We take the
3//! crate's AST parser (the escape-rule-dense part) and own the small, corpus-pinned
4//! rest: regex generation with one named group per parameter, built-in + custom
5//! parameter types, argument extraction, and `parameter_type_names`.
6//!
7//! Deviation from the `cucumber-expressions` 20.0.0 line every other port pins
8//! (recorded in ADR 0006, `doc/adr/0006-rust-port.md`): there is no official
9//! Rust port, so this crate hand-writes the regex generation, and only the
10//! `{int}`, `{word}`, and `{string}` built-ins exist. All other 20.0.0
11//! built-ins — `{float}`, `{double}`, `{byte}`, `{short}`, `{long}`,
12//! `{biginteger}`, `{bigdecimal}`, and the anonymous `{}` — are omitted: the
13//! numeric ones need lookahead the `regex` crate lacks, and none is used by the
14//! conformance corpus. Using one fails loudly at registration
15//! ("Undefined parameter type {double}"), never silently misparses.
16
17use crate::value::Value;
18use cucumber_expressions::Expression;
19use cucumber_expressions::ast::SingleExpression;
20use regex::Regex;
21use std::rc::Rc;
22
23/// A parameter-type transform. Receives the type's regexp **capture groups** in
24/// order (a group that did not participate arrives as `""` — the closest
25/// `&[&str]` can come to Java's `null`/Python's `None`); a regexp with no
26/// groups of its own receives the whole matched text as the single element.
27/// Mirrors Java's `CaptureGroupTransformer` / Python's `parse(*groups)`.
28pub type ParseFn = Rc<dyn Fn(&[&str]) -> Value>;
29
30/// One captured argument of a whole-string match.
31#[derive(Clone, Debug, PartialEq)]
32pub struct Argument {
33    /// The transformed value.
34    pub value: Value,
35    /// The parameter-type name (the `formats` lookup key).
36    pub parameter_type_name: String,
37    /// The captured group's byte offsets within the matched text (`None` if the
38    /// group did not participate).
39    pub group: Option<(usize, usize)>,
40}
41
42/// Registry of parameter types (built-ins + author-defined custom types).
43#[derive(Clone)]
44pub struct ParameterTypeRegistry {
45    types: Vec<ParameterTypeDef>,
46}
47
48#[derive(Clone)]
49struct ParameterTypeDef {
50    name: String,
51    regexp_source: String,
52    transform: Transform,
53}
54
55#[derive(Clone)]
56enum Transform {
57    Int,
58    Word,
59    QuotedString,
60    Custom(ParseFn),
61}
62
63// Built-in regexps, mirroring cucumber-expressions 20.0.0 (via the crate's own
64// expansion). Each is wrapped in one named group per parameter at compile time.
65const INT_RE: &str = r"(?:-?\d+)|(?:\d+)";
66const WORD_RE: &str = r"[^\s]+";
67const STRING_RE: &str = r#""[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'"#;
68
69impl ParameterTypeRegistry {
70    /// A fresh registry with the built-in `{int}`, `{word}`, `{string}` types.
71    pub fn new() -> ParameterTypeRegistry {
72        ParameterTypeRegistry {
73            types: vec![
74                ParameterTypeDef {
75                    name: "int".to_string(),
76                    regexp_source: INT_RE.to_string(),
77                    transform: Transform::Int,
78                },
79                ParameterTypeDef {
80                    name: "word".to_string(),
81                    regexp_source: WORD_RE.to_string(),
82                    transform: Transform::Word,
83                },
84                ParameterTypeDef {
85                    name: "string".to_string(),
86                    regexp_source: STRING_RE.to_string(),
87                    transform: Transform::QuotedString,
88                },
89            ],
90        }
91    }
92
93    /// Registers a custom parameter type `name` with a bare regexp source and a
94    /// transform.
95    pub fn define(&mut self, name: &str, regexp_source: &str, parse: ParseFn) {
96        self.types.push(ParameterTypeDef {
97            name: name.to_string(),
98            regexp_source: regexp_source.to_string(),
99            transform: Transform::Custom(parse),
100        });
101    }
102
103    fn lookup(&self, name: &str) -> Option<&ParameterTypeDef> {
104        self.types.iter().find(|t| t.name == name)
105    }
106}
107
108impl Default for ParameterTypeRegistry {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114/// An expression failed to compile.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ExpressionError {
117    pub message: String,
118}
119
120/// A compiled cucumber expression.
121#[derive(Clone)]
122pub struct CompiledExpression {
123    source: String,
124    regexp_source: String,
125    anchored: Regex,
126    params: Vec<ParamRef>,
127}
128
129#[derive(Clone)]
130struct ParamRef {
131    group_name: String,
132    type_name: String,
133    transform: Transform,
134    /// Numeric indices of the capture groups the author wrote INSIDE this
135    /// parameter's own regexp (empty for group-free patterns and built-ins).
136    /// These are what a custom transform receives, matching Java/Python.
137    inner_groups: Vec<usize>,
138}
139
140impl CompiledExpression {
141    /// Compiles `source` against `types`. Errors on an undefined parameter type
142    /// or an un-compilable pattern.
143    pub fn compile(
144        source: &str,
145        types: &ParameterTypeRegistry,
146    ) -> Result<CompiledExpression, ExpressionError> {
147        let parsed = Expression::parse(source).map_err(|e| ExpressionError {
148            message: format!("failed to parse cucumber expression: {e}"),
149        })?;
150
151        let mut regex_str = String::from("^");
152        let mut params = Vec::new();
153        for se in &parsed.0 {
154            match se {
155                SingleExpression::Text(input) => regex_str.push_str(&escape_text(input.fragment())),
156                SingleExpression::Whitespaces(input) => {
157                    regex_str.push_str(&regex::escape(input.fragment()))
158                }
159                SingleExpression::Parameter(p) => {
160                    let name = *p.input.fragment();
161                    let def = types.lookup(name).ok_or_else(|| ExpressionError {
162                        message: format!("Undefined parameter type {{{name}}}"),
163                    })?;
164                    let group = format!("__p{}", params.len());
165                    regex_str.push_str(&format!("(?P<{group}>{})", def.regexp_source));
166                    params.push(ParamRef {
167                        group_name: group,
168                        type_name: name.to_string(),
169                        transform: def.transform.clone(),
170                        inner_groups: Vec::new(), // filled in below, once compiled
171                    });
172                }
173                SingleExpression::Optional(opt) => {
174                    regex_str.push_str("(?:");
175                    regex_str.push_str(&escape_text(opt.0.fragment()));
176                    regex_str.push_str(")?");
177                }
178                SingleExpression::Alternation(alt) => regex_str.push_str(&alternation_regex(alt)),
179            }
180        }
181        regex_str.push('$');
182
183        let anchored = Regex::new(&regex_str).map_err(|e| ExpressionError {
184            message: format!("failed to compile expression regex: {e}"),
185        })?;
186
187        // Locate each author-written capture group inside its parameter. Every
188        // construct WE generate is non-capturing (the `__pN` named groups
189        // aside), so any group between `__pN` and `__p{N+1}` in paren order was
190        // written inside parameter N's own regexp — those are the groups its
191        // custom transform receives (Java/Python parity).
192        let names: Vec<Option<&str>> = anchored.capture_names().collect();
193        let param_positions: Vec<usize> = params
194            .iter()
195            .map(|p| {
196                names
197                    .iter()
198                    .position(|n| *n == Some(p.group_name.as_str()))
199                    .expect("named parameter group exists")
200            })
201            .collect();
202        for (n, param) in params.iter_mut().enumerate() {
203            let start = param_positions[n] + 1;
204            let end = param_positions.get(n + 1).copied().unwrap_or(names.len());
205            param.inner_groups = (start..end).collect();
206        }
207
208        Ok(CompiledExpression {
209            source: source.to_string(),
210            regexp_source: regex_str,
211            anchored,
212            params,
213        })
214    }
215
216    /// The original expression text.
217    pub fn source(&self) -> &str {
218        &self.source
219    }
220
221    /// The anchored regex source (`^...$`), what the matcher strips to scan.
222    pub fn regexp_source(&self) -> &str {
223        &self.regexp_source
224    }
225
226    /// Matches the *entire* `text`, returning the typed arguments. `None` when
227    /// `text` is not a whole match.
228    pub fn match_whole(&self, text: &str) -> Option<Vec<Argument>> {
229        let caps = self.anchored.captures(text)?;
230        let mut args = Vec::with_capacity(self.params.len());
231        for p in &self.params {
232            match caps.name(&p.group_name) {
233                Some(m) => {
234                    let value = match &p.transform {
235                        // A custom transform receives its regexp's own capture
236                        // groups (non-participating → ""); with no groups, the
237                        // whole match is the single element. See [`ParseFn`].
238                        Transform::Custom(f) if !p.inner_groups.is_empty() => {
239                            let groups: Vec<&str> = p
240                                .inner_groups
241                                .iter()
242                                .map(|&i| caps.get(i).map_or("", |g| g.as_str()))
243                                .collect();
244                            f(&groups)
245                        }
246                        other => apply_transform(other, m.as_str()),
247                    };
248                    args.push(Argument {
249                        value,
250                        parameter_type_name: p.type_name.clone(),
251                        group: Some((m.start(), m.end())),
252                    })
253                }
254                None => args.push(Argument {
255                    value: Value::Null,
256                    parameter_type_name: p.type_name.clone(),
257                    group: None,
258                }),
259            }
260        }
261        Some(args)
262    }
263}
264
265fn apply_transform(transform: &Transform, text: &str) -> Value {
266    match transform {
267        Transform::Int => text.parse::<i64>().map_or(Value::Null, Value::Int),
268        Transform::Word => Value::String(text.to_string()),
269        Transform::QuotedString => Value::String(dequote(text)),
270        Transform::Custom(f) => f(&[text]),
271    }
272}
273
274/// Strips a `{string}` token's surrounding quotes and unescapes `\X` → `X`.
275fn dequote(s: &str) -> String {
276    let chars: Vec<char> = s.chars().collect();
277    if chars.len() < 2 {
278        return s.to_string();
279    }
280    let inner = &chars[1..chars.len() - 1];
281    let mut out = String::new();
282    let mut i = 0;
283    while i < inner.len() {
284        if inner[i] == '\\' && i + 1 < inner.len() {
285            out.push(inner[i + 1]);
286            i += 2;
287        } else {
288            out.push(inner[i]);
289            i += 1;
290        }
291    }
292    out
293}
294
295/// Unescapes cucumber `\X` sequences in expression text, then regex-escapes so the
296/// literal text matches verbatim.
297fn escape_text(raw: &str) -> String {
298    let mut unescaped = String::new();
299    let mut chars = raw.chars();
300    while let Some(c) = chars.next() {
301        if c == '\\' {
302            if let Some(n) = chars.next() {
303                unescaped.push(n);
304            }
305        } else {
306            unescaped.push(c);
307        }
308    }
309    regex::escape(&unescaped)
310}
311
312fn alternation_regex(
313    alt: &cucumber_expressions::ast::Alternation<cucumber_expressions::ast::Spanned<'_>>,
314) -> String {
315    use cucumber_expressions::ast::Alternative;
316    let mut branches = Vec::new();
317    for single in alt.0.iter() {
318        let mut branch = String::new();
319        for alternative in single {
320            match alternative {
321                Alternative::Text(t) => branch.push_str(&escape_text(t.fragment())),
322                Alternative::Optional(o) => {
323                    branch.push_str("(?:");
324                    branch.push_str(&escape_text(o.0.fragment()));
325                    branch.push_str(")?");
326                }
327            }
328        }
329        branches.push(branch);
330    }
331    format!("(?:{})", branches.join("|"))
332}
333
334/// Parameter-type names in source order, read from the parsed AST (escaped
335/// braces `\{...\}` are literal text, not parameters).
336pub fn parameter_type_names(source: &str) -> Vec<String> {
337    let Ok(parsed) = Expression::parse(source) else {
338        return Vec::new();
339    };
340    parsed
341        .0
342        .iter()
343        .filter_map(|se| match se {
344            SingleExpression::Parameter(p) => Some((*p.input.fragment()).to_string()),
345            _ => None,
346        })
347        .collect()
348}