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.
223/// The flags that change what a matcher accepts, in a fixed order.
224///
225/// `g`, `y` and `d` affect how a caller drives the regex, not the language it accepts, so two
226/// matchers differing only in those are the same matcher. `docs/rules.md` calls this the
227/// "effective" flag set and both the exact-duplicate class and normalization key on it.
228pub(crate) fn semantic_regex_flags(flags: &str) -> String {
229    ['i', 'm', 's', 'u', 'v']
230        .into_iter()
231        .filter(|flag| flags.contains(*flag))
232        .collect()
233}
234
235pub(super) fn normalize_regular_expression(expression: &str) -> String {
236    normalize_regex_whitespace(&normalize_capture_groups(expression))
237}
238
239fn normalize_regex_whitespace(expression: &str) -> String {
240    let characters: Vec<_> = expression.chars().collect();
241    let mut output = String::with_capacity(expression.len());
242    let mut offset = 0;
243    let mut in_class = false;
244    while offset < characters.len() {
245        match characters[offset] {
246            '[' => {
247                in_class = true;
248                output.push('[');
249                offset += 1;
250            }
251            ']' => {
252                in_class = false;
253                output.push(']');
254                offset += 1;
255            }
256            '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
257                output.push(' ');
258                offset += 2;
259            }
260            '\\' if !in_class
261                && characters.get(offset + 1) == Some(&'s')
262                && characters.get(offset + 2) == Some(&'+') =>
263            {
264                output.push(' ');
265                offset += 3;
266            }
267            '\\' => {
268                output.push('\\');
269                offset += 1;
270                if let Some(character) = characters.get(offset) {
271                    output.push(*character);
272                    offset += 1;
273                }
274            }
275            character => {
276                output.push(character);
277                offset += 1;
278            }
279        }
280    }
281    output
282}
283
284fn normalize_capture_groups(expression: &str) -> String {
285    let characters: Vec<_> = expression.chars().collect();
286    let mut output = String::new();
287    let mut offset = 0;
288    let mut in_class = false;
289    while offset < characters.len() {
290        match characters[offset] {
291            '\\' => {
292                output.push('\\');
293                offset += 1;
294                if let Some(character) = characters.get(offset) {
295                    output.push(*character);
296                    offset += 1;
297                }
298            }
299            '[' => {
300                in_class = true;
301                output.push('[');
302                offset += 1;
303            }
304            ']' => {
305                in_class = false;
306                output.push(']');
307                offset += 1;
308            }
309            '(' if !in_class => {
310                output.push('(');
311                offset += 1;
312                // Named and unnamed captures have the same matching semantics. Strip only a
313                // syntactically valid name; preserve the group expression itself, non-capturing
314                // groups, lookarounds, and backreferences.
315                if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
316                {
317                    let name_start = offset + 2;
318                    if characters
319                        .get(name_start)
320                        .is_some_and(|character| character.is_alphabetic() || *character == '_')
321                    {
322                        if let Some(end) = characters[name_start..]
323                            .iter()
324                            .position(|character| *character == '>')
325                        {
326                            offset = name_start + end + 1;
327                        }
328                    }
329                }
330            }
331            character => {
332                output.push(character);
333                offset += 1;
334            }
335        }
336    }
337    output
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    /// `v` enables Unicode set notation and changes character-class semantics, so it belongs to a
345    /// matcher's identity exactly as `u` does — more so, since `v` implies `u`. Dropping it made
346    /// `/x/v` normalize to the text of `/x/` and reported the two as equivalent.
347    ///
348    /// The non-semantic flags are pinned alongside it, so widening the set cannot pass this test by
349    /// making every flag significant.
350    #[test]
351    fn unicode_sets_flag_belongs_to_matcher_identity() {
352        let normalized = |flags: &str| {
353            normalize_matcher_with_flags("^the gauge$", MatcherKind::RegularExpression, flags)
354        };
355
356        // The defect: these three were indistinguishable from the unflagged spelling.
357        for flags in ["v", "vi", "uv"] {
358            assert_ne!(
359                normalized(flags),
360                normalized(""),
361                "`/x/{flags}` must not share an identity with the unflagged matcher"
362            );
363        }
364        assert_ne!(
365            normalized("v"),
366            normalized("u"),
367            "`v` is not `u`: it changes character-class semantics"
368        );
369        assert_ne!(normalized("vi"), normalized("i"), "`v` survives beside `i`");
370
371        // Execution-only flags stay stripped, so the set was widened rather than abandoned.
372        for flags in ["g", "y", "d", "gy"] {
373            assert_eq!(
374                normalized(flags),
375                normalized(""),
376                "`{flags}` does not change what a matcher matches and must stay non-semantic"
377            );
378        }
379
380        // `v` is a flag, not an opaque marker: two matchers carrying it still share an identity.
381        assert_eq!(normalized("v"), normalized("v"));
382        assert_eq!(
383            normalized("iv"),
384            normalized("vi"),
385            "flag order is not identity"
386        );
387
388        // A `v` matcher is still unsupported for compilation, which this must not change.
389        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
390    }
391
392    #[test]
393    fn javascript_string_decoding_covers_escape_forms_and_rejections() {
394        assert_eq!(
395            decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
396            "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
397        );
398        assert_eq!(
399            decode_js_string("'line\\\njoin'"),
400            Some("linejoin".to_owned())
401        );
402        assert_eq!(
403            decode_js_string("'line\\\r\njoin'"),
404            Some("linejoin".to_owned())
405        );
406        assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
407        for invalid in [
408            "",
409            "plain",
410            "'unterminated",
411            r#"'\xG0'"#,
412            r#"'\u{}'"#,
413            r#"'\u{110000}'"#,
414        ] {
415            assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
416        }
417    }
418
419    #[test]
420    fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
421        assert_eq!(
422            rust_regex_expression("^value$", "gymi").as_deref(),
423            Some("(?mi:^value$)")
424        );
425        assert_eq!(
426            rust_regex_expression("^value$", "gy").as_deref(),
427            Some("^value$")
428        );
429        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
430        assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
431        assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
432        assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
433        assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
434        assert_eq!(
435            normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
436            "[regex-flags:im] (a) b"
437        );
438        assert_eq!(
439            normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
440            "I have {int}"
441        );
442        assert_eq!(
443            normalize_regular_expression(r"[a\ ]\s+\ value"),
444            r"[a\ ]  value"
445        );
446        assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
447        assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
448        assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
449        assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
450    }
451}