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 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 let mut characters = text.chars();
57 while let Some(character) = characters.next() {
58 if character != '\\' {
59 continue;
60 }
61 match characters.next() {
62 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 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}' | '\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
156pub(crate) fn rust_regex_expression(matcher: &str, flags: &str) -> Option<String> {
162 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
189pub 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: ®ex::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 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}