Skip to main content

cuke_dedup/typescript/
matcher.rs

1use super::node_text;
2use crate::model::MatcherKind;
3use crate::resource_limits::compile_regex;
4use regex::Regex;
5use std::sync::LazyLock;
6use tree_sitter::Node;
7use unicode_normalization::UnicodeNormalization;
8
9static CUCUMBER_PLACEHOLDER: LazyLock<Regex> =
10    LazyLock::new(|| Regex::new(r"\{\s*([^{}]+?)\s*\}").expect("static placeholder regex"));
11
12pub(super) fn matcher_value(
13    node: Node<'_>,
14    source: &[u8],
15) -> Option<(String, MatcherKind, String)> {
16    let text = node_text(node, source);
17    match node.kind() {
18        "string" => decode_js_string(text)
19            .map(|value| (value, MatcherKind::CucumberExpression, String::new())),
20        "template_string" if !text.contains("${") => Some((
21            text.strip_prefix('`')?.strip_suffix('`')?.to_owned(),
22            MatcherKind::CucumberExpression,
23            String::new(),
24        )),
25        "regex" => {
26            let body = text.strip_prefix('/')?;
27            let final_slash = body.rfind('/')?;
28            Some((
29                body[..final_slash].to_owned(),
30                MatcherKind::RegularExpression,
31                body[final_slash + 1..].to_owned(),
32            ))
33        }
34        _ => None,
35    }
36}
37
38/// Decodes a property key that is written as a static string literal.
39///
40/// Deciding this from text alone is unsound: any expression whose text merely begins and ends with
41/// a matching quote — a sequence expression like `['a', f(), 'b']` or a concatenation like
42/// `['a' + 'b']` — would otherwise decode into a key that names nothing, either inventing a
43/// property or masking an unknown one. Requiring the node to be a string literal keeps an
44/// undecodable key undecodable, so callers apply their conservative branch instead.
45pub(super) fn static_string_key(node: Node<'_>, source: &[u8]) -> Option<String> {
46    if node.kind() != "string" {
47        return None;
48    }
49    let text = node_text(node, source);
50    // Legacy numeric escapes are not decoded here: `'\157'` is `o`, but the shared decoder reads
51    // the digits literally and would answer `157`. A wrong name is worse than no name — it grants
52    // trust to a property the source never mentions — so an unsupported escape stays unreadable.
53    //
54    // Walk the escapes rather than scanning adjacent bytes: in `'foo\\5'` the first backslash
55    // escapes the second and the `5` is an ordinary character, so the key is readable.
56    let mut characters = text.chars();
57    while let Some(character) = characters.next() {
58        if character != '\\' {
59            continue;
60        }
61        match characters.next() {
62            // `\0` is NUL unless another digit follows, and the decoder handles it. Only the
63            // legacy octal forms are unreadable here.
64            Some('0')
65                if !characters
66                    .clone()
67                    .next()
68                    .is_some_and(|next| next.is_ascii_digit()) => {}
69            Some(escaped) if escaped.is_ascii_digit() => return None,
70            // Every other escape consumes its character, so a doubled backslash cannot be
71            // mistaken for the start of one.
72            Some(_) => {}
73            None => return None,
74        }
75    }
76    decode_js_string(text)
77}
78
79pub(super) fn decode_js_string(text: &str) -> Option<String> {
80    let quote = text.chars().next()?;
81    if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
82        return None;
83    }
84    let body = &text[1..text.len() - 1];
85    let mut decoded = String::with_capacity(body.len());
86    let mut chars = body.chars();
87    while let Some(character) = chars.next() {
88        if character != '\\' {
89            decoded.push(character);
90            continue;
91        }
92        let escaped = chars.next()?;
93        match escaped {
94            'n' => decoded.push('\n'),
95            'r' => decoded.push('\r'),
96            't' => decoded.push('\t'),
97            'b' => decoded.push('\u{0008}'),
98            'f' => decoded.push('\u{000c}'),
99            'v' => decoded.push('\u{000b}'),
100            '0' => decoded.push('\0'),
101            '\'' => decoded.push('\''),
102            '"' => decoded.push('"'),
103            '\\' => decoded.push('\\'),
104            '\n' => {}
105            // U+2028 and U+2029 are JavaScript line terminators, so a backslash before either is a
106            // line continuation that contributes nothing. Preserving the separator instead would
107            // both lose the real name and conflate the key with one that genuinely contains it.
108            '\u{2028}' | '\u{2029}' => {}
109            '\r' => {
110                if chars.clone().next() == Some('\n') {
111                    chars.next();
112                }
113            }
114            'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
115            'u' => {
116                let value = if chars.clone().next() == Some('{') {
117                    chars.next();
118                    let mut digits = String::new();
119                    loop {
120                        let character = chars.next()?;
121                        if character == '}' {
122                            break;
123                        }
124                        digits.push(character);
125                    }
126                    u32::from_str_radix(&digits, 16).ok()?
127                } else {
128                    decode_hex_value(&mut chars, 4)?
129                };
130                decoded.push(char::from_u32(value)?);
131            }
132            other => decoded.push(other),
133        }
134    }
135    Some(decoded)
136}
137
138fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
139    char::from_u32(decode_hex_value(chars, length)?)
140}
141
142fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
143    let digits: String = chars.take(length).collect();
144    (digits.len() == length)
145        .then(|| u32::from_str_radix(&digits, 16).ok())
146        .flatten()
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub(super) enum RegexSupport {
151    Supported,
152    Unsupported,
153    ResourceLimit,
154}
155
156/// Builds the Rust regular expression for a JavaScript matcher and its flags.
157///
158/// Extraction and usage analysis must agree on this text exactly: extraction uses it to decide
159/// whether a definition is analyzable, and usage analysis uses it to match feature steps. Two
160/// copies could drift and report a limitation the analyzer did not actually hit.
161pub(crate) fn rust_regex_expression(matcher: &str, flags: &str) -> Option<String> {
162    // JavaScript's `v` flag enables Unicode set notation and changes character-class semantics.
163    // Rust's regex engine has no equivalent, so no Rust expression is authoritative for it.
164    if flags.contains('v') {
165        return None;
166    }
167    let flags: String = flags
168        .chars()
169        .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
170        .collect();
171    if flags.is_empty() {
172        Some(matcher.to_owned())
173    } else {
174        Some(format!("(?{flags}:{matcher})"))
175    }
176}
177
178pub(super) fn rust_regex_support(matcher: &str, flags: &str) -> RegexSupport {
179    let Some(expression) = rust_regex_expression(matcher, flags) else {
180        return RegexSupport::Unsupported;
181    };
182    match compile_regex(&expression) {
183        Ok(_) => RegexSupport::Supported,
184        Err(regex::Error::CompiledTooBig(_)) => RegexSupport::ResourceLimit,
185        Err(_) => RegexSupport::Unsupported,
186    }
187}
188
189/// Canonicalizes matcher text for stable equivalence comparisons.
190pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
191    let normalized: String = matcher.nfkc().collect();
192    let normalized = match kind {
193        MatcherKind::CucumberExpression => CUCUMBER_PLACEHOLDER
194            .replace_all(&normalized, |captures: &regex::Captures<'_>| {
195                format!("{{{}}}", captures[1].trim())
196            })
197            .into_owned(),
198        MatcherKind::RegularExpression => normalize_regular_expression(&normalized),
199    };
200    normalized.split_whitespace().collect::<Vec<_>>().join(" ")
201}
202
203pub(super) fn normalize_matcher_with_flags(
204    matcher: &str,
205    kind: MatcherKind,
206    flags: &str,
207) -> String {
208    let normalized = normalize_matcher(matcher, kind);
209    let semantic_flags = semantic_regex_flags(flags);
210    if kind == MatcherKind::RegularExpression {
211        format!("[regex-flags:{semantic_flags}] {normalized}")
212    } else {
213        normalized
214    }
215}
216
217/// The flags that change what a matcher matches, and therefore belong to its identity.
218///
219/// `v` is included: it enables Unicode set notation and changes character-class semantics, so it is
220/// at least as significant as `u`, which it implies. Leaving it out made `/x/v` normalize to the
221/// text of `/x/` and reported the two as equivalent. `g`, `y` and `d` affect only how a match is
222/// executed or reported, never which inputs match, so they stay out.
223fn semantic_regex_flags(flags: &str) -> String {
224    ['i', 'm', 's', 'u', 'v']
225        .into_iter()
226        .filter(|flag| flags.contains(*flag))
227        .collect()
228}
229
230pub(super) fn normalize_regular_expression(expression: &str) -> String {
231    normalize_regex_whitespace(&normalize_capture_groups(expression))
232}
233
234fn normalize_regex_whitespace(expression: &str) -> String {
235    let characters: Vec<_> = expression.chars().collect();
236    let mut output = String::with_capacity(expression.len());
237    let mut offset = 0;
238    let mut in_class = false;
239    while offset < characters.len() {
240        match characters[offset] {
241            '[' => {
242                in_class = true;
243                output.push('[');
244                offset += 1;
245            }
246            ']' => {
247                in_class = false;
248                output.push(']');
249                offset += 1;
250            }
251            '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
252                output.push(' ');
253                offset += 2;
254            }
255            '\\' if !in_class
256                && characters.get(offset + 1) == Some(&'s')
257                && characters.get(offset + 2) == Some(&'+') =>
258            {
259                output.push(' ');
260                offset += 3;
261            }
262            '\\' => {
263                output.push('\\');
264                offset += 1;
265                if let Some(character) = characters.get(offset) {
266                    output.push(*character);
267                    offset += 1;
268                }
269            }
270            character => {
271                output.push(character);
272                offset += 1;
273            }
274        }
275    }
276    output
277}
278
279fn normalize_capture_groups(expression: &str) -> String {
280    let characters: Vec<_> = expression.chars().collect();
281    let mut output = String::new();
282    let mut offset = 0;
283    let mut in_class = false;
284    while offset < characters.len() {
285        match characters[offset] {
286            '\\' => {
287                output.push('\\');
288                offset += 1;
289                if let Some(character) = characters.get(offset) {
290                    output.push(*character);
291                    offset += 1;
292                }
293            }
294            '[' => {
295                in_class = true;
296                output.push('[');
297                offset += 1;
298            }
299            ']' => {
300                in_class = false;
301                output.push(']');
302                offset += 1;
303            }
304            '(' if !in_class => {
305                output.push('(');
306                offset += 1;
307                // Named and unnamed captures have the same matching semantics. Strip only a
308                // syntactically valid name; preserve the group expression itself, non-capturing
309                // groups, lookarounds, and backreferences.
310                if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
311                {
312                    let name_start = offset + 2;
313                    if characters
314                        .get(name_start)
315                        .is_some_and(|character| character.is_alphabetic() || *character == '_')
316                    {
317                        if let Some(end) = characters[name_start..]
318                            .iter()
319                            .position(|character| *character == '>')
320                        {
321                            offset = name_start + end + 1;
322                        }
323                    }
324                }
325            }
326            character => {
327                output.push(character);
328                offset += 1;
329            }
330        }
331    }
332    output
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    /// `v` enables Unicode set notation and changes character-class semantics, so it belongs to a
340    /// matcher's identity exactly as `u` does — more so, since `v` implies `u`. Dropping it made
341    /// `/x/v` normalize to the text of `/x/` and reported the two as equivalent.
342    ///
343    /// The non-semantic flags are pinned alongside it, so widening the set cannot pass this test by
344    /// making every flag significant.
345    #[test]
346    fn unicode_sets_flag_belongs_to_matcher_identity() {
347        let normalized = |flags: &str| {
348            normalize_matcher_with_flags("^the gauge$", MatcherKind::RegularExpression, flags)
349        };
350
351        // The defect: these three were indistinguishable from the unflagged spelling.
352        for flags in ["v", "vi", "uv"] {
353            assert_ne!(
354                normalized(flags),
355                normalized(""),
356                "`/x/{flags}` must not share an identity with the unflagged matcher"
357            );
358        }
359        assert_ne!(
360            normalized("v"),
361            normalized("u"),
362            "`v` is not `u`: it changes character-class semantics"
363        );
364        assert_ne!(normalized("vi"), normalized("i"), "`v` survives beside `i`");
365
366        // Execution-only flags stay stripped, so the set was widened rather than abandoned.
367        for flags in ["g", "y", "d", "gy"] {
368            assert_eq!(
369                normalized(flags),
370                normalized(""),
371                "`{flags}` does not change what a matcher matches and must stay non-semantic"
372            );
373        }
374
375        // `v` is a flag, not an opaque marker: two matchers carrying it still share an identity.
376        assert_eq!(normalized("v"), normalized("v"));
377        assert_eq!(
378            normalized("iv"),
379            normalized("vi"),
380            "flag order is not identity"
381        );
382
383        // A `v` matcher is still unsupported for compilation, which this must not change.
384        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
385    }
386
387    #[test]
388    fn javascript_string_decoding_covers_escape_forms_and_rejections() {
389        assert_eq!(
390            decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
391            "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
392        );
393        assert_eq!(
394            decode_js_string("'line\\\njoin'"),
395            Some("linejoin".to_owned())
396        );
397        assert_eq!(
398            decode_js_string("'line\\\r\njoin'"),
399            Some("linejoin".to_owned())
400        );
401        assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
402        for invalid in [
403            "",
404            "plain",
405            "'unterminated",
406            r#"'\xG0'"#,
407            r#"'\u{}'"#,
408            r#"'\u{110000}'"#,
409        ] {
410            assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
411        }
412    }
413
414    #[test]
415    fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
416        assert_eq!(
417            rust_regex_expression("^value$", "gymi").as_deref(),
418            Some("(?mi:^value$)")
419        );
420        assert_eq!(
421            rust_regex_expression("^value$", "gy").as_deref(),
422            Some("^value$")
423        );
424        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
425        assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
426        assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
427        assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
428        assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
429        assert_eq!(
430            normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
431            "[regex-flags:im] (a) b"
432        );
433        assert_eq!(
434            normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
435            "I have {int}"
436        );
437        assert_eq!(
438            normalize_regular_expression(r"[a\ ]\s+\ value"),
439            r"[a\ ]  value"
440        );
441        assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
442        assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
443        assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
444        assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
445    }
446}