Skip to main content

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}