nodejs/regexp.rs
1//! JavaScript `RegExp` on top of the [`fancy_regex`] crate.
2//!
3//! `fancy-regex` wraps the linear Rust `regex` engine and layers a backtracking
4//! matcher on top, so it can express the JS constructs plain `regex` cannot:
5//! lookahead (`(?=)`/`(?!)`), lookbehind (`(?<=)`/`(?<!)`), and backreferences
6//! (`\1`, `\k<name>`). node-js therefore accepts a near-superset of the JS regex
7//! grammar; the small residue fancy-regex still cannot represent is documented
8//! in BUGS.md and rejected loudly at construction time (never a silently-wrong
9//! match).
10//!
11//! What `translate` still has to do (fancy-regex/regex differ from JS here):
12//! * `\uXXXX` / `\u{...}` → `\x{...}` (regex spells fixed code points that
13//! way), with lone-surrogate escapes (`\uD800`..`\uDFFF`) mapped into a
14//! Plane-15 private-use block — surrogate code points are not valid Unicode
15//! scalar values, so `\x{D800}` will not compile; a valid UTF-8 `&str` can
16//! never contain a lone surrogate anyway, so those alternatives stay dead
17//! (correct for all valid input, e.g. encodeurl's unmatched-surrogate scan).
18//! * `\/` in a literal → a plain `/` (regex rejects the redundant escape).
19//! * `\N` / `\k<name>` → a conditional, so a reference to an unset group
20//! matches empty as in JS; the Annex B legacy escapes (`\0`, octal, `\cX`,
21//! identity `\8`/`\k`) become fixed code points; non-JS group syntax
22//! (`(?i)`, `(?P<n>`, `(?>`) is rejected with node's reason.
23//!
24//! Everything else — including `(?<name>...)`, `(?=)`/`(?!)`, `(?<=)`/`(?<!)`
25//! and the `(?ims-ims:...)` modifier groups — passes through verbatim.
26//!
27//! Flags: `i`/`m`/`s` map onto inline flags; `g`/`y` drive iteration and
28//! `lastIndex` here (fancy-regex has no global flag); `u`/`d` are accepted.
29
30use crate::host::{self, with_host, JsObj, RegExpObj};
31use crate::utf16::{self, U16Index};
32use fancy_regex::{Captures, Regex};
33use fusevm::Value;
34use indexmap::IndexMap;
35use rustc_hash::FxHashMap;
36use std::cell::RefCell;
37use std::rc::Rc;
38
39/// Lone-surrogate code points are not valid Unicode scalar values, so they can
40/// never appear in a Rust `&str` and `regex` refuses to compile `\x{D800}`. Map
41/// the 2048-code-point surrogate block bijectively into Plane-15 PUA-B, which is
42/// valid, contiguous (so class ranges stay ranges), and never occurs in normal
43/// text — the surrogate alternatives thus compile and stay inert on valid input.
44const SURROGATE_LO: u32 = 0xD800;
45const SURROGATE_HI: u32 = 0xDFFF;
46const SURROGATE_PUA_BASE: u32 = 0xF_0000;
47
48/// Remap a surrogate code point into the inert PUA block; pass others through.
49fn remap_surrogate(cp: u32) -> u32 {
50 if (SURROGATE_LO..=SURROGATE_HI).contains(&cp) {
51 SURROGATE_PUA_BASE + (cp - SURROGATE_LO)
52 } else {
53 cp
54 }
55}
56
57/// Build a `RegExp` value from a JS `pattern` + `flags`, or a `SyntaxError` if the
58/// pattern uses an unsupported construct or is otherwise invalid.
59/// 22.2.6.13.1 EscapeRegExpPattern — the text `source` reports, escaped so that
60/// `/` + source + `/` is a parseable regular-expression literal that matches the
61/// same thing.
62///
63/// Two characters need it and neither was handled: an unescaped `/` ended the
64/// literal early, so `String(new RegExp('/'))` produced `///`, and a literal
65/// line terminator cannot appear in a literal at all, so `new RegExp('\n')`
66/// reported a raw newline where node reports the two characters `\n`.
67///
68/// A `/` inside a character class does NOT need escaping and node does not add
69/// one (`new RegExp('[/]').source` is `[/]`), so the scan tracks class depth.
70/// An already-escaped `\/` is left alone rather than doubled.
71fn escape_regexp_pattern(pattern: &str) -> String {
72 if pattern.is_empty() {
73 return "(?:)".to_string();
74 }
75 let mut out = String::with_capacity(pattern.len());
76 let mut in_class = false;
77 let mut chars = pattern.chars();
78 while let Some(c) = chars.next() {
79 match c {
80 // A backslash escapes whatever follows; both travel through as-is.
81 '\\' => {
82 out.push(c);
83 if let Some(next) = chars.next() {
84 out.push(next);
85 }
86 }
87 '[' if !in_class => {
88 in_class = true;
89 out.push(c);
90 }
91 ']' if in_class => {
92 in_class = false;
93 out.push(c);
94 }
95 '/' if !in_class => out.push_str("\\/"),
96 '\n' => out.push_str("\\n"),
97 '\r' => out.push_str("\\r"),
98 '\u{2028}' => out.push_str("\\u2028"),
99 '\u{2029}' => out.push_str("\\u2029"),
100 _ => out.push(c),
101 }
102 }
103 out
104}
105
106pub fn build_regexp(pattern: &str, flags: &str) -> Result<Value, String> {
107 // Validate flags (Node throws on an unknown/repeated flag).
108 let mut seen = String::new();
109 for c in flags.chars() {
110 if !"gimsuyd".contains(c) || seen.contains(c) {
111 return Err(format!(
112 "SyntaxError: Invalid flags supplied to RegExp constructor '{flags}'"
113 ));
114 }
115 seen.push(c);
116 }
117 let global = flags.contains('g');
118 let ignore_case = flags.contains('i');
119 let multiline = flags.contains('m');
120 let dot_all = flags.contains('s');
121 let sticky = flags.contains('y');
122 let unicode = flags.contains('u');
123
124 let invalid = |reason: &str| {
125 format!("SyntaxError: Invalid regular expression: /{pattern}/{flags}: {reason}")
126 };
127 let rust_pat = translate(pattern, unicode).map_err(|r| invalid(&r))?;
128 // Assemble the inline-flag prefix fancy-regex (via the regex layer) understands.
129 let mut prefixed = String::new();
130 if ignore_case || multiline || dot_all {
131 prefixed.push_str("(?");
132 if ignore_case {
133 prefixed.push('i');
134 }
135 if multiline {
136 prefixed.push('m');
137 }
138 if dot_all {
139 prefixed.push('s');
140 }
141 prefixed.push(')');
142 }
143 prefixed.push_str(&rust_pat);
144
145 let re = compiled(&prefixed).map_err(|e| {
146 // Collapse the multi-line error to one line for a JS-shaped message.
147 invalid(&e.lines().collect::<Vec<_>>().join(" "))
148 })?;
149
150 // A `RegExpObj` is unavoidably fresh per evaluation (`lastIndex` is
151 // per-object mutable state), but the engine inside it is not.
152 let obj = RegExpObj {
153 re,
154 source: escape_regexp_pattern(pattern),
155 flags: flags.to_string(),
156 global,
157 ignore_case,
158 multiline,
159 dot_all,
160 sticky,
161 unicode,
162 last_index: U16Index::ZERO,
163 };
164 Ok(with_host(|h| h.alloc(JsObj::RegExp(Box::new(obj)))))
165}
166
167/// The compiled engine for an already-translated, flag-prefixed pattern,
168/// compiling it at most once per process.
169///
170/// A JS regex LITERAL is re-evaluated every time control reaches it, and each
171/// evaluation must produce a fresh `RegExp` object (`lastIndex` is per-object
172/// mutable state). Building the ENGINE each time as well is what made module
173/// loading slow: `require("express")` compiled 1,782 regexes drawn from only 59
174/// distinct patterns, and `fancy_regex::Regex::new` — not matching — accounted
175/// for 85% of the wall time. A single literal inside a hot function is the worst
176/// case: `mime-types`' `/(\.|x-).*/` cost ~1.5 ms per compile, so 2,582 loop
177/// iterations spent 4.2 s compiling one constant pattern. Hoisting that same
178/// regex out of the loop by hand took it to 27 ms, which is what identified
179/// compilation rather than matching as the cost.
180///
181/// Keyed on the translated + prefixed pattern, so two literals that differ only
182/// in spelling before translation still share one engine, and two that differ in
183/// flags do not. A compile FAILURE is not cached: it is a one-off cost on a path
184/// that immediately throws, and caching it would mean holding the error string
185/// for the life of the process.
186///
187/// Unbounded on purpose. The entries are the distinct regexes a program's source
188/// contains, which is a property of the code rather than of the input — the 59
189/// above is what a whole express dependency tree amounts to. A program that
190/// builds patterns from unbounded INPUT (`new RegExp(userString)`) is the case
191/// this would grow with, and it is also the case that gets no benefit; if that
192/// ever matters the fix is a capacity bound here, not a different design.
193fn compiled(prefixed: &str) -> Result<Rc<Regex>, String> {
194 thread_local! {
195 static CACHE: RefCell<FxHashMap<String, Rc<Regex>>> =
196 RefCell::new(FxHashMap::default());
197 }
198 if let Some(hit) = CACHE.with(|c| c.borrow().get(prefixed).cloned()) {
199 return Ok(hit);
200 }
201 let re = Rc::new(Regex::new(prefixed).map_err(|e| e.to_string())?);
202 CACHE.with(|c| {
203 c.borrow_mut().insert(prefixed.to_string(), re.clone());
204 });
205 Ok(re)
206}
207
208/// The capturing groups of a JS pattern, in source order: how many there are,
209/// and the index each named one got. A group is capturing when its `(` is not
210/// escaped, not inside a class, and is either bare or `(?<name>`
211/// (`(?<=`/`(?<!` are lookbehinds).
212///
213/// `translate` needs both BEFORE it reaches any escape: a decimal escape `\N` is
214/// a backreference only when `N` does not exceed the pattern's TOTAL group count
215/// (22.2.1.1 — a forward reference such as `/\1(a)/` still counts), and `\k` is
216/// a named reference only when the pattern has a named group at all.
217fn scan_groups(chars: &[char]) -> (usize, Vec<(String, usize)>) {
218 let mut count = 0usize;
219 let mut names = Vec::new();
220 let mut in_class = false;
221 let mut i = 0;
222 while i < chars.len() {
223 match chars[i] {
224 '\\' => i += 1,
225 '[' if !in_class => in_class = true,
226 ']' if in_class => in_class = false,
227 '(' if !in_class => {
228 if chars.get(i + 1) != Some(&'?') {
229 count += 1;
230 } else if chars.get(i + 2) == Some(&'<')
231 && !matches!(chars.get(i + 3), Some('=') | Some('!'))
232 {
233 count += 1;
234 let name: String = chars[i + 3..].iter().take_while(|c| **c != '>').collect();
235 names.push((name, count));
236 }
237 }
238 _ => {}
239 }
240 i += 1;
241 }
242 (count, names)
243}
244
245/// A code point as the fixed `\x{..}` spelling the regex layer accepts both in
246/// and out of a class.
247fn hex_escape(cp: u32) -> String {
248 format!("\\x{{{cp:X}}}")
249}
250
251/// Annex B.1.2 LegacyOctalEscapeSequence starting at `chars[i]` (an octal
252/// digit): the longest of `[0-3][0-7][0-7]`, `[0-7][0-7]`, `[0-7]`, so the value
253/// never exceeds 0o377. Returns the code point and how many digits it took.
254fn legacy_octal(chars: &[char], i: usize) -> (u32, usize) {
255 let oct = |k: usize| chars.get(k).and_then(|c| c.to_digit(8));
256 let first = oct(i).unwrap_or(0);
257 let max = if first <= 3 { 3 } else { 2 };
258 let mut value = first;
259 let mut len = 1;
260 while len < max {
261 match oct(i + len) {
262 Some(d) => {
263 value = value * 8 + d;
264 len += 1;
265 }
266 None => break,
267 }
268 }
269 (value, len)
270}
271
272/// Validate the group opener at `chars[i] == '('` when it is followed by `?`,
273/// returning the reason node's parser gives for a form JS does not have.
274///
275/// JS accepts exactly `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>`, and the
276/// ES2025 modifier groups `(?ims-ims:` — never a bare inline flag (`(?i)`), and
277/// none of Perl/PCRE's `(?x)`, `(?P<n>`, `(?#…)`, `(?>…)`, all of which the
278/// regex layer would otherwise accept and silently give a meaning.
279fn check_group(chars: &[char], i: usize) -> Result<(), &'static str> {
280 match chars.get(i + 2) {
281 Some(':') | Some('=') | Some('!') | Some('<') => return Ok(()),
282 _ => {}
283 }
284 let mut seen = String::new();
285 let mut k = i + 2;
286 let mut saw_dash = false;
287 while let Some(&c) = chars.get(k) {
288 match c {
289 'i' | 'm' | 's' => {
290 if seen.contains(c) {
291 return Err("Repeated flag in flag group");
292 }
293 seen.push(c);
294 }
295 '-' if !saw_dash => saw_dash = true,
296 ':' if seen.is_empty() => return Err("Invalid flag group"),
297 ':' => return Ok(()),
298 _ => return Err("Invalid group"),
299 }
300 k += 1;
301 }
302 Err("Invalid group")
303}
304
305/// Translate a JS regex source into fancy-regex syntax, or the reason node's
306/// parser would reject it (the caller adds the `Invalid regular expression:
307/// /…/flags: ` frame).
308///
309/// Rewrites, each because the regex layer reads the same spelling differently:
310/// * `\uXXXX` / `\u{…}` → `\x{…}`, surrogates remapped (see `remap_surrogate`).
311/// * `\/` → `/`; a bare `[` inside a class → `\[`.
312/// * `\N` that names an existing group → `(?(N)\N|)`. JS matches a reference
313/// to a group that has not participated as the EMPTY string (22.2.2.7.2
314/// BackreferenceMatcher step 7), so `/\1(a)/` and `/(a)?b\1/` both match;
315/// fancy-regex fails such a reference instead, and its conditional is what
316/// restores "empty when unset". `\k<name>` gets the same treatment by index.
317/// * Outside unicode mode, the Annex B.1.2 legacy forms: `\N` past the group
318/// count is an octal escape (`\052` is `*`), or the digit itself for 8/9;
319/// `\0` is NUL; in a class every `\N` is octal; `\cX` is the control
320/// character X % 32 (plus `\c<digit>`/`\c_` in a class), and a `\c` that
321/// forms none of those is a literal backslash followed by `c`; `\k` with no
322/// named group in the pattern is the letter `k`.
323/// * In unicode mode those legacy forms are the SyntaxErrors node raises.
324fn translate(pat: &str, unicode: bool) -> Result<String, String> {
325 let chars: Vec<char> = pat.chars().collect();
326 let (group_count, group_names) = scan_groups(&chars);
327 let mut out = String::new();
328 let mut i = 0;
329 // Track whether we're inside a `[...]` class. `class_pos` is how many chars
330 // into the current class we are, so we can spot the `]` that would close an
331 // empty class (`[]` / `[^]`) vs. a literal leading `]`.
332 let mut in_class = false;
333 let mut class_pos = 0usize;
334 while i < chars.len() {
335 let c = chars[i];
336 // Character-class bookkeeping. A `\` escape is handled below and never
337 // toggles class state (it consumes its own two chars).
338 if c != '\\' {
339 if !in_class && c == '[' {
340 in_class = true;
341 class_pos = 0;
342 out.push('[');
343 i += 1;
344 // A leading `^` is the negation, not the first member.
345 if chars.get(i) == Some(&'^') {
346 out.push('^');
347 i += 1;
348 }
349 continue;
350 }
351 if in_class {
352 // The first char of a class, if `]`, is a literal `]` in JS; a
353 // later bare `[` must be escaped for the regex layer.
354 if c == ']' && class_pos > 0 {
355 in_class = false;
356 out.push(']');
357 i += 1;
358 continue;
359 }
360 if c == '[' {
361 out.push_str("\\[");
362 class_pos += 1;
363 i += 1;
364 continue;
365 }
366 } else if c == '(' && chars.get(i + 1) == Some(&'?') {
367 check_group(&chars, i).map_err(str::to_string)?;
368 }
369 }
370 match c {
371 '\\' => {
372 class_pos += 1;
373 match chars.get(i + 1).copied() {
374 // `\uXXXX` / `\u{...}` → `\x{...}` (surrogates remapped).
375 Some('u') => {
376 i += 2;
377 let cp_hex: String;
378 if chars.get(i) == Some(&'{') {
379 i += 1;
380 let mut hex = String::new();
381 while i < chars.len() && chars[i] != '}' {
382 hex.push(chars[i]);
383 i += 1;
384 }
385 i += 1; // consume '}'
386 cp_hex = hex;
387 } else {
388 // Exactly four hex digits.
389 cp_hex = chars[i..(i + 4).min(chars.len())].iter().collect();
390 i += 4;
391 }
392 match u32::from_str_radix(cp_hex.trim(), 16) {
393 Ok(cp) => out.push_str(&hex_escape(remap_surrogate(cp))),
394 // Not valid hex — emit the code point literally so the
395 // engine surfaces its own error rather than us guessing.
396 Err(_) => out.push_str(&format!("\\x{{{cp_hex}}}")),
397 }
398 continue;
399 }
400 // `\/` in a JS literal → a plain slash (regex rejects `\/`).
401 Some('/') => {
402 out.push('/');
403 i += 2;
404 continue;
405 }
406 Some('c') => {
407 let control = match chars.get(i + 2) {
408 Some(x) if x.is_ascii_alphabetic() => Some(*x),
409 Some(x)
410 if in_class && !unicode && (x.is_ascii_digit() || *x == '_') =>
411 {
412 Some(*x)
413 }
414 _ => None,
415 };
416 match control {
417 Some(x) => {
418 out.push_str(&hex_escape(x as u32 % 32));
419 i += 3;
420 }
421 None if unicode => return Err("Invalid Unicode escape".into()),
422 // Annex B: `\` stands for itself; `c` is read next.
423 None => {
424 out.push_str("\\\\");
425 i += 1;
426 }
427 }
428 continue;
429 }
430 Some(d) if d.is_ascii_digit() => {
431 let lone_zero =
432 d == '0' && !chars.get(i + 2).is_some_and(|n| n.is_ascii_digit());
433 if lone_zero {
434 out.push_str(&hex_escape(0));
435 i += 2;
436 continue;
437 }
438 if !in_class && d != '0' {
439 let digits: String = chars[i + 1..]
440 .iter()
441 .take_while(|c| c.is_ascii_digit())
442 .collect();
443 let n = digits.parse::<usize>().unwrap_or(usize::MAX);
444 if n <= group_count {
445 out.push_str(&format!("(?({n})\\{n}|)"));
446 i += 1 + digits.len();
447 continue;
448 }
449 }
450 if unicode {
451 let reason = if in_class || d == '0' {
452 "Invalid decimal escape"
453 } else {
454 "Invalid escape"
455 };
456 return Err(reason.into());
457 }
458 if d == '8' || d == '9' {
459 out.push(d);
460 i += 2;
461 } else {
462 let (cp, len) = legacy_octal(&chars, i + 1);
463 out.push_str(&hex_escape(cp));
464 i += 1 + len;
465 }
466 continue;
467 }
468 Some('k') if in_class || (group_names.is_empty() && !unicode) => {
469 if unicode {
470 return Err("Invalid class escape".into());
471 }
472 out.push('k');
473 i += 2;
474 continue;
475 }
476 Some('k') => {
477 if chars.get(i + 2) != Some(&'<') {
478 return Err("Invalid named reference".into());
479 }
480 let Some(close) = chars[i + 3..].iter().position(|c| *c == '>') else {
481 return Err("Invalid capture group name".into());
482 };
483 let name: String = chars[i + 3..i + 3 + close].iter().collect();
484 let Some((_, index)) = group_names.iter().find(|(n, _)| *n == name) else {
485 return Err("Invalid named capture referenced".into());
486 };
487 out.push_str(&format!("(?({index})\\{index}|)"));
488 i += 4 + close;
489 continue;
490 }
491 // Everything else (`\d \w \s \b \n \. \\` …) passes through.
492 Some(other) => {
493 out.push('\\');
494 out.push(other);
495 i += 2;
496 continue;
497 }
498 None => {
499 out.push('\\');
500 i += 1;
501 }
502 }
503 }
504 _ => {
505 if in_class {
506 class_pos += 1;
507 }
508 out.push(c);
509 i += 1;
510 }
511 }
512 }
513 Ok(out)
514}
515
516/// The flags string in the spec's canonical order (22.2.6.4 reads the six
517/// reflectors in a fixed sequence), independent of how the literal spelled
518/// them.
519fn canonical_flags(flags: &str) -> String {
520 "dgimsuvy"
521 .chars()
522 .filter(|c| flags.contains(*c))
523 .collect::<String>()
524}
525
526/// A `RegExp` own data property (`source`/`flags`/`global`/…/`lastIndex`), or
527/// `None` if `name` is not one (so the caller tries methods).
528pub fn regexp_property(r: &RegExpObj, name: &str) -> Option<Value> {
529 Some(match name {
530 "source" => with_host(|h| h.new_str(r.source.clone())),
531 // `RegExp.prototype.flags` (22.2.6.4) is a GETTER that rebuilds the
532 // string in the spec's fixed `dgimsuvy` order, not the spelling the
533 // literal used: `/a/gid.flags` is `"dgi"` in node and was `"gid"` here,
534 // so any code keyed on the flags string (a cache key, a `new
535 // RegExp(src, flags)` round-trip comparison) disagreed.
536 "flags" => with_host(|h| h.new_str(canonical_flags(&r.flags))),
537 "global" => Value::Bool(r.global),
538 "ignoreCase" => Value::Bool(r.ignore_case),
539 "multiline" => Value::Bool(r.multiline),
540 "dotAll" => Value::Bool(r.dot_all),
541 "sticky" => Value::Bool(r.sticky),
542 "unicode" => Value::Bool(r.unicode),
543 // `d` is accepted and its match-indices output ignored (BUGS.md), but
544 // the flag reflector still has to report it; it read `undefined` where
545 // node says `true`/`false`. `v` is rejected at construction time, so
546 // `unicodeSets` is `false` for every regex that exists here.
547 "hasIndices" => Value::Bool(r.flags.contains('d')),
548 "unicodeSets" => Value::Bool(r.flags.contains('v')),
549 "lastIndex" => Value::Float(r.last_index.get() as f64),
550 _ => return None,
551 })
552}
553
554pub fn is_regexp_method(name: &str) -> bool {
555 matches!(name, "test" | "exec" | "toString" | "compile")
556 // The five symbol-keyed methods a RegExp exposes so the string methods
557 // can delegate to it (22.2.6.x). They were absent, so
558 // `/a/[Symbol.match]("x")` was not a function and a subclass could not
559 // override the protocol by calling `super[Symbol.match]`.
560 || matches!(
561 name,
562 "@@match" | "@@matchAll" | "@@search" | "@@split" | "@@replace"
563 )
564}
565
566/// Dispatch a `RegExp.prototype` method.
567pub fn regexp_method(recv: &Value, name: &str, args: Vec<Value>) -> Result<Value, String> {
568 match name {
569 "test" => {
570 let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
571 Ok(Value::Bool(regexp_test(recv, &s)))
572 }
573 "exec" => {
574 let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
575 regexp_exec(recv, &s)
576 }
577 "toString" => Ok(with_host(|h| {
578 let s = h.str_of(recv);
579 h.new_str(s)
580 })),
581 // `compile` is a legacy no-op here (the pattern is already compiled).
582 "compile" => Ok(recv.clone()),
583 // The symbol-keyed forms ARE the string methods' implementations, so
584 // each forwards to the same routine with the arguments swapped: the
585 // subject is the argument here and the receiver there.
586 "@@match" | "@@matchAll" | "@@search" | "@@split" | "@@replace" => {
587 let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
588 match name {
589 "@@match" => str_match(&s, recv),
590 "@@matchAll" => str_match_all(&s, recv),
591 "@@search" => str_search(&s, recv),
592 "@@split" => {
593 let limit = args.get(1).and_then(|v| {
594 let n = with_host(|h| h.to_number(v));
595 n.is_finite().then_some(n.max(0.0) as usize)
596 });
597 str_split_regex(&s, recv, limit)
598 }
599 _ => str_replace_regex(
600 &s,
601 recv,
602 &args.get(1).cloned().unwrap_or(Value::Undef),
603 false,
604 ),
605 }
606 }
607 _ => Err(host::type_error(&format!("{name} is not a function"))),
608 }
609}
610
611/// Snapshot the fields we need without holding the host borrow across a match.
612fn regexp_snapshot(recv: &Value) -> Option<(Rc<Regex>, bool, bool, U16Index)> {
613 with_host(|h| match h.get(recv) {
614 Some(JsObj::RegExp(r)) => Some((r.re.clone(), r.global, r.sticky, r.last_index)),
615 _ => None,
616 })
617}
618
619/// Whether the regexp carries the `d` flag, so its matches report `.indices`.
620fn has_indices(recv: &Value) -> bool {
621 with_host(|h| match h.get(recv) {
622 Some(JsObj::RegExp(r)) => r.flags.contains('d'),
623 _ => false,
624 })
625}
626
627fn set_last_index(recv: &Value, idx: U16Index) {
628 with_host(|h| {
629 if let Some(JsObj::RegExp(r)) = h.get_mut(recv) {
630 r.last_index = idx;
631 }
632 });
633}
634
635/// Byte offset of a UTF-16 index (clamped to the string length).
636///
637/// `lastIndex` and `.index` are UTF-16 code-unit offsets in JS, while the regex
638/// engine works in UTF-8 byte offsets. Both are `usize`-shaped, so the newtype
639/// is what stops one being passed where the other belongs.
640fn byte_of_index(s: &str, n: U16Index) -> usize {
641 utf16::byte_of_index(s, n)
642}
643/// UTF-16 index of a byte offset.
644fn index_of_byte(s: &str, byte: usize) -> U16Index {
645 utf16::index_of_byte(s, byte)
646}
647
648/// `re.test(s)` — honoring `g`/`y` `lastIndex` advancement, exactly like `exec`.
649pub fn regexp_test(recv: &Value, s: &str) -> bool {
650 let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
651 return false;
652 };
653 let start_idx = if global || sticky {
654 last
655 } else {
656 U16Index::ZERO
657 };
658 if start_idx.get() > utf16::len(s) {
659 if global || sticky {
660 set_last_index(recv, U16Index::ZERO);
661 }
662 return false;
663 }
664 let start_byte = byte_of_index(s, start_idx);
665 // A backtracking match can fail (catastrophic backtracking guard); treat an
666 // engine error as "no match" so a pathological pattern never panics the VM.
667 match re.find_from_pos(s, start_byte) {
668 Ok(Some(m)) if !sticky || m.start() == start_byte => {
669 if global || sticky {
670 set_last_index(recv, index_of_byte(s, m.end()));
671 }
672 true
673 }
674 _ => {
675 if global || sticky {
676 set_last_index(recv, U16Index::ZERO);
677 }
678 false
679 }
680 }
681}
682
683/// `re.exec(s)` — returns a match array (`[full, ...captures]` with `.index`,
684/// `.input`, `.groups`), or `null`. Advances `lastIndex` under `g`/`y`.
685pub fn regexp_exec(recv: &Value, s: &str) -> Result<Value, String> {
686 let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
687 return Ok(with_host(|h| h.null()));
688 };
689 let start_idx = if global || sticky {
690 last
691 } else {
692 U16Index::ZERO
693 };
694 if start_idx.get() > utf16::len(s) {
695 if global || sticky {
696 set_last_index(recv, U16Index::ZERO);
697 }
698 return Ok(with_host(|h| h.null()));
699 }
700 let start_byte = byte_of_index(s, start_idx);
701 let caps = re.captures_from_pos(s, start_byte).ok().flatten();
702 let caps = match caps {
703 Some(c) if !sticky || c.get(0).map(|m| m.start()) == Some(start_byte) => c,
704 _ => {
705 if global || sticky {
706 set_last_index(recv, U16Index::ZERO);
707 }
708 return Ok(with_host(|h| h.null()));
709 }
710 };
711 let whole = caps.get(0).unwrap();
712 if global || sticky {
713 set_last_index(recv, index_of_byte(s, whole.end()));
714 }
715 Ok(build_match_array(&re, &caps, s, has_indices(recv)))
716}
717
718/// Build the JS match-result array from a `Captures`, attaching `.index`,
719/// `.input`, and (named-group) `.groups` — plus `.indices` when the regexp
720/// carried the `d` flag (22.2.7.2 step 34, `MakeMatchIndicesIndexPairArray`).
721fn build_match_array(re: &Regex, caps: &Captures, s: &str, indices: bool) -> Value {
722 let mut items: Vec<Value> = Vec::with_capacity(caps.len());
723 for i in 0..caps.len() {
724 items.push(match caps.get(i) {
725 Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
726 None => Value::Undef, // a non-participating optional group
727 });
728 }
729 let whole = caps.get(0).unwrap();
730 let arr = with_host(|h| h.new_array(items));
731 let index = index_of_byte(s, whole.start()).get();
732 with_host(|h| {
733 let idx = Value::Float(index as f64);
734 h.set_fn_prop(&arr, "index", idx);
735 let input = h.new_str(s.to_string());
736 h.set_fn_prop(&arr, "input", input);
737 });
738 // Named groups → a `.groups` object (or `undefined` if the regex has none).
739 let names: Vec<&str> = re.capture_names().flatten().collect();
740 if indices {
741 attach_indices(caps, s, &arr, &names);
742 }
743 if names.is_empty() {
744 with_host(|h| h.set_fn_prop(&arr, "groups", Value::Undef));
745 } else {
746 let mut g: IndexMap<String, Value> = IndexMap::new();
747 for name in names {
748 let v = match caps.name(name) {
749 Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
750 None => Value::Undef,
751 };
752 g.insert(name.to_string(), v);
753 }
754 with_host(|h| {
755 let obj = h.new_object(g);
756 // `groups` is an `OrdinaryObjectCreate(null)` (22.2.7.2 step 30), so
757 // it inherits nothing and inspects as `[Object: null prototype]`.
758 let null = h.null();
759 h.set_proto(&obj, null);
760 h.set_fn_prop(&arr, "groups", obj);
761 });
762 }
763 arr
764}
765
766/// `MakeMatchIndicesIndexPairArray` (22.2.7.8): a `[start, end]` pair per
767/// capture — `null` for one that did not participate — with a null-prototype
768/// `.groups` mirroring the named groups. Offsets are UTF-16 indices, the same
769/// units `.index` uses.
770fn attach_indices(caps: &Captures, s: &str, arr: &Value, names: &[&str]) {
771 let pair = |m: Option<fancy_regex::Match>| match m {
772 Some(m) => {
773 let (a, b) = (index_of_byte(s, m.start()), index_of_byte(s, m.end()));
774 with_host(|h| {
775 h.new_array(vec![
776 Value::Float(a.get() as f64),
777 Value::Float(b.get() as f64),
778 ])
779 })
780 }
781 None => Value::Undef,
782 };
783 let mut pairs: Vec<Value> = Vec::with_capacity(caps.len());
784 for i in 0..caps.len() {
785 pairs.push(pair(caps.get(i)));
786 }
787 let idx_arr = with_host(|h| h.new_array(pairs));
788 if names.is_empty() {
789 with_host(|h| h.set_fn_prop(&idx_arr, "groups", Value::Undef));
790 } else {
791 let mut g: IndexMap<String, Value> = IndexMap::new();
792 for name in names {
793 g.insert((*name).to_string(), pair(caps.name(name)));
794 }
795 with_host(|h| {
796 let obj = h.new_object(g);
797 let null = h.null();
798 h.set_proto(&obj, null);
799 h.set_fn_prop(&idx_arr, "groups", obj);
800 });
801 }
802 with_host(|h| h.set_fn_prop(arr, "indices", idx_arr));
803}
804
805// ── String.prototype regex methods (called from builtins::string_method) ──────
806
807/// `str.match(re)`: without `g`, same as `exec` (array or null); with `g`, an
808/// array of every whole-match string (or null if none).
809pub fn str_match(s: &str, re_val: &Value) -> Result<Value, String> {
810 let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
811 return Ok(with_host(|h| h.null()));
812 };
813 if !global {
814 // Non-global match ignores lastIndex and searches from the start.
815 set_last_index(re_val, U16Index::ZERO);
816 return regexp_exec_from_zero(&re, s, has_indices(re_val));
817 }
818 // 22.2.6.9 step 6.a: a global match sets `lastIndex` to 0 before it starts,
819 // so it always collects from the beginning and leaves it there. It was
820 // being left wherever the caller had put it.
821 set_last_index(re_val, U16Index::ZERO);
822 let matches: Vec<Value> = re
823 .find_iter(s)
824 .filter_map(|m| m.ok())
825 .map(|m| with_host(|h| h.new_str(m.as_str().to_string())))
826 .collect();
827 if matches.is_empty() {
828 Ok(with_host(|h| h.null()))
829 } else {
830 Ok(with_host(|h| h.new_array(matches)))
831 }
832}
833
834/// Non-global exec searching from offset 0 (for `str.match` without `g`).
835fn regexp_exec_from_zero(re: &Regex, s: &str, indices: bool) -> Result<Value, String> {
836 match re.captures(s).ok().flatten() {
837 Some(caps) => Ok(build_match_array(re, &caps, s, indices)),
838 None => Ok(with_host(|h| h.null())),
839 }
840}
841
842/// `str.matchAll(re)`: an iterator over every match array (requires the `g` flag
843/// in Node, but we accept a non-global regex too and still iterate all matches).
844pub fn str_match_all(s: &str, re_val: &Value) -> Result<Value, String> {
845 let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
846 return Ok(with_host(|h| h.new_array(Vec::new())));
847 };
848 let indices = has_indices(re_val);
849 // 22.1.3.14 step 5.c: a non-global regexp is a TypeError, because
850 // `matchAll` cannot produce every match without `g` — the same rule
851 // `replaceAll` enforces. This used to return just the first match.
852 if !global {
853 return Err(host::type_error(
854 "String.prototype.matchAll called with a non-global RegExp argument",
855 ));
856 }
857 let mut items = Vec::new();
858 for caps in re.captures_iter(s).flatten() {
859 items.push(build_match_array(&re, &caps, s, indices));
860 }
861 // Return a live iterator so `for-of`/spread/`Array.from` all work.
862 Ok(with_host(|h| {
863 h.alloc(JsObj::Iter {
864 items,
865 idx: 0,
866 array: None,
867 })
868 }))
869}
870
871/// `str.search(re)`: char index of the first match, or -1.
872pub fn str_search(s: &str, re_val: &Value) -> Result<Value, String> {
873 let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
874 return Ok(Value::Float(-1.0));
875 };
876 Ok(match re.find(s).ok().flatten() {
877 Some(m) => Value::Float(index_of_byte(s, m.start()).get() as f64),
878 None => Value::Float(-1.0),
879 })
880}
881
882/// `str.split(re[, limit])`: split on regex matches; captured groups are spliced
883/// into the output (JS semantics).
884/// `String.prototype.split(regexp[, limit])` — 22.2.6.14 `RegExp.prototype
885/// [@@split]`.
886///
887/// The previous shape of this was a `captures_iter` with one hand-rolled
888/// special case for a zero-width match at position 0, and it got every other
889/// empty-match position wrong: `'ab'.split(/(?:)/)` grew a trailing `""`,
890/// `''.split(/(?:)/)` answered `[""]` instead of `[]`, and `'ab'.split(/()/)`
891/// answered five elements instead of three.
892///
893/// The spec loop is what gets those right, and the single rule doing the work
894/// is `e == p`: a match ENDING where the previous piece began contributes
895/// nothing and only advances the scan. The final piece is always the tail from
896/// `p`, appended after the loop — which is why an empty match at the very end
897/// does not produce an extra `""` (the loop stops at `q == size` before ever
898/// matching there) while a real separator at the end does.
899///
900/// The scan is in UTF-16 code units, since that is what the spec indexes and
901/// what `.index`/`lastIndex` report elsewhere in this file. Where the spec
902/// advances `q` one position at a time until a match occurs AT `q`, this jumps
903/// straight to the next match at or after `q`: every position skipped is one
904/// the spec would have failed to match, so the two agree.
905///
906/// One divergence remains and is not fixable here: a lone surrogate cannot be
907/// represented in a Rust `String`, so a split position INSIDE an astral
908/// character does not exist to be split at. `'\u{1F600}a'.split(/(?:)/)` is
909/// `['\u{1F600}', 'a']` here and three elements in node, which splits the
910/// surrogate pair. `'\u{1F600}'.split('')` has always had the same limit; it
911/// is the string representation, not this algorithm.
912pub fn str_split_regex(s: &str, re_val: &Value, limit: Option<usize>) -> Result<Value, String> {
913 let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
914 return Ok(with_host(|h| h.new_array(Vec::new())));
915 };
916 let lim = limit.unwrap_or(usize::MAX);
917 if lim == 0 {
918 return Ok(with_host(|h| h.new_array(Vec::new())));
919 }
920 let size = utf16::len(s);
921 let units = |i: usize| byte_of_index(s, U16Index::new(i));
922
923 // An empty subject splits to nothing when the separator can match it, and
924 // to `[""]`... which is the subject itself, when it cannot.
925 if size == 0 {
926 let out = if matches!(re.find(s), Ok(Some(_))) {
927 Vec::new()
928 } else {
929 vec![with_host(|h| h.new_str(String::new()))]
930 };
931 return Ok(with_host(|h| h.new_array(out)));
932 }
933
934 let mut out: Vec<Value> = Vec::new();
935 let mut p = 0usize; // start of the piece being accumulated
936 let mut q = 0usize; // scan position
937 while q < size {
938 let Some(caps) = re.captures_from_pos(s, units(q)).ok().flatten() else {
939 break;
940 };
941 let m = caps.get(0).expect("group 0 always participates");
942 let m_start = index_of_byte(s, m.start()).get();
943 // The spec scans `q` only while `q < size` and requires the match to be
944 // AT `q`, so a match starting at the very end is never reached. Jumping
945 // to the next match does reach it, and letting it through appended a
946 // spurious trailing `""` for every end-anchored zero-width separator —
947 // `/$/`, `/\b/`, a trailing lookbehind.
948 if m_start >= size {
949 break;
950 }
951 let e = index_of_byte(s, m.end()).get().min(size);
952 if e == p {
953 // Contributes no piece; step past this position and rescan.
954 //
955 // The step is off `q`, not off `m_start`, because the two can move
956 // backwards relative to each other: a unit index that falls INSIDE
957 // an astral character has no byte offset of its own, so the search
958 // starts at the character's first byte and reports a match before
959 // `q`. Stepping off `m_start` there left `q` pinned and the loop
960 // spun forever on `'\u{1F600}a'.split(/(?:)/)`.
961 q = m_start.max(q) + 1;
962 continue;
963 }
964 out.push(with_host(|h| {
965 h.new_str(utf16::Units::of(s).slice(p, m_start))
966 }));
967 if out.len() >= lim {
968 return Ok(with_host(|h| h.new_array(out)));
969 }
970 for i in 1..caps.len() {
971 out.push(match caps.get(i) {
972 Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
973 None => Value::Undef,
974 });
975 if out.len() >= lim {
976 return Ok(with_host(|h| h.new_array(out)));
977 }
978 }
979 p = e;
980 q = p;
981 }
982 out.push(with_host(|h| h.new_str(utf16::Units::of(s).slice(p, size))));
983 out.truncate(lim);
984 Ok(with_host(|h| h.new_array(out)))
985}
986
987/// `str.replace(re, repl)` / `str.replaceAll(re, repl)`. `repl` is either a string
988/// (with `$1`/`$&`/`` $` ``/`$'`/`$<name>`/`$$` patterns) or a function replacer.
989pub fn str_replace_regex(
990 s: &str,
991 re_val: &Value,
992 repl: &Value,
993 all: bool,
994) -> Result<Value, String> {
995 let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
996 return Ok(with_host(|h| h.new_str(s.to_string())));
997 };
998 let replace_all = all || global;
999 let is_fn = with_host(|h| host::is_callable(h, repl));
1000
1001 let mut out = String::new();
1002 let mut last = 0usize;
1003 let mut count = 0;
1004 for caps in re.captures_iter(s).flatten() {
1005 let m = caps.get(0).unwrap();
1006 out.push_str(&s[last..m.start()]);
1007 if is_fn {
1008 // fn(match, p1, …, offset, whole_string)
1009 let mut call_args: Vec<Value> = Vec::new();
1010 for i in 0..caps.len() {
1011 call_args.push(match caps.get(i) {
1012 Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
1013 None => Value::Undef,
1014 });
1015 }
1016 call_args.push(Value::Float(index_of_byte(s, m.start()).get() as f64));
1017 call_args.push(with_host(|h| h.new_str(s.to_string())));
1018 // 22.1.3.19: when the pattern has named groups the callback takes a
1019 // final `groups` argument. Omitting it left `arguments.length` at 4
1020 // where node reports 5, and destructuring the groups out of the last
1021 // parameter saw the subject string.
1022 if let Some(groups) = named_groups_object(&re, &caps) {
1023 call_args.push(groups);
1024 }
1025 let r = host::invoke(repl, call_args, None)?;
1026 out.push_str(&with_host(|h| h.str_of(&r)));
1027 } else {
1028 let repl_str = with_host(|h| h.str_of(repl));
1029 out.push_str(&expand_replacement(&repl_str, &caps, s));
1030 }
1031 last = m.end();
1032 count += 1;
1033 if !replace_all && count >= 1 {
1034 break;
1035 }
1036 }
1037 out.push_str(&s[last..]);
1038 // 22.2.6.11 step 8: a GLOBAL regexp has its `lastIndex` set to 0 by the
1039 // replace, so the next use starts from the beginning. It was left wherever
1040 // the caller had put it, which made a shared `/…/g` skip the front of the
1041 // string on its next `test`/`exec`.
1042 if global {
1043 set_last_index(re_val, U16Index::ZERO);
1044 }
1045 Ok(with_host(|h| h.new_str(out)))
1046}
1047
1048/// Expand a replacement template's `$` patterns against a match.
1049/// The `groups` object for a match — `OrdinaryObjectCreate(null)` carrying each
1050/// named capture (22.2.7.2 step 30), or `None` when the pattern names none.
1051fn named_groups_object(re: &Regex, caps: &Captures) -> Option<Value> {
1052 let names: Vec<&str> = re.capture_names().flatten().collect();
1053 if names.is_empty() {
1054 return None;
1055 }
1056 let mut g: IndexMap<String, Value> = IndexMap::new();
1057 for name in names {
1058 let v = match caps.name(name) {
1059 Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
1060 None => Value::Undef,
1061 };
1062 g.insert(name.to_string(), v);
1063 }
1064 Some(with_host(|h| {
1065 let obj = h.new_object(g);
1066 let null = h.null();
1067 h.set_proto(&obj, null);
1068 obj
1069 }))
1070}
1071
1072fn expand_replacement(templ: &str, caps: &Captures, s: &str) -> String {
1073 let chars: Vec<char> = templ.chars().collect();
1074 let mut out = String::new();
1075 let mut i = 0;
1076 let whole = caps.get(0).unwrap();
1077 while i < chars.len() {
1078 if chars[i] == '$' && i + 1 < chars.len() {
1079 let n = chars[i + 1];
1080 match n {
1081 '$' => {
1082 out.push('$');
1083 i += 2;
1084 }
1085 '&' => {
1086 out.push_str(whole.as_str());
1087 i += 2;
1088 }
1089 '`' => {
1090 out.push_str(&s[..whole.start()]);
1091 i += 2;
1092 }
1093 '\'' => {
1094 out.push_str(&s[whole.end()..]);
1095 i += 2;
1096 }
1097 '<' => {
1098 // `$<name>` named-group reference.
1099 let mut j = i + 2;
1100 let mut name = String::new();
1101 while j < chars.len() && chars[j] != '>' {
1102 name.push(chars[j]);
1103 j += 1;
1104 }
1105 if let Some(m) = caps.name(&name) {
1106 out.push_str(m.as_str());
1107 }
1108 i = j + 1; // consume '>'
1109 }
1110 d if d.is_ascii_digit() => {
1111 // `$1`..`$99`: prefer a two-digit group if it exists.
1112 let d2 = chars.get(i + 2).copied().filter(|c| c.is_ascii_digit());
1113 let two = d2.and_then(|c2| format!("{d}{c2}").parse::<usize>().ok());
1114 if let Some(gi) = two.filter(|gi| *gi < caps.len()) {
1115 if let Some(g) = caps.get(gi) {
1116 out.push_str(g.as_str());
1117 }
1118 i += 3;
1119 } else {
1120 let gi = d.to_digit(10).unwrap() as usize;
1121 if gi >= 1 && gi < caps.len() {
1122 if let Some(g) = caps.get(gi) {
1123 out.push_str(g.as_str());
1124 }
1125 i += 2;
1126 } else {
1127 out.push('$');
1128 i += 1;
1129 }
1130 }
1131 }
1132 _ => {
1133 out.push('$');
1134 i += 1;
1135 }
1136 }
1137 } else {
1138 out.push(chars[i]);
1139 i += 1;
1140 }
1141 }
1142 out
1143}