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| h.alloc(JsObj::Iter { items, idx: 0 })))
863}
864
865/// `str.search(re)`: char index of the first match, or -1.
866pub fn str_search(s: &str, re_val: &Value) -> Result<Value, String> {
867    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
868        return Ok(Value::Float(-1.0));
869    };
870    Ok(match re.find(s).ok().flatten() {
871        Some(m) => Value::Float(index_of_byte(s, m.start()).get() as f64),
872        None => Value::Float(-1.0),
873    })
874}
875
876/// `str.split(re[, limit])`: split on regex matches; captured groups are spliced
877/// into the output (JS semantics).
878/// `String.prototype.split(regexp[, limit])` — 22.2.6.14 `RegExp.prototype
879/// [@@split]`.
880///
881/// The previous shape of this was a `captures_iter` with one hand-rolled
882/// special case for a zero-width match at position 0, and it got every other
883/// empty-match position wrong: `'ab'.split(/(?:)/)` grew a trailing `""`,
884/// `''.split(/(?:)/)` answered `[""]` instead of `[]`, and `'ab'.split(/()/)`
885/// answered five elements instead of three.
886///
887/// The spec loop is what gets those right, and the single rule doing the work
888/// is `e == p`: a match ENDING where the previous piece began contributes
889/// nothing and only advances the scan. The final piece is always the tail from
890/// `p`, appended after the loop — which is why an empty match at the very end
891/// does not produce an extra `""` (the loop stops at `q == size` before ever
892/// matching there) while a real separator at the end does.
893///
894/// The scan is in UTF-16 code units, since that is what the spec indexes and
895/// what `.index`/`lastIndex` report elsewhere in this file. Where the spec
896/// advances `q` one position at a time until a match occurs AT `q`, this jumps
897/// straight to the next match at or after `q`: every position skipped is one
898/// the spec would have failed to match, so the two agree.
899///
900/// One divergence remains and is not fixable here: a lone surrogate cannot be
901/// represented in a Rust `String`, so a split position INSIDE an astral
902/// character does not exist to be split at. `'\u{1F600}a'.split(/(?:)/)` is
903/// `['\u{1F600}', 'a']` here and three elements in node, which splits the
904/// surrogate pair. `'\u{1F600}'.split('')` has always had the same limit; it
905/// is the string representation, not this algorithm.
906pub fn str_split_regex(s: &str, re_val: &Value, limit: Option<usize>) -> Result<Value, String> {
907    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
908        return Ok(with_host(|h| h.new_array(Vec::new())));
909    };
910    let lim = limit.unwrap_or(usize::MAX);
911    if lim == 0 {
912        return Ok(with_host(|h| h.new_array(Vec::new())));
913    }
914    let size = utf16::len(s);
915    let units = |i: usize| byte_of_index(s, U16Index::new(i));
916
917    // An empty subject splits to nothing when the separator can match it, and
918    // to `[""]`... which is the subject itself, when it cannot.
919    if size == 0 {
920        let out = if matches!(re.find(s), Ok(Some(_))) {
921            Vec::new()
922        } else {
923            vec![with_host(|h| h.new_str(String::new()))]
924        };
925        return Ok(with_host(|h| h.new_array(out)));
926    }
927
928    let mut out: Vec<Value> = Vec::new();
929    let mut p = 0usize; // start of the piece being accumulated
930    let mut q = 0usize; // scan position
931    while q < size {
932        let Some(caps) = re.captures_from_pos(s, units(q)).ok().flatten() else {
933            break;
934        };
935        let m = caps.get(0).expect("group 0 always participates");
936        let m_start = index_of_byte(s, m.start()).get();
937        // The spec scans `q` only while `q < size` and requires the match to be
938        // AT `q`, so a match starting at the very end is never reached. Jumping
939        // to the next match does reach it, and letting it through appended a
940        // spurious trailing `""` for every end-anchored zero-width separator —
941        // `/$/`, `/\b/`, a trailing lookbehind.
942        if m_start >= size {
943            break;
944        }
945        let e = index_of_byte(s, m.end()).get().min(size);
946        if e == p {
947            // Contributes no piece; step past this position and rescan.
948            //
949            // The step is off `q`, not off `m_start`, because the two can move
950            // backwards relative to each other: a unit index that falls INSIDE
951            // an astral character has no byte offset of its own, so the search
952            // starts at the character's first byte and reports a match before
953            // `q`. Stepping off `m_start` there left `q` pinned and the loop
954            // spun forever on `'\u{1F600}a'.split(/(?:)/)`.
955            q = m_start.max(q) + 1;
956            continue;
957        }
958        out.push(with_host(|h| {
959            h.new_str(utf16::Units::of(s).slice(p, m_start))
960        }));
961        if out.len() >= lim {
962            return Ok(with_host(|h| h.new_array(out)));
963        }
964        for i in 1..caps.len() {
965            out.push(match caps.get(i) {
966                Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
967                None => Value::Undef,
968            });
969            if out.len() >= lim {
970                return Ok(with_host(|h| h.new_array(out)));
971            }
972        }
973        p = e;
974        q = p;
975    }
976    out.push(with_host(|h| h.new_str(utf16::Units::of(s).slice(p, size))));
977    out.truncate(lim);
978    Ok(with_host(|h| h.new_array(out)))
979}
980
981/// `str.replace(re, repl)` / `str.replaceAll(re, repl)`. `repl` is either a string
982/// (with `$1`/`$&`/`` $` ``/`$'`/`$<name>`/`$$` patterns) or a function replacer.
983pub fn str_replace_regex(
984    s: &str,
985    re_val: &Value,
986    repl: &Value,
987    all: bool,
988) -> Result<Value, String> {
989    let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
990        return Ok(with_host(|h| h.new_str(s.to_string())));
991    };
992    let replace_all = all || global;
993    let is_fn = with_host(|h| host::is_callable(h, repl));
994
995    let mut out = String::new();
996    let mut last = 0usize;
997    let mut count = 0;
998    for caps in re.captures_iter(s).flatten() {
999        let m = caps.get(0).unwrap();
1000        out.push_str(&s[last..m.start()]);
1001        if is_fn {
1002            // fn(match, p1, …, offset, whole_string)
1003            let mut call_args: Vec<Value> = Vec::new();
1004            for i in 0..caps.len() {
1005                call_args.push(match caps.get(i) {
1006                    Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
1007                    None => Value::Undef,
1008                });
1009            }
1010            call_args.push(Value::Float(index_of_byte(s, m.start()).get() as f64));
1011            call_args.push(with_host(|h| h.new_str(s.to_string())));
1012            // 22.1.3.19: when the pattern has named groups the callback takes a
1013            // final `groups` argument. Omitting it left `arguments.length` at 4
1014            // where node reports 5, and destructuring the groups out of the last
1015            // parameter saw the subject string.
1016            if let Some(groups) = named_groups_object(&re, &caps) {
1017                call_args.push(groups);
1018            }
1019            let r = host::invoke(repl, call_args, None)?;
1020            out.push_str(&with_host(|h| h.str_of(&r)));
1021        } else {
1022            let repl_str = with_host(|h| h.str_of(repl));
1023            out.push_str(&expand_replacement(&repl_str, &caps, s));
1024        }
1025        last = m.end();
1026        count += 1;
1027        if !replace_all && count >= 1 {
1028            break;
1029        }
1030    }
1031    out.push_str(&s[last..]);
1032    // 22.2.6.11 step 8: a GLOBAL regexp has its `lastIndex` set to 0 by the
1033    // replace, so the next use starts from the beginning. It was left wherever
1034    // the caller had put it, which made a shared `/…/g` skip the front of the
1035    // string on its next `test`/`exec`.
1036    if global {
1037        set_last_index(re_val, U16Index::ZERO);
1038    }
1039    Ok(with_host(|h| h.new_str(out)))
1040}
1041
1042/// Expand a replacement template's `$` patterns against a match.
1043/// The `groups` object for a match — `OrdinaryObjectCreate(null)` carrying each
1044/// named capture (22.2.7.2 step 30), or `None` when the pattern names none.
1045fn named_groups_object(re: &Regex, caps: &Captures) -> Option<Value> {
1046    let names: Vec<&str> = re.capture_names().flatten().collect();
1047    if names.is_empty() {
1048        return None;
1049    }
1050    let mut g: IndexMap<String, Value> = IndexMap::new();
1051    for name in names {
1052        let v = match caps.name(name) {
1053            Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
1054            None => Value::Undef,
1055        };
1056        g.insert(name.to_string(), v);
1057    }
1058    Some(with_host(|h| {
1059        let obj = h.new_object(g);
1060        let null = h.null();
1061        h.set_proto(&obj, null);
1062        obj
1063    }))
1064}
1065
1066fn expand_replacement(templ: &str, caps: &Captures, s: &str) -> String {
1067    let chars: Vec<char> = templ.chars().collect();
1068    let mut out = String::new();
1069    let mut i = 0;
1070    let whole = caps.get(0).unwrap();
1071    while i < chars.len() {
1072        if chars[i] == '$' && i + 1 < chars.len() {
1073            let n = chars[i + 1];
1074            match n {
1075                '$' => {
1076                    out.push('$');
1077                    i += 2;
1078                }
1079                '&' => {
1080                    out.push_str(whole.as_str());
1081                    i += 2;
1082                }
1083                '`' => {
1084                    out.push_str(&s[..whole.start()]);
1085                    i += 2;
1086                }
1087                '\'' => {
1088                    out.push_str(&s[whole.end()..]);
1089                    i += 2;
1090                }
1091                '<' => {
1092                    // `$<name>` named-group reference.
1093                    let mut j = i + 2;
1094                    let mut name = String::new();
1095                    while j < chars.len() && chars[j] != '>' {
1096                        name.push(chars[j]);
1097                        j += 1;
1098                    }
1099                    if let Some(m) = caps.name(&name) {
1100                        out.push_str(m.as_str());
1101                    }
1102                    i = j + 1; // consume '>'
1103                }
1104                d if d.is_ascii_digit() => {
1105                    // `$1`..`$99`: prefer a two-digit group if it exists.
1106                    let d2 = chars.get(i + 2).copied().filter(|c| c.is_ascii_digit());
1107                    let two = d2.and_then(|c2| format!("{d}{c2}").parse::<usize>().ok());
1108                    if let Some(gi) = two.filter(|gi| *gi < caps.len()) {
1109                        if let Some(g) = caps.get(gi) {
1110                            out.push_str(g.as_str());
1111                        }
1112                        i += 3;
1113                    } else {
1114                        let gi = d.to_digit(10).unwrap() as usize;
1115                        if gi >= 1 && gi < caps.len() {
1116                            if let Some(g) = caps.get(gi) {
1117                                out.push_str(g.as_str());
1118                            }
1119                            i += 2;
1120                        } else {
1121                            out.push('$');
1122                            i += 1;
1123                        }
1124                    }
1125                }
1126                _ => {
1127                    out.push('$');
1128                    i += 1;
1129                }
1130            }
1131        } else {
1132            out.push(chars[i]);
1133            i += 1;
1134        }
1135    }
1136    out
1137}