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
38pub(super) fn decode_js_string(text: &str) -> Option<String> {
39    let quote = text.chars().next()?;
40    if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
41        return None;
42    }
43    let body = &text[1..text.len() - 1];
44    let mut decoded = String::with_capacity(body.len());
45    let mut chars = body.chars();
46    while let Some(character) = chars.next() {
47        if character != '\\' {
48            decoded.push(character);
49            continue;
50        }
51        let escaped = chars.next()?;
52        match escaped {
53            'n' => decoded.push('\n'),
54            'r' => decoded.push('\r'),
55            't' => decoded.push('\t'),
56            'b' => decoded.push('\u{0008}'),
57            'f' => decoded.push('\u{000c}'),
58            'v' => decoded.push('\u{000b}'),
59            '0' => decoded.push('\0'),
60            '\'' => decoded.push('\''),
61            '"' => decoded.push('"'),
62            '\\' => decoded.push('\\'),
63            '\n' => {}
64            '\r' => {
65                if chars.clone().next() == Some('\n') {
66                    chars.next();
67                }
68            }
69            'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
70            'u' => {
71                let value = if chars.clone().next() == Some('{') {
72                    chars.next();
73                    let mut digits = String::new();
74                    loop {
75                        let character = chars.next()?;
76                        if character == '}' {
77                            break;
78                        }
79                        digits.push(character);
80                    }
81                    u32::from_str_radix(&digits, 16).ok()?
82                } else {
83                    decode_hex_value(&mut chars, 4)?
84                };
85                decoded.push(char::from_u32(value)?);
86            }
87            other => decoded.push(other),
88        }
89    }
90    Some(decoded)
91}
92
93fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
94    char::from_u32(decode_hex_value(chars, length)?)
95}
96
97fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
98    let digits: String = chars.take(length).collect();
99    (digits.len() == length)
100        .then(|| u32::from_str_radix(&digits, 16).ok())
101        .flatten()
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub(super) enum RegexSupport {
106    Supported,
107    Unsupported,
108    ResourceLimit,
109}
110
111/// Builds the Rust regular expression for a JavaScript matcher and its flags.
112///
113/// Extraction and usage analysis must agree on this text exactly: extraction uses it to decide
114/// whether a definition is analyzable, and usage analysis uses it to match feature steps. Two
115/// copies could drift and report a limitation the analyzer did not actually hit.
116pub(crate) fn rust_regex_expression(matcher: &str, flags: &str) -> Option<String> {
117    // JavaScript's `v` flag enables Unicode set notation and changes character-class semantics.
118    // Rust's regex engine has no equivalent, so no Rust expression is authoritative for it.
119    if flags.contains('v') {
120        return None;
121    }
122    let flags: String = flags
123        .chars()
124        .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
125        .collect();
126    if flags.is_empty() {
127        Some(matcher.to_owned())
128    } else {
129        Some(format!("(?{flags}:{matcher})"))
130    }
131}
132
133pub(super) fn rust_regex_support(matcher: &str, flags: &str) -> RegexSupport {
134    let Some(expression) = rust_regex_expression(matcher, flags) else {
135        return RegexSupport::Unsupported;
136    };
137    match compile_regex(&expression) {
138        Ok(_) => RegexSupport::Supported,
139        Err(regex::Error::CompiledTooBig(_)) => RegexSupport::ResourceLimit,
140        Err(_) => RegexSupport::Unsupported,
141    }
142}
143
144/// Canonicalizes matcher text for stable equivalence comparisons.
145pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
146    let normalized: String = matcher.nfkc().collect();
147    let normalized = match kind {
148        MatcherKind::CucumberExpression => CUCUMBER_PLACEHOLDER
149            .replace_all(&normalized, |captures: &regex::Captures<'_>| {
150                format!("{{{}}}", captures[1].trim())
151            })
152            .into_owned(),
153        MatcherKind::RegularExpression => normalize_regular_expression(&normalized),
154    };
155    normalized.split_whitespace().collect::<Vec<_>>().join(" ")
156}
157
158pub(super) fn normalize_matcher_with_flags(
159    matcher: &str,
160    kind: MatcherKind,
161    flags: &str,
162) -> String {
163    let normalized = normalize_matcher(matcher, kind);
164    let semantic_flags = semantic_regex_flags(flags);
165    if kind == MatcherKind::RegularExpression {
166        format!("[regex-flags:{semantic_flags}] {normalized}")
167    } else {
168        normalized
169    }
170}
171
172fn semantic_regex_flags(flags: &str) -> String {
173    ['i', 'm', 's', 'u']
174        .into_iter()
175        .filter(|flag| flags.contains(*flag))
176        .collect()
177}
178
179pub(super) fn normalize_regular_expression(expression: &str) -> String {
180    normalize_regex_whitespace(&normalize_capture_groups(expression))
181}
182
183fn normalize_regex_whitespace(expression: &str) -> String {
184    let characters: Vec<_> = expression.chars().collect();
185    let mut output = String::with_capacity(expression.len());
186    let mut offset = 0;
187    let mut in_class = false;
188    while offset < characters.len() {
189        match characters[offset] {
190            '[' => {
191                in_class = true;
192                output.push('[');
193                offset += 1;
194            }
195            ']' => {
196                in_class = false;
197                output.push(']');
198                offset += 1;
199            }
200            '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
201                output.push(' ');
202                offset += 2;
203            }
204            '\\' if !in_class
205                && characters.get(offset + 1) == Some(&'s')
206                && characters.get(offset + 2) == Some(&'+') =>
207            {
208                output.push(' ');
209                offset += 3;
210            }
211            '\\' => {
212                output.push('\\');
213                offset += 1;
214                if let Some(character) = characters.get(offset) {
215                    output.push(*character);
216                    offset += 1;
217                }
218            }
219            character => {
220                output.push(character);
221                offset += 1;
222            }
223        }
224    }
225    output
226}
227
228fn normalize_capture_groups(expression: &str) -> String {
229    let characters: Vec<_> = expression.chars().collect();
230    let mut output = String::new();
231    let mut offset = 0;
232    let mut in_class = false;
233    while offset < characters.len() {
234        match characters[offset] {
235            '\\' => {
236                output.push('\\');
237                offset += 1;
238                if let Some(character) = characters.get(offset) {
239                    output.push(*character);
240                    offset += 1;
241                }
242            }
243            '[' => {
244                in_class = true;
245                output.push('[');
246                offset += 1;
247            }
248            ']' => {
249                in_class = false;
250                output.push(']');
251                offset += 1;
252            }
253            '(' if !in_class => {
254                output.push('(');
255                offset += 1;
256                // Named and unnamed captures have the same matching semantics. Strip only a
257                // syntactically valid name; preserve the group expression itself, non-capturing
258                // groups, lookarounds, and backreferences.
259                if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
260                {
261                    let name_start = offset + 2;
262                    if characters
263                        .get(name_start)
264                        .is_some_and(|character| character.is_alphabetic() || *character == '_')
265                    {
266                        if let Some(end) = characters[name_start..]
267                            .iter()
268                            .position(|character| *character == '>')
269                        {
270                            offset = name_start + end + 1;
271                        }
272                    }
273                }
274            }
275            character => {
276                output.push(character);
277                offset += 1;
278            }
279        }
280    }
281    output
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn javascript_string_decoding_covers_escape_forms_and_rejections() {
290        assert_eq!(
291            decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
292            "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
293        );
294        assert_eq!(
295            decode_js_string("'line\\\njoin'"),
296            Some("linejoin".to_owned())
297        );
298        assert_eq!(
299            decode_js_string("'line\\\r\njoin'"),
300            Some("linejoin".to_owned())
301        );
302        assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
303        for invalid in [
304            "",
305            "plain",
306            "'unterminated",
307            r#"'\xG0'"#,
308            r#"'\u{}'"#,
309            r#"'\u{110000}'"#,
310        ] {
311            assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
312        }
313    }
314
315    #[test]
316    fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
317        assert_eq!(
318            rust_regex_expression("^value$", "gymi").as_deref(),
319            Some("(?mi:^value$)")
320        );
321        assert_eq!(
322            rust_regex_expression("^value$", "gy").as_deref(),
323            Some("^value$")
324        );
325        assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
326        assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
327        assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
328        assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
329        assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
330        assert_eq!(
331            normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
332            "[regex-flags:im] (a) b"
333        );
334        assert_eq!(
335            normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
336            "I have {int}"
337        );
338        assert_eq!(
339            normalize_regular_expression(r"[a\ ]\s+\ value"),
340            r"[a\ ]  value"
341        );
342        assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
343        assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
344        assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
345        assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
346    }
347}