1use super::node_text;
2use crate::model::MatcherKind;
3use crate::resource_limits::compile_regex;
4use tree_sitter::Node;
5use unicode_normalization::UnicodeNormalization;
6
7pub(super) fn matcher_value(
8 node: Node<'_>,
9 source: &[u8],
10) -> Option<(String, MatcherKind, String)> {
11 let text = node_text(node, source);
12 match node.kind() {
13 "string" => decode_js_string(text)
14 .map(|value| (value, MatcherKind::CucumberExpression, String::new())),
15 "template_string" if !text.contains("${") => Some((
16 text.strip_prefix('`')?.strip_suffix('`')?.to_owned(),
17 MatcherKind::CucumberExpression,
18 String::new(),
19 )),
20 "regex" => {
21 let body = text.strip_prefix('/')?;
22 let final_slash = body.rfind('/')?;
23 Some((
24 body[..final_slash].to_owned(),
25 MatcherKind::RegularExpression,
26 body[final_slash + 1..].to_owned(),
27 ))
28 }
29 _ => None,
30 }
31}
32
33pub(super) fn static_string_key(node: Node<'_>, source: &[u8]) -> Option<String> {
41 if node.kind() != "string" {
42 return None;
43 }
44 let text = node_text(node, source);
45 let mut characters = text.chars();
52 while let Some(character) = characters.next() {
53 if character != '\\' {
54 continue;
55 }
56 match characters.next() {
57 Some('0')
60 if !characters
61 .clone()
62 .next()
63 .is_some_and(|next| next.is_ascii_digit()) => {}
64 Some(escaped) if escaped.is_ascii_digit() => return None,
65 Some(_) => {}
68 None => return None,
69 }
70 }
71 decode_js_string(text)
72}
73
74pub(super) fn decode_js_string(text: &str) -> Option<String> {
75 let quote = text.chars().next()?;
76 if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
77 return None;
78 }
79 let body = &text[1..text.len() - 1];
80 let mut decoded = String::with_capacity(body.len());
81 let mut chars = body.chars();
82 while let Some(character) = chars.next() {
83 if character != '\\' {
84 decoded.push(character);
85 continue;
86 }
87 let escaped = chars.next()?;
88 match escaped {
89 'n' => decoded.push('\n'),
90 'r' => decoded.push('\r'),
91 't' => decoded.push('\t'),
92 'b' => decoded.push('\u{0008}'),
93 'f' => decoded.push('\u{000c}'),
94 'v' => decoded.push('\u{000b}'),
95 '0' => decoded.push('\0'),
96 '\'' => decoded.push('\''),
97 '"' => decoded.push('"'),
98 '\\' => decoded.push('\\'),
99 '\n' => {}
100 '\u{2028}' | '\u{2029}' => {}
104 '\r' => {
105 if chars.clone().next() == Some('\n') {
106 chars.next();
107 }
108 }
109 'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
110 'u' => {
111 let value = if chars.clone().next() == Some('{') {
112 chars.next();
113 let mut digits = String::new();
114 loop {
115 let character = chars.next()?;
116 if character == '}' {
117 break;
118 }
119 digits.push(character);
120 }
121 u32::from_str_radix(&digits, 16).ok()?
122 } else {
123 decode_hex_value(&mut chars, 4)?
124 };
125 decoded.push(char::from_u32(value)?);
126 }
127 other => decoded.push(other),
128 }
129 }
130 Some(decoded)
131}
132
133fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
134 char::from_u32(decode_hex_value(chars, length)?)
135}
136
137fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
138 let digits: String = chars.take(length).collect();
139 (digits.len() == length)
140 .then(|| u32::from_str_radix(&digits, 16).ok())
141 .flatten()
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub(super) enum RegexSupport {
146 Supported,
147 Unsupported,
148 ResourceLimit,
149}
150
151pub(crate) fn rust_regex_expression(matcher: &str, flags: &str) -> Option<String> {
157 if flags.contains('v') {
160 return None;
161 }
162 let flags: String = flags
163 .chars()
164 .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
165 .collect();
166 if flags.is_empty() {
167 Some(matcher.to_owned())
168 } else {
169 Some(format!("(?{flags}:{matcher})"))
170 }
171}
172
173pub(super) fn rust_regex_support(matcher: &str, flags: &str) -> RegexSupport {
174 let Some(expression) = rust_regex_expression(matcher, flags) else {
175 return RegexSupport::Unsupported;
176 };
177 match compile_regex(&expression) {
178 Ok(_) => RegexSupport::Supported,
179 Err(regex::Error::CompiledTooBig(_)) => RegexSupport::ResourceLimit,
180 Err(_) => RegexSupport::Unsupported,
181 }
182}
183
184pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
186 let normalized = match kind {
187 MatcherKind::Literal => return matcher.to_owned(),
188 MatcherKind::CucumberExpression => {
189 return crate::matcher::normalize_cucumber_expression(matcher);
190 }
191 MatcherKind::RegularExpression => {
192 normalize_regular_expression(&matcher.nfkc().collect::<String>())
193 }
194 };
195 normalized.split_whitespace().collect::<Vec<_>>().join(" ")
196}
197
198pub(super) fn normalize_matcher_with_flags(
199 matcher: &str,
200 kind: MatcherKind,
201 flags: &str,
202) -> String {
203 let normalized = normalize_matcher(matcher, kind);
204 let semantic_flags = semantic_regex_flags(flags);
205 if kind == MatcherKind::RegularExpression {
206 format!("[regex-flags:{semantic_flags}] {normalized}")
207 } else {
208 normalized
209 }
210}
211
212pub(crate) fn semantic_regex_flags(flags: &str) -> String {
224 crate::source_adapter::semantics::legacy_identity_flags(flags)
225}
226
227pub(super) fn normalize_regular_expression(expression: &str) -> String {
228 normalize_regex_whitespace(&normalize_capture_groups(expression))
229}
230
231fn normalize_regex_whitespace(expression: &str) -> String {
232 let characters: Vec<_> = expression.chars().collect();
233 let mut output = String::with_capacity(expression.len());
234 let mut offset = 0;
235 let mut in_class = false;
236 while offset < characters.len() {
237 match characters[offset] {
238 '[' => {
239 in_class = true;
240 output.push('[');
241 offset += 1;
242 }
243 ']' => {
244 in_class = false;
245 output.push(']');
246 offset += 1;
247 }
248 '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
249 output.push(' ');
250 offset += 2;
251 }
252 '\\' if !in_class
253 && characters.get(offset + 1) == Some(&'s')
254 && characters.get(offset + 2) == Some(&'+') =>
255 {
256 output.push(' ');
257 offset += 3;
258 }
259 '\\' => {
260 output.push('\\');
261 offset += 1;
262 if let Some(character) = characters.get(offset) {
263 output.push(*character);
264 offset += 1;
265 }
266 }
267 character => {
268 output.push(character);
269 offset += 1;
270 }
271 }
272 }
273 output
274}
275
276fn normalize_capture_groups(expression: &str) -> String {
277 let characters: Vec<_> = expression.chars().collect();
278 let mut output = String::new();
279 let mut offset = 0;
280 let mut in_class = false;
281 while offset < characters.len() {
282 match characters[offset] {
283 '\\' => {
284 output.push('\\');
285 offset += 1;
286 if let Some(character) = characters.get(offset) {
287 output.push(*character);
288 offset += 1;
289 }
290 }
291 '[' => {
292 in_class = true;
293 output.push('[');
294 offset += 1;
295 }
296 ']' => {
297 in_class = false;
298 output.push(']');
299 offset += 1;
300 }
301 '(' if !in_class => {
302 output.push('(');
303 offset += 1;
304 if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
308 {
309 let name_start = offset + 2;
310 if characters
311 .get(name_start)
312 .is_some_and(|character| character.is_alphabetic() || *character == '_')
313 {
314 if let Some(end) = characters[name_start..]
315 .iter()
316 .position(|character| *character == '>')
317 {
318 offset = name_start + end + 1;
319 }
320 }
321 }
322 }
323 character => {
324 output.push(character);
325 offset += 1;
326 }
327 }
328 }
329 output
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
343 fn unicode_sets_flag_belongs_to_matcher_identity() {
344 let normalized = |flags: &str| {
345 normalize_matcher_with_flags("^the gauge$", MatcherKind::RegularExpression, flags)
346 };
347
348 for flags in ["v", "vi", "uv"] {
350 assert_ne!(
351 normalized(flags),
352 normalized(""),
353 "`/x/{flags}` must not share an identity with the unflagged matcher"
354 );
355 }
356 assert_ne!(
357 normalized("v"),
358 normalized("u"),
359 "`v` is not `u`: it changes character-class semantics"
360 );
361 assert_ne!(normalized("vi"), normalized("i"), "`v` survives beside `i`");
362
363 for flags in ["g", "y", "d", "gy"] {
365 assert_eq!(
366 normalized(flags),
367 normalized(""),
368 "`{flags}` does not change what a matcher matches and must stay non-semantic"
369 );
370 }
371
372 assert_eq!(normalized("v"), normalized("v"));
374 assert_eq!(
375 normalized("iv"),
376 normalized("vi"),
377 "flag order is not identity"
378 );
379
380 assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
382 }
383
384 #[test]
385 fn javascript_string_decoding_covers_escape_forms_and_rejections() {
386 assert_eq!(
387 decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
388 "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
389 );
390 assert_eq!(
391 decode_js_string("'line\\\njoin'"),
392 Some("linejoin".to_owned())
393 );
394 assert_eq!(
395 decode_js_string("'line\\\r\njoin'"),
396 Some("linejoin".to_owned())
397 );
398 assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
399 for invalid in [
400 "",
401 "plain",
402 "'unterminated",
403 r#"'\xG0'"#,
404 r#"'\u{}'"#,
405 r#"'\u{110000}'"#,
406 ] {
407 assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
408 }
409 }
410
411 #[test]
412 fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
413 assert_eq!(
414 rust_regex_expression("^value$", "gymi").as_deref(),
415 Some("(?mi:^value$)")
416 );
417 assert_eq!(
418 rust_regex_expression("^value$", "gy").as_deref(),
419 Some("^value$")
420 );
421 assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
422 assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
423 assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
424 assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
425 assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
426 assert_eq!(
427 normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
428 "[regex-flags:im] (a) b"
429 );
430 assert_eq!(
431 normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
432 "I have {int}"
433 );
434 assert_eq!(
435 normalize_regular_expression(r"[a\ ]\s+\ value"),
436 r"[a\ ] value"
437 );
438 assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
439 assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
440 assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
441 assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
442 }
443}