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