Skip to main content

cuke_dedup/typescript/
matcher.rs

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