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