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
217fn semantic_regex_flags(flags: &str) -> String {
218    ['i', 'm', 's', 'u']
219        .into_iter()
220        .filter(|flag| flags.contains(*flag))
221        .collect()
222}
223
224pub(super) fn normalize_regular_expression(expression: &str) -> String {
225    normalize_regex_whitespace(&normalize_capture_groups(expression))
226}
227
228fn normalize_regex_whitespace(expression: &str) -> String {
229    let characters: Vec<_> = expression.chars().collect();
230    let mut output = String::with_capacity(expression.len());
231    let mut offset = 0;
232    let mut in_class = false;
233    while offset < characters.len() {
234        match characters[offset] {
235            '[' => {
236                in_class = true;
237                output.push('[');
238                offset += 1;
239            }
240            ']' => {
241                in_class = false;
242                output.push(']');
243                offset += 1;
244            }
245            '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
246                output.push(' ');
247                offset += 2;
248            }
249            '\\' if !in_class
250                && characters.get(offset + 1) == Some(&'s')
251                && characters.get(offset + 2) == Some(&'+') =>
252            {
253                output.push(' ');
254                offset += 3;
255            }
256            '\\' => {
257                output.push('\\');
258                offset += 1;
259                if let Some(character) = characters.get(offset) {
260                    output.push(*character);
261                    offset += 1;
262                }
263            }
264            character => {
265                output.push(character);
266                offset += 1;
267            }
268        }
269    }
270    output
271}
272
273fn normalize_capture_groups(expression: &str) -> String {
274    let characters: Vec<_> = expression.chars().collect();
275    let mut output = String::new();
276    let mut offset = 0;
277    let mut in_class = false;
278    while offset < characters.len() {
279        match characters[offset] {
280            '\\' => {
281                output.push('\\');
282                offset += 1;
283                if let Some(character) = characters.get(offset) {
284                    output.push(*character);
285                    offset += 1;
286                }
287            }
288            '[' => {
289                in_class = true;
290                output.push('[');
291                offset += 1;
292            }
293            ']' => {
294                in_class = false;
295                output.push(']');
296                offset += 1;
297            }
298            '(' if !in_class => {
299                output.push('(');
300                offset += 1;
301                // Named and unnamed captures have the same matching semantics. Strip only a
302                // syntactically valid name; preserve the group expression itself, non-capturing
303                // groups, lookarounds, and backreferences.
304                if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
305                {
306                    let name_start = offset + 2;
307                    if characters
308                        .get(name_start)
309                        .is_some_and(|character| character.is_alphabetic() || *character == '_')
310                    {
311                        if let Some(end) = characters[name_start..]
312                            .iter()
313                            .position(|character| *character == '>')
314                        {
315                            offset = name_start + end + 1;
316                        }
317                    }
318                }
319            }
320            character => {
321                output.push(character);
322                offset += 1;
323            }
324        }
325    }
326    output
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn javascript_string_decoding_covers_escape_forms_and_rejections() {
335        assert_eq!(
336            decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
337            "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
338        );
339        assert_eq!(
340            decode_js_string("'line\\\njoin'"),
341            Some("linejoin".to_owned())
342        );
343        assert_eq!(
344            decode_js_string("'line\\\r\njoin'"),
345            Some("linejoin".to_owned())
346        );
347        assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
348        for invalid in [
349            "",
350            "plain",
351            "'unterminated",
352            r#"'\xG0'"#,
353            r#"'\u{}'"#,
354            r#"'\u{110000}'"#,
355        ] {
356            assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
357        }
358    }
359
360    #[test]
361    fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
362        assert_eq!(
363            rust_regex_expression("^value$", "gymi").as_deref(),
364            Some("(?mi:^value$)")
365        );
366        assert_eq!(
367            rust_regex_expression("^value$", "gy").as_deref(),
368            Some("^value$")
369        );
370        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
371        assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
372        assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
373        assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
374        assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
375        assert_eq!(
376            normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
377            "[regex-flags:im] (a) b"
378        );
379        assert_eq!(
380            normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
381            "I have {int}"
382        );
383        assert_eq!(
384            normalize_regular_expression(r"[a\ ]\s+\ value"),
385            r"[a\ ]  value"
386        );
387        assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
388        assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
389        assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
390        assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
391    }
392}