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
217pub(crate) fn semantic_regex_flags(flags: &str) -> String {
229 ['i', 'm', 's', 'u', 'v']
230 .into_iter()
231 .filter(|flag| flags.contains(*flag))
232 .collect()
233}
234
235pub(super) fn normalize_regular_expression(expression: &str) -> String {
236 normalize_regex_whitespace(&normalize_capture_groups(expression))
237}
238
239fn normalize_regex_whitespace(expression: &str) -> String {
240 let characters: Vec<_> = expression.chars().collect();
241 let mut output = String::with_capacity(expression.len());
242 let mut offset = 0;
243 let mut in_class = false;
244 while offset < characters.len() {
245 match characters[offset] {
246 '[' => {
247 in_class = true;
248 output.push('[');
249 offset += 1;
250 }
251 ']' => {
252 in_class = false;
253 output.push(']');
254 offset += 1;
255 }
256 '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
257 output.push(' ');
258 offset += 2;
259 }
260 '\\' if !in_class
261 && characters.get(offset + 1) == Some(&'s')
262 && characters.get(offset + 2) == Some(&'+') =>
263 {
264 output.push(' ');
265 offset += 3;
266 }
267 '\\' => {
268 output.push('\\');
269 offset += 1;
270 if let Some(character) = characters.get(offset) {
271 output.push(*character);
272 offset += 1;
273 }
274 }
275 character => {
276 output.push(character);
277 offset += 1;
278 }
279 }
280 }
281 output
282}
283
284fn normalize_capture_groups(expression: &str) -> String {
285 let characters: Vec<_> = expression.chars().collect();
286 let mut output = String::new();
287 let mut offset = 0;
288 let mut in_class = false;
289 while offset < characters.len() {
290 match characters[offset] {
291 '\\' => {
292 output.push('\\');
293 offset += 1;
294 if let Some(character) = characters.get(offset) {
295 output.push(*character);
296 offset += 1;
297 }
298 }
299 '[' => {
300 in_class = true;
301 output.push('[');
302 offset += 1;
303 }
304 ']' => {
305 in_class = false;
306 output.push(']');
307 offset += 1;
308 }
309 '(' if !in_class => {
310 output.push('(');
311 offset += 1;
312 if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
316 {
317 let name_start = offset + 2;
318 if characters
319 .get(name_start)
320 .is_some_and(|character| character.is_alphabetic() || *character == '_')
321 {
322 if let Some(end) = characters[name_start..]
323 .iter()
324 .position(|character| *character == '>')
325 {
326 offset = name_start + end + 1;
327 }
328 }
329 }
330 }
331 character => {
332 output.push(character);
333 offset += 1;
334 }
335 }
336 }
337 output
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
351 fn unicode_sets_flag_belongs_to_matcher_identity() {
352 let normalized = |flags: &str| {
353 normalize_matcher_with_flags("^the gauge$", MatcherKind::RegularExpression, flags)
354 };
355
356 for flags in ["v", "vi", "uv"] {
358 assert_ne!(
359 normalized(flags),
360 normalized(""),
361 "`/x/{flags}` must not share an identity with the unflagged matcher"
362 );
363 }
364 assert_ne!(
365 normalized("v"),
366 normalized("u"),
367 "`v` is not `u`: it changes character-class semantics"
368 );
369 assert_ne!(normalized("vi"), normalized("i"), "`v` survives beside `i`");
370
371 for flags in ["g", "y", "d", "gy"] {
373 assert_eq!(
374 normalized(flags),
375 normalized(""),
376 "`{flags}` does not change what a matcher matches and must stay non-semantic"
377 );
378 }
379
380 assert_eq!(normalized("v"), normalized("v"));
382 assert_eq!(
383 normalized("iv"),
384 normalized("vi"),
385 "flag order is not identity"
386 );
387
388 assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
390 }
391
392 #[test]
393 fn javascript_string_decoding_covers_escape_forms_and_rejections() {
394 assert_eq!(
395 decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
396 "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
397 );
398 assert_eq!(
399 decode_js_string("'line\\\njoin'"),
400 Some("linejoin".to_owned())
401 );
402 assert_eq!(
403 decode_js_string("'line\\\r\njoin'"),
404 Some("linejoin".to_owned())
405 );
406 assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
407 for invalid in [
408 "",
409 "plain",
410 "'unterminated",
411 r#"'\xG0'"#,
412 r#"'\u{}'"#,
413 r#"'\u{110000}'"#,
414 ] {
415 assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
416 }
417 }
418
419 #[test]
420 fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
421 assert_eq!(
422 rust_regex_expression("^value$", "gymi").as_deref(),
423 Some("(?mi:^value$)")
424 );
425 assert_eq!(
426 rust_regex_expression("^value$", "gy").as_deref(),
427 Some("^value$")
428 );
429 assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
430 assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
431 assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
432 assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
433 assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
434 assert_eq!(
435 normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
436 "[regex-flags:im] (a) b"
437 );
438 assert_eq!(
439 normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
440 "I have {int}"
441 );
442 assert_eq!(
443 normalize_regular_expression(r"[a\ ]\s+\ value"),
444 r"[a\ ] value"
445 );
446 assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
447 assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
448 assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
449 assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
450 }
451}