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// Markdown emphasis, the built-in `{emph}` type. Six alternation branches,
69// longest-delimiter-first (so `**x**` isn't half-eaten by the `*` branch); each
70// branch captures the inner text in its own group, so only the outermost
71// delimiter pair is stripped (`**_x_**` → `_x_`). Byte-identical to the TS port's
72// `EMPH_REGEXP`. The transform ([`emph_transform`]) returns the first
73// participating group.
74const EMPH_RE: &str =
75    r"\*\*\*([^*]+)\*\*\*|___([^_]+)___|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_";
76
77/// The `{emph}` transform: exactly one of the six branches matches, so the inner
78/// text is the first non-empty capture group. Non-participating groups arrive as
79/// `""` (see [`ParseFn`]).
80fn emph_transform() -> ParseFn {
81    Rc::new(|groups: &[&str]| {
82        let inner = groups.iter().copied().find(|g| !g.is_empty()).unwrap_or("");
83        Value::String(inner.to_string())
84    })
85}
86
87impl ParameterTypeRegistry {
88    /// A fresh registry with the built-in `{int}`, `{word}`, `{string}`,
89    /// `{emph}` types.
90    pub fn new() -> ParameterTypeRegistry {
91        ParameterTypeRegistry {
92            types: vec![
93                ParameterTypeDef {
94                    name: "int".to_string(),
95                    regexp_source: INT_RE.to_string(),
96                    transform: Transform::Int,
97                },
98                ParameterTypeDef {
99                    name: "word".to_string(),
100                    regexp_source: WORD_RE.to_string(),
101                    transform: Transform::Word,
102                },
103                ParameterTypeDef {
104                    name: "string".to_string(),
105                    regexp_source: STRING_RE.to_string(),
106                    transform: Transform::QuotedString,
107                },
108                ParameterTypeDef {
109                    name: "emph".to_string(),
110                    regexp_source: EMPH_RE.to_string(),
111                    transform: Transform::Custom(emph_transform()),
112                },
113            ],
114        }
115    }
116
117    /// Registers a custom parameter type `name` with a bare regexp source and a
118    /// transform.
119    pub fn define(&mut self, name: &str, regexp_source: &str, parse: ParseFn) {
120        self.types.push(ParameterTypeDef {
121            name: name.to_string(),
122            regexp_source: regexp_source.to_string(),
123            transform: Transform::Custom(parse),
124        });
125    }
126
127    fn lookup(&self, name: &str) -> Option<&ParameterTypeDef> {
128        self.types.iter().find(|t| t.name == name)
129    }
130}
131
132impl Default for ParameterTypeRegistry {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// An expression failed to compile.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct ExpressionError {
141    pub message: String,
142}
143
144/// A compiled cucumber expression.
145#[derive(Clone)]
146pub struct CompiledExpression {
147    source: String,
148    regexp_source: String,
149    anchored: Regex,
150    params: Vec<ParamRef>,
151}
152
153#[derive(Clone)]
154struct ParamRef {
155    group_name: String,
156    type_name: String,
157    transform: Transform,
158    /// Numeric indices of the capture groups the author wrote INSIDE this
159    /// parameter's own regexp (empty for group-free patterns and built-ins).
160    /// These are what a custom transform receives, matching Java/Python.
161    inner_groups: Vec<usize>,
162}
163
164impl CompiledExpression {
165    /// Compiles `source` against `types`. Errors on an undefined parameter type
166    /// or an un-compilable pattern.
167    pub fn compile(
168        source: &str,
169        types: &ParameterTypeRegistry,
170    ) -> Result<CompiledExpression, ExpressionError> {
171        let parsed = Expression::parse(source).map_err(|e| ExpressionError {
172            message: format!("failed to parse cucumber expression: {e}"),
173        })?;
174
175        let mut regex_str = String::from("^");
176        let mut params = Vec::new();
177        for se in &parsed.0 {
178            match se {
179                SingleExpression::Text(input) => regex_str.push_str(&escape_text(input.fragment())),
180                SingleExpression::Whitespaces(input) => {
181                    regex_str.push_str(&regex::escape(input.fragment()))
182                }
183                SingleExpression::Parameter(p) => {
184                    let name = *p.input.fragment();
185                    let def = types.lookup(name).ok_or_else(|| ExpressionError {
186                        message: format!("Undefined parameter type {{{name}}}"),
187                    })?;
188                    let group = format!("__p{}", params.len());
189                    regex_str.push_str(&format!("(?P<{group}>{})", def.regexp_source));
190                    params.push(ParamRef {
191                        group_name: group,
192                        type_name: name.to_string(),
193                        transform: def.transform.clone(),
194                        inner_groups: Vec::new(), // filled in below, once compiled
195                    });
196                }
197                SingleExpression::Optional(opt) => {
198                    regex_str.push_str("(?:");
199                    regex_str.push_str(&escape_text(opt.0.fragment()));
200                    regex_str.push_str(")?");
201                }
202                SingleExpression::Alternation(alt) => regex_str.push_str(&alternation_regex(alt)),
203            }
204        }
205        regex_str.push('$');
206
207        let anchored = Regex::new(&regex_str).map_err(|e| ExpressionError {
208            message: format!("failed to compile expression regex: {e}"),
209        })?;
210
211        // Locate each author-written capture group inside its parameter. Every
212        // construct WE generate is non-capturing (the `__pN` named groups
213        // aside), so any group between `__pN` and `__p{N+1}` in paren order was
214        // written inside parameter N's own regexp — those are the groups its
215        // custom transform receives (Java/Python parity).
216        let names: Vec<Option<&str>> = anchored.capture_names().collect();
217        let param_positions: Vec<usize> = params
218            .iter()
219            .map(|p| {
220                names
221                    .iter()
222                    .position(|n| *n == Some(p.group_name.as_str()))
223                    .expect("named parameter group exists")
224            })
225            .collect();
226        for (n, param) in params.iter_mut().enumerate() {
227            let start = param_positions[n] + 1;
228            let end = param_positions.get(n + 1).copied().unwrap_or(names.len());
229            param.inner_groups = (start..end).collect();
230        }
231
232        Ok(CompiledExpression {
233            source: source.to_string(),
234            regexp_source: regex_str,
235            anchored,
236            params,
237        })
238    }
239
240    /// The original expression text.
241    pub fn source(&self) -> &str {
242        &self.source
243    }
244
245    /// The anchored regex source (`^...$`), what the matcher strips to scan.
246    pub fn regexp_source(&self) -> &str {
247        &self.regexp_source
248    }
249
250    /// Matches the *entire* `text`, returning the typed arguments. `None` when
251    /// `text` is not a whole match.
252    pub fn match_whole(&self, text: &str) -> Option<Vec<Argument>> {
253        let caps = self.anchored.captures(text)?;
254        let mut args = Vec::with_capacity(self.params.len());
255        for p in &self.params {
256            match caps.name(&p.group_name) {
257                Some(m) => {
258                    let value = match &p.transform {
259                        // A custom transform receives its regexp's own capture
260                        // groups (non-participating → ""); with no groups, the
261                        // whole match is the single element. See [`ParseFn`].
262                        Transform::Custom(f) if !p.inner_groups.is_empty() => {
263                            let groups: Vec<&str> = p
264                                .inner_groups
265                                .iter()
266                                .map(|&i| caps.get(i).map_or("", |g| g.as_str()))
267                                .collect();
268                            f(&groups)
269                        }
270                        other => apply_transform(other, m.as_str()),
271                    };
272                    args.push(Argument {
273                        value,
274                        parameter_type_name: p.type_name.clone(),
275                        group: Some((m.start(), m.end())),
276                    })
277                }
278                None => args.push(Argument {
279                    value: Value::Null,
280                    parameter_type_name: p.type_name.clone(),
281                    group: None,
282                }),
283            }
284        }
285        Some(args)
286    }
287}
288
289fn apply_transform(transform: &Transform, text: &str) -> Value {
290    match transform {
291        Transform::Int => text.parse::<i64>().map_or(Value::Null, Value::Int),
292        Transform::Word => Value::String(text.to_string()),
293        Transform::QuotedString => Value::String(dequote(text)),
294        Transform::Custom(f) => f(&[text]),
295    }
296}
297
298/// Strips a `{string}` token's surrounding quotes and unescapes `\X` → `X`.
299fn dequote(s: &str) -> String {
300    let chars: Vec<char> = s.chars().collect();
301    if chars.len() < 2 {
302        return s.to_string();
303    }
304    let inner = &chars[1..chars.len() - 1];
305    let mut out = String::new();
306    let mut i = 0;
307    while i < inner.len() {
308        if inner[i] == '\\' && i + 1 < inner.len() {
309            out.push(inner[i + 1]);
310            i += 2;
311        } else {
312            out.push(inner[i]);
313            i += 1;
314        }
315    }
316    out
317}
318
319/// Unescapes cucumber `\X` sequences in expression text, then regex-escapes so the
320/// literal text matches verbatim.
321fn escape_text(raw: &str) -> String {
322    let mut unescaped = String::new();
323    let mut chars = raw.chars();
324    while let Some(c) = chars.next() {
325        if c == '\\' {
326            if let Some(n) = chars.next() {
327                unescaped.push(n);
328            }
329        } else {
330            unescaped.push(c);
331        }
332    }
333    regex::escape(&unescaped)
334}
335
336fn alternation_regex(
337    alt: &cucumber_expressions::ast::Alternation<cucumber_expressions::ast::Spanned<'_>>,
338) -> String {
339    use cucumber_expressions::ast::Alternative;
340    let mut branches = Vec::new();
341    for single in alt.0.iter() {
342        let mut branch = String::new();
343        for alternative in single {
344            match alternative {
345                Alternative::Text(t) => branch.push_str(&escape_text(t.fragment())),
346                Alternative::Optional(o) => {
347                    branch.push_str("(?:");
348                    branch.push_str(&escape_text(o.0.fragment()));
349                    branch.push_str(")?");
350                }
351            }
352        }
353        branches.push(branch);
354    }
355    format!("(?:{})", branches.join("|"))
356}
357
358/// Parameter-type names in source order, read from the parsed AST (escaped
359/// braces `\{...\}` are literal text, not parameters).
360pub fn parameter_type_names(source: &str) -> Vec<String> {
361    let Ok(parsed) = Expression::parse(source) else {
362        return Vec::new();
363    };
364    parsed
365        .0
366        .iter()
367        .filter_map(|se| match se {
368            SingleExpression::Parameter(p) => Some((*p.input.fragment()).to_string()),
369            _ => None,
370        })
371        .collect()
372}