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 decode_js_string(text: &str) -> Option<String> {
39 let quote = text.chars().next()?;
40 if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
41 return None;
42 }
43 let body = &text[1..text.len() - 1];
44 let mut decoded = String::with_capacity(body.len());
45 let mut chars = body.chars();
46 while let Some(character) = chars.next() {
47 if character != '\\' {
48 decoded.push(character);
49 continue;
50 }
51 let escaped = chars.next()?;
52 match escaped {
53 'n' => decoded.push('\n'),
54 'r' => decoded.push('\r'),
55 't' => decoded.push('\t'),
56 'b' => decoded.push('\u{0008}'),
57 'f' => decoded.push('\u{000c}'),
58 'v' => decoded.push('\u{000b}'),
59 '0' => decoded.push('\0'),
60 '\'' => decoded.push('\''),
61 '"' => decoded.push('"'),
62 '\\' => decoded.push('\\'),
63 '\n' => {}
64 '\r' => {
65 if chars.clone().next() == Some('\n') {
66 chars.next();
67 }
68 }
69 'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
70 'u' => {
71 let value = if chars.clone().next() == Some('{') {
72 chars.next();
73 let mut digits = String::new();
74 loop {
75 let character = chars.next()?;
76 if character == '}' {
77 break;
78 }
79 digits.push(character);
80 }
81 u32::from_str_radix(&digits, 16).ok()?
82 } else {
83 decode_hex_value(&mut chars, 4)?
84 };
85 decoded.push(char::from_u32(value)?);
86 }
87 other => decoded.push(other),
88 }
89 }
90 Some(decoded)
91}
92
93fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
94 char::from_u32(decode_hex_value(chars, length)?)
95}
96
97fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
98 let digits: String = chars.take(length).collect();
99 (digits.len() == length)
100 .then(|| u32::from_str_radix(&digits, 16).ok())
101 .flatten()
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub(super) enum RegexSupport {
106 Supported,
107 Unsupported,
108 ResourceLimit,
109}
110
111pub(crate) fn rust_regex_expression(matcher: &str, flags: &str) -> Option<String> {
117 if flags.contains('v') {
120 return None;
121 }
122 let flags: String = flags
123 .chars()
124 .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
125 .collect();
126 if flags.is_empty() {
127 Some(matcher.to_owned())
128 } else {
129 Some(format!("(?{flags}:{matcher})"))
130 }
131}
132
133pub(super) fn rust_regex_support(matcher: &str, flags: &str) -> RegexSupport {
134 let Some(expression) = rust_regex_expression(matcher, flags) else {
135 return RegexSupport::Unsupported;
136 };
137 match compile_regex(&expression) {
138 Ok(_) => RegexSupport::Supported,
139 Err(regex::Error::CompiledTooBig(_)) => RegexSupport::ResourceLimit,
140 Err(_) => RegexSupport::Unsupported,
141 }
142}
143
144pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
146 let normalized: String = matcher.nfkc().collect();
147 let normalized = match kind {
148 MatcherKind::CucumberExpression => CUCUMBER_PLACEHOLDER
149 .replace_all(&normalized, |captures: ®ex::Captures<'_>| {
150 format!("{{{}}}", captures[1].trim())
151 })
152 .into_owned(),
153 MatcherKind::RegularExpression => normalize_regular_expression(&normalized),
154 };
155 normalized.split_whitespace().collect::<Vec<_>>().join(" ")
156}
157
158pub(super) fn normalize_matcher_with_flags(
159 matcher: &str,
160 kind: MatcherKind,
161 flags: &str,
162) -> String {
163 let normalized = normalize_matcher(matcher, kind);
164 let semantic_flags = semantic_regex_flags(flags);
165 if kind == MatcherKind::RegularExpression {
166 format!("[regex-flags:{semantic_flags}] {normalized}")
167 } else {
168 normalized
169 }
170}
171
172fn semantic_regex_flags(flags: &str) -> String {
173 ['i', 'm', 's', 'u']
174 .into_iter()
175 .filter(|flag| flags.contains(*flag))
176 .collect()
177}
178
179pub(super) fn normalize_regular_expression(expression: &str) -> String {
180 normalize_regex_whitespace(&normalize_capture_groups(expression))
181}
182
183fn normalize_regex_whitespace(expression: &str) -> String {
184 let characters: Vec<_> = expression.chars().collect();
185 let mut output = String::with_capacity(expression.len());
186 let mut offset = 0;
187 let mut in_class = false;
188 while offset < characters.len() {
189 match characters[offset] {
190 '[' => {
191 in_class = true;
192 output.push('[');
193 offset += 1;
194 }
195 ']' => {
196 in_class = false;
197 output.push(']');
198 offset += 1;
199 }
200 '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
201 output.push(' ');
202 offset += 2;
203 }
204 '\\' if !in_class
205 && characters.get(offset + 1) == Some(&'s')
206 && characters.get(offset + 2) == Some(&'+') =>
207 {
208 output.push(' ');
209 offset += 3;
210 }
211 '\\' => {
212 output.push('\\');
213 offset += 1;
214 if let Some(character) = characters.get(offset) {
215 output.push(*character);
216 offset += 1;
217 }
218 }
219 character => {
220 output.push(character);
221 offset += 1;
222 }
223 }
224 }
225 output
226}
227
228fn normalize_capture_groups(expression: &str) -> String {
229 let characters: Vec<_> = expression.chars().collect();
230 let mut output = String::new();
231 let mut offset = 0;
232 let mut in_class = false;
233 while offset < characters.len() {
234 match characters[offset] {
235 '\\' => {
236 output.push('\\');
237 offset += 1;
238 if let Some(character) = characters.get(offset) {
239 output.push(*character);
240 offset += 1;
241 }
242 }
243 '[' => {
244 in_class = true;
245 output.push('[');
246 offset += 1;
247 }
248 ']' => {
249 in_class = false;
250 output.push(']');
251 offset += 1;
252 }
253 '(' if !in_class => {
254 output.push('(');
255 offset += 1;
256 if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
260 {
261 let name_start = offset + 2;
262 if characters
263 .get(name_start)
264 .is_some_and(|character| character.is_alphabetic() || *character == '_')
265 {
266 if let Some(end) = characters[name_start..]
267 .iter()
268 .position(|character| *character == '>')
269 {
270 offset = name_start + end + 1;
271 }
272 }
273 }
274 }
275 character => {
276 output.push(character);
277 offset += 1;
278 }
279 }
280 }
281 output
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn javascript_string_decoding_covers_escape_forms_and_rejections() {
290 assert_eq!(
291 decode_js_string(r#""\n\r\t\b\f\v\0\"\\\x41\u0042\u{43}z""#).unwrap(),
292 "\n\r\t\u{0008}\u{000c}\u{000b}\0\"\\ABCz"
293 );
294 assert_eq!(
295 decode_js_string("'line\\\njoin'"),
296 Some("linejoin".to_owned())
297 );
298 assert_eq!(
299 decode_js_string("'line\\\r\njoin'"),
300 Some("linejoin".to_owned())
301 );
302 assert_eq!(decode_js_string(r#"'\q'"#), Some("q".to_owned()));
303 for invalid in [
304 "",
305 "plain",
306 "'unterminated",
307 r#"'\xG0'"#,
308 r#"'\u{}'"#,
309 r#"'\u{110000}'"#,
310 ] {
311 assert!(decode_js_string(invalid).is_none(), "{invalid:?}");
312 }
313 }
314
315 #[test]
316 fn regex_normalization_preserves_semantics_while_canonicalizing_equivalence() {
317 assert_eq!(
318 rust_regex_expression("^value$", "gymi").as_deref(),
319 Some("(?mi:^value$)")
320 );
321 assert_eq!(
322 rust_regex_expression("^value$", "gy").as_deref(),
323 Some("^value$")
324 );
325 assert_eq!(rust_regex_expression("[a&&b]", "v"), None);
326 assert_eq!(rust_regex_support("^value$", ""), RegexSupport::Supported);
327 assert_eq!(rust_regex_support("(", ""), RegexSupport::Unsupported);
328 assert_eq!(rust_regex_support("[a&&b]", "v"), RegexSupport::Unsupported);
329 assert_eq!(semantic_regex_flags("uugmisy"), "imsu");
330 assert_eq!(
331 normalize_matcher_with_flags("(?<name>a)\\s+b", MatcherKind::RegularExpression, "mi"),
332 "[regex-flags:im] (a) b"
333 );
334 assert_eq!(
335 normalize_matcher_with_flags("I have { int }", MatcherKind::CucumberExpression, "i"),
336 "I have {int}"
337 );
338 assert_eq!(
339 normalize_regular_expression(r"[a\ ]\s+\ value"),
340 r"[a\ ] value"
341 );
342 assert_eq!(normalize_regular_expression(r"\(literal\)"), r"\(literal\)");
343 assert_eq!(normalize_regular_expression("(?<1bad>a)"), "(?<1bad>a)");
344 assert_eq!(normalize_regular_expression("(?<open"), "(?<open");
345 assert_eq!(normalize_regular_expression("[(?<name>)]"), "[(?<name>)]");
346 }
347}