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