cuke_dedup/typescript/
matcher.rs1use super::node_text;
2use crate::model::MatcherKind;
3use regex::Regex;
4use std::sync::LazyLock;
5use tree_sitter::Node;
6use unicode_normalization::UnicodeNormalization;
7
8static CUCUMBER_PLACEHOLDER: LazyLock<Regex> =
9 LazyLock::new(|| Regex::new(r"\{\s*([^{}]+?)\s*\}").expect("static placeholder regex"));
10
11pub(super) fn matcher_value(
12 node: Node<'_>,
13 source: &[u8],
14) -> Option<(String, MatcherKind, String)> {
15 let text = node_text(node, source);
16 match node.kind() {
17 "string" => decode_js_string(text)
18 .map(|value| (value, MatcherKind::CucumberExpression, String::new())),
19 "template_string" if !text.contains("${") => Some((
20 text.strip_prefix('`')?.strip_suffix('`')?.to_owned(),
21 MatcherKind::CucumberExpression,
22 String::new(),
23 )),
24 "regex" => {
25 let body = text.strip_prefix('/')?;
26 let final_slash = body.rfind('/')?;
27 Some((
28 body[..final_slash].to_owned(),
29 MatcherKind::RegularExpression,
30 body[final_slash + 1..].to_owned(),
31 ))
32 }
33 _ => None,
34 }
35}
36
37fn decode_js_string(text: &str) -> Option<String> {
38 let quote = text.chars().next()?;
39 if text.chars().last()? != quote || !matches!(quote, '\'' | '"') {
40 return None;
41 }
42 let body = &text[1..text.len() - 1];
43 let mut decoded = String::with_capacity(body.len());
44 let mut chars = body.chars();
45 while let Some(character) = chars.next() {
46 if character != '\\' {
47 decoded.push(character);
48 continue;
49 }
50 let escaped = chars.next()?;
51 match escaped {
52 'n' => decoded.push('\n'),
53 'r' => decoded.push('\r'),
54 't' => decoded.push('\t'),
55 'b' => decoded.push('\u{0008}'),
56 'f' => decoded.push('\u{000c}'),
57 'v' => decoded.push('\u{000b}'),
58 '0' => decoded.push('\0'),
59 '\'' => decoded.push('\''),
60 '"' => decoded.push('"'),
61 '\\' => decoded.push('\\'),
62 '\n' => {}
63 '\r' => {
64 if chars.clone().next() == Some('\n') {
65 chars.next();
66 }
67 }
68 'x' => decoded.push(decode_hex_escape(&mut chars, 2)?),
69 'u' => {
70 let value = if chars.clone().next() == Some('{') {
71 chars.next();
72 let mut digits = String::new();
73 loop {
74 let character = chars.next()?;
75 if character == '}' {
76 break;
77 }
78 digits.push(character);
79 }
80 u32::from_str_radix(&digits, 16).ok()?
81 } else {
82 decode_hex_value(&mut chars, 4)?
83 };
84 decoded.push(char::from_u32(value)?);
85 }
86 other => decoded.push(other),
87 }
88 }
89 Some(decoded)
90}
91
92fn decode_hex_escape(chars: &mut std::str::Chars<'_>, length: usize) -> Option<char> {
93 char::from_u32(decode_hex_value(chars, length)?)
94}
95
96fn decode_hex_value(chars: &mut std::str::Chars<'_>, length: usize) -> Option<u32> {
97 let digits: String = chars.take(length).collect();
98 (digits.len() == length)
99 .then(|| u32::from_str_radix(&digits, 16).ok())
100 .flatten()
101}
102
103pub(super) fn rust_regex_supported(matcher: &str, flags: &str) -> bool {
104 let flags: String = flags
105 .chars()
106 .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
107 .collect();
108 let expression = if flags.is_empty() {
109 matcher.to_owned()
110 } else {
111 format!("(?{flags}:{matcher})")
112 };
113 Regex::new(&expression).is_ok()
114}
115
116pub fn normalize_matcher(matcher: &str, kind: MatcherKind) -> String {
118 let normalized: String = matcher.nfkc().collect();
119 let normalized = match kind {
120 MatcherKind::CucumberExpression => CUCUMBER_PLACEHOLDER
121 .replace_all(&normalized, |captures: ®ex::Captures<'_>| {
122 format!("{{{}}}", captures[1].trim())
123 })
124 .into_owned(),
125 MatcherKind::RegularExpression => normalize_regular_expression(&normalized),
126 };
127 normalized.split_whitespace().collect::<Vec<_>>().join(" ")
128}
129
130pub(super) fn normalize_matcher_with_flags(
131 matcher: &str,
132 kind: MatcherKind,
133 flags: &str,
134) -> String {
135 let normalized = normalize_matcher(matcher, kind);
136 let semantic_flags = semantic_regex_flags(flags);
137 if kind == MatcherKind::RegularExpression {
138 format!("[regex-flags:{semantic_flags}] {normalized}")
139 } else {
140 normalized
141 }
142}
143
144fn semantic_regex_flags(flags: &str) -> String {
145 ['i', 'm', 's', 'u']
146 .into_iter()
147 .filter(|flag| flags.contains(*flag))
148 .collect()
149}
150
151pub(super) fn normalize_regular_expression(expression: &str) -> String {
152 normalize_regex_whitespace(&normalize_capture_groups(expression))
153}
154
155fn normalize_regex_whitespace(expression: &str) -> String {
156 let characters: Vec<_> = expression.chars().collect();
157 let mut output = String::with_capacity(expression.len());
158 let mut offset = 0;
159 let mut in_class = false;
160 while offset < characters.len() {
161 match characters[offset] {
162 '[' => {
163 in_class = true;
164 output.push('[');
165 offset += 1;
166 }
167 ']' => {
168 in_class = false;
169 output.push(']');
170 offset += 1;
171 }
172 '\\' if !in_class && characters.get(offset + 1) == Some(&' ') => {
173 output.push(' ');
174 offset += 2;
175 }
176 '\\' if !in_class
177 && characters.get(offset + 1) == Some(&'s')
178 && characters.get(offset + 2) == Some(&'+') =>
179 {
180 output.push(' ');
181 offset += 3;
182 }
183 '\\' => {
184 output.push('\\');
185 offset += 1;
186 if let Some(character) = characters.get(offset) {
187 output.push(*character);
188 offset += 1;
189 }
190 }
191 character => {
192 output.push(character);
193 offset += 1;
194 }
195 }
196 }
197 output
198}
199
200fn normalize_capture_groups(expression: &str) -> String {
201 let characters: Vec<_> = expression.chars().collect();
202 let mut output = String::new();
203 let mut offset = 0;
204 let mut in_class = false;
205 while offset < characters.len() {
206 match characters[offset] {
207 '\\' => {
208 output.push('\\');
209 offset += 1;
210 if let Some(character) = characters.get(offset) {
211 output.push(*character);
212 offset += 1;
213 }
214 }
215 '[' => {
216 in_class = true;
217 output.push('[');
218 offset += 1;
219 }
220 ']' => {
221 in_class = false;
222 output.push(']');
223 offset += 1;
224 }
225 '(' if !in_class => {
226 output.push('(');
227 offset += 1;
228 if characters.get(offset) == Some(&'?') && characters.get(offset + 1) == Some(&'<')
232 {
233 let name_start = offset + 2;
234 if characters
235 .get(name_start)
236 .is_some_and(|character| character.is_alphabetic() || *character == '_')
237 {
238 if let Some(end) = characters[name_start..]
239 .iter()
240 .position(|character| *character == '>')
241 {
242 offset = name_start + end + 1;
243 }
244 }
245 }
246 }
247 character => {
248 output.push(character);
249 offset += 1;
250 }
251 }
252 }
253 output
254}