Skip to main content

cuke_dedup/typescript/
matcher.rs

1use super::node_text;
2use crate::model::MatcherKind;
3use regex::Regex;
4use std::sync::LazyLock;
5use tree_sitter::Node;
6use unicode_normalization::UnicodeNormalization;
7
8static CUCUMBER_PLACEHOLDER: LazyLock<Regex> =
9    LazyLock::new(|| Regex::new(r"\{\s*([^{}]+?)\s*\}").expect("static placeholder regex"));
10
11pub(super) fn matcher_value(
12    node: Node<'_>,
13    source: &[u8],
14) -> Option<(String, MatcherKind, String)> {
15    let text = node_text(node, source);
16    match node.kind() {
17        "string" => decode_js_string(text)
18            .map(|value| (value, MatcherKind::CucumberExpression, String::new())),
19        "template_string" if !text.contains("${") => Some((
20            text.strip_prefix('`')?.strip_suffix('`')?.to_owned(),
21            MatcherKind::CucumberExpression,
22            String::new(),
23        )),
24        "regex" => {
25            let body = text.strip_prefix('/')?;
26            let final_slash = body.rfind('/')?;
27            Some((
28                body[..final_slash].to_owned(),
29                MatcherKind::RegularExpression,
30                body[final_slash + 1..].to_owned(),
31            ))
32        }
33        _ => None,
34    }
35}
36
37fn decode_js_string(text: &str) -> Option<String> {
38    let quote = text.chars().next()?;
39    if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
40        return None;
41    }
42    let body = &text[1..text.len() - 1];
43    let mut decoded = String::with_capacity(body.len());
44    let mut chars = body.chars();
45    while let Some(character) = chars.next() {
46        if character != '\\' {
47            decoded.push(character);
48            continue;
49        }
50        let escaped = chars.next()?;
51        match escaped {
52            'n' => decoded.push('\n'),
53            'r' => decoded.push('\r'),
54            't' => decoded.push('\t'),
55            'b' => decoded.push('\u{0008}'),
56            'f' => decoded.push('\u{000c}'),
57            'v' => decoded.push('\u{000b}'),
58            '0' => decoded.push('\0'),
59            '\'' => decoded.push('\''),
60            '"' => decoded.push('"'),
61            '\\' => decoded.push('\\'),
62            '\n' => {}
63            '\r' => {
64                if chars.clone().next() == Some('\n') {
65                    chars.next();
66                }
67            }
68            'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
69            'u' => {
70                let value = if chars.clone().next() == Some('{') {
71                    chars.next();
72                    let mut digits = String::new();
73                    loop {
74                        let character = chars.next()?;
75                        if character == '}' {
76                            break;
77                        }
78                        digits.push(character);
79                    }
80                    u32::from_str_radix(&digits, 16).ok()?
81                } else {
82                    decode_hex_value(&mut chars, 4)?
83                };
84                decoded.push(char::from_u32(value)?);
85            }
86            other => decoded.push(other),
87        }
88    }
89    Some(decoded)
90}
91
92fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
93    char::from_u32(decode_hex_value(chars, length)?)
94}
95
96fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
97    let digits: String = chars.take(length).collect();
98    (digits.len() == length)
99        .then(|| u32::from_str_radix(&digits, 16).ok())
100        .flatten()
101}
102
103pub(super) fn rust_regex_supported(matcher: &str, flags: &str) -> bool {
104    let flags: String = flags
105        .chars()
106        .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
107        .collect();
108    let expression = if flags.is_empty() {
109        matcher.to_owned()
110    } else {
111        format!("(?{flags}:{matcher})")
112    };
113    Regex::new(&expression).is_ok()
114}
115
116/// Canonicalizes matcher text for stable equivalence comparisons.
117pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
118    let normalized: String = matcher.nfkc().collect();
119    let normalized = match kind {
120        MatcherKind::CucumberExpression => CUCUMBER_PLACEHOLDER
121            .replace_all(&normalized, |captures: &regex::Captures<'_>| {
122                format!("{{{}}}", captures[1].trim())
123            })
124            .into_owned(),
125        MatcherKind::RegularExpression => normalize_regular_expression(&normalized),
126    };
127    normalized.split_whitespace().collect::<Vec<_>>().join(" ")
128}
129
130pub(super) fn normalize_matcher_with_flags(
131    matcher: &str,
132    kind: MatcherKind,
133    flags: &str,
134) -> String {
135    let normalized = normalize_matcher(matcher, kind);
136    let semantic_flags = semantic_regex_flags(flags);
137    if kind == MatcherKind::RegularExpression {
138        format!("[regex-flags:{semantic_flags}] {normalized}")
139    } else {
140        normalized
141    }
142}
143
144fn semantic_regex_flags(flags: &str) -> String {
145    ['i', 'm', 's', 'u']
146        .into_iter()
147        .filter(|flag| flags.contains(*flag))
148        .collect()
149}
150
151pub(super) fn normalize_regular_expression(expression: &str) -> String {
152    normalize_regex_whitespace(&normalize_capture_groups(expression))
153}
154
155fn normalize_regex_whitespace(expression: &str) -> String {
156    let characters: Vec<_> = expression.chars().collect();
157    let mut output = String::with_capacity(expression.len());
158    let mut offset = 0;
159    let mut in_class = false;
160    while offset < characters.len() {
161        match characters[offset] {
162            '[' => {
163                in_class = true;
164                output.push('[');
165                offset += 1;
166            }
167            ']' => {
168                in_class = false;
169                output.push(']');
170                offset += 1;
171            }
172            '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
173                output.push(' ');
174                offset += 2;
175            }
176            '\\' if !in_class
177                && characters.get(offset + 1) == Some(&'s')
178                && characters.get(offset + 2) == Some(&'+') =>
179            {
180                output.push(' ');
181                offset += 3;
182            }
183            '\\' => {
184                output.push('\\');
185                offset += 1;
186                if let Some(character) = characters.get(offset) {
187                    output.push(*character);
188                    offset += 1;
189                }
190            }
191            character => {
192                output.push(character);
193                offset += 1;
194            }
195        }
196    }
197    output
198}
199
200fn normalize_capture_groups(expression: &str) -> String {
201    let characters: Vec<_> = expression.chars().collect();
202    let mut output = String::new();
203    let mut offset = 0;
204    let mut in_class = false;
205    while offset < characters.len() {
206        match characters[offset] {
207            '\\' => {
208                output.push('\\');
209                offset += 1;
210                if let Some(character) = characters.get(offset) {
211                    output.push(*character);
212                    offset += 1;
213                }
214            }
215            '[' => {
216                in_class = true;
217                output.push('[');
218                offset += 1;
219            }
220            ']' => {
221                in_class = false;
222                output.push(']');
223                offset += 1;
224            }
225            '(' if !in_class => {
226                output.push('(');
227                offset += 1;
228                // Named and unnamed captures have the same matching semantics. Strip only a
229                // syntactically valid name; preserve the group expression itself, non-capturing
230                // groups, lookarounds, and backreferences.
231                if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
232                {
233                    let name_start = offset + 2;
234                    if characters
235                        .get(name_start)
236                        .is_some_and(|character| character.is_alphabetic() || *character == '_')
237                    {
238                        if let Some(end) = characters[name_start..]
239                            .iter()
240                            .position(|character| *character == '>')
241                        {
242                            offset = name_start + end + 1;
243                        }
244                    }
245                }
246            }
247            character => {
248                output.push(character);
249                offset += 1;
250            }
251        }
252    }
253    output
254}