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//!
20//! Everything else — including `(?<name>...)`, `(?=)`/`(?!)`, `(?<=)`/`(?<!)`,
21//! and `\1`/`\k<name>` — passes through verbatim; fancy-regex parses it natively.
22//!
23//! Flags: `i`/`m`/`s` map onto inline flags; `g`/`y` drive iteration and
24//! `lastIndex` here (fancy-regex has no global flag); `u`/`d` are accepted.
25
26use crate::host::{self, with_host, JsObj, RegExpObj};
27use crate::utf16::{self, U16Index};
28use fancy_regex::{Captures, Regex};
29use fusevm::Value;
30use indexmap::IndexMap;
31use rustc_hash::FxHashMap;
32use std::cell::RefCell;
33use std::rc::Rc;
34
35/// Lone-surrogate code points are not valid Unicode scalar values, so they can
36/// never appear in a Rust `&str` and `regex` refuses to compile `\x{D800}`. Map
37/// the 2048-code-point surrogate block bijectively into Plane-15 PUA-B, which is
38/// valid, contiguous (so class ranges stay ranges), and never occurs in normal
39/// text — the surrogate alternatives thus compile and stay inert on valid input.
40const SURROGATE_LO: u32 = 0xD800;
41const SURROGATE_HI: u32 = 0xDFFF;
42const SURROGATE_PUA_BASE: u32 = 0xF_0000;
43
44/// Remap a surrogate code point into the inert PUA block; pass others through.
45fn remap_surrogate(cp: u32) -> u32 {
46    if (SURROGATE_LO..=SURROGATE_HI).contains(&cp) {
47        SURROGATE_PUA_BASE + (cp - SURROGATE_LO)
48    } else {
49        cp
50    }
51}
52
53/// Build a `RegExp` value from a JS `pattern` + `flags`, or a `SyntaxError` if the
54/// pattern uses an unsupported construct or is otherwise invalid.
55pub fn build_regexp(pattern: &str, flags: &str) -> Result<Value, String> {
56    // Validate flags (Node throws on an unknown/repeated flag).
57    let mut seen = String::new();
58    for c in flags.chars() {
59        if !"gimsuyd".contains(c) || seen.contains(c) {
60            return Err(format!(
61                "SyntaxError: Invalid flags supplied to RegExp constructor '{flags}'"
62            ));
63        }
64        seen.push(c);
65    }
66    let global = flags.contains('g');
67    let ignore_case = flags.contains('i');
68    let multiline = flags.contains('m');
69    let dot_all = flags.contains('s');
70    let sticky = flags.contains('y');
71    let unicode = flags.contains('u');
72
73    let rust_pat = translate(pattern)?;
74    // Assemble the inline-flag prefix fancy-regex (via the regex layer) understands.
75    let mut prefixed = String::new();
76    if ignore_case || multiline || dot_all {
77        prefixed.push_str("(?");
78        if ignore_case {
79            prefixed.push('i');
80        }
81        if multiline {
82            prefixed.push('m');
83        }
84        if dot_all {
85            prefixed.push('s');
86        }
87        prefixed.push(')');
88    }
89    prefixed.push_str(&rust_pat);
90
91    let re = compiled(&prefixed).map_err(|e| {
92        // Collapse the multi-line error to one line for a JS-shaped message.
93        let msg = e.lines().collect::<Vec<_>>().join(" ");
94        format!("SyntaxError: Invalid regular expression: /{pattern}/: {msg}")
95    })?;
96
97    // A `RegExpObj` is unavoidably fresh per evaluation (`lastIndex` is
98    // per-object mutable state), but the engine inside it is not.
99    let obj = RegExpObj {
100        re,
101        source: if pattern.is_empty() {
102            "(?:)".to_string()
103        } else {
104            pattern.to_string()
105        },
106        flags: flags.to_string(),
107        global,
108        ignore_case,
109        multiline,
110        dot_all,
111        sticky,
112        unicode,
113        last_index: U16Index::ZERO,
114    };
115    Ok(with_host(|h| h.alloc(JsObj::RegExp(Box::new(obj)))))
116}
117
118/// The compiled engine for an already-translated, flag-prefixed pattern,
119/// compiling it at most once per process.
120///
121/// A JS regex LITERAL is re-evaluated every time control reaches it, and each
122/// evaluation must produce a fresh `RegExp` object (`lastIndex` is per-object
123/// mutable state). Building the ENGINE each time as well is what made module
124/// loading slow: `require("express")` compiled 1,782 regexes drawn from only 59
125/// distinct patterns, and `fancy_regex::Regex::new` — not matching — accounted
126/// for 85% of the wall time. A single literal inside a hot function is the worst
127/// case: `mime-types`' `/(\.|x-).*/` cost ~1.5 ms per compile, so 2,582 loop
128/// iterations spent 4.2 s compiling one constant pattern. Hoisting that same
129/// regex out of the loop by hand took it to 27 ms, which is what identified
130/// compilation rather than matching as the cost.
131///
132/// Keyed on the translated + prefixed pattern, so two literals that differ only
133/// in spelling before translation still share one engine, and two that differ in
134/// flags do not. A compile FAILURE is not cached: it is a one-off cost on a path
135/// that immediately throws, and caching it would mean holding the error string
136/// for the life of the process.
137///
138/// Unbounded on purpose. The entries are the distinct regexes a program's source
139/// contains, which is a property of the code rather than of the input — the 59
140/// above is what a whole express dependency tree amounts to. A program that
141/// builds patterns from unbounded INPUT (`new RegExp(userString)`) is the case
142/// this would grow with, and it is also the case that gets no benefit; if that
143/// ever matters the fix is a capacity bound here, not a different design.
144fn compiled(prefixed: &str) -> Result<Rc<Regex>, String> {
145    thread_local! {
146        static CACHE: RefCell<FxHashMap<String, Rc<Regex>>> =
147            RefCell::new(FxHashMap::default());
148    }
149    if let Some(hit) = CACHE.with(|c| c.borrow().get(prefixed).cloned()) {
150        return Ok(hit);
151    }
152    let re = Rc::new(Regex::new(prefixed).map_err(|e| e.to_string())?);
153    CACHE.with(|c| {
154        c.borrow_mut().insert(prefixed.to_string(), re.clone());
155    });
156    Ok(re)
157}
158
159/// Translate a JS regex source into fancy-regex syntax. The rewrites needed are
160/// the `\u`→`\x{}` code-point spelling (with surrogate remapping), the redundant
161/// `\/` escape, and escaping a bare `[` inside a character class — JS treats it
162/// as a literal, but the `regex` layer parses it as a (nested) class open and
163/// errors ("Invalid character class"). lookaround/backrefs/named groups pass
164/// through verbatim.
165fn translate(pat: &str) -> Result<String, String> {
166    let chars: Vec<char> = pat.chars().collect();
167    let mut out = String::new();
168    let mut i = 0;
169    // Track whether we're inside a `[...]` class. `class_pos` is how many chars
170    // into the current class we are, so we can spot the `]` that would close an
171    // empty class (`[]` / `[^]`) vs. a literal leading `]`.
172    let mut in_class = false;
173    let mut class_pos = 0usize;
174    while i < chars.len() {
175        let c = chars[i];
176        // Character-class bookkeeping. A `\` escape is handled below and never
177        // toggles class state (it consumes its own two chars).
178        if c != '\\' {
179            if !in_class && c == '[' {
180                in_class = true;
181                class_pos = 0;
182                out.push('[');
183                i += 1;
184                // A leading `^` is the negation, not the first member.
185                if chars.get(i) == Some(&'^') {
186                    out.push('^');
187                    i += 1;
188                }
189                continue;
190            }
191            if in_class {
192                // The first char of a class, if `]`, is a literal `]` in JS; a
193                // later bare `[` must be escaped for the regex layer.
194                if c == ']' && class_pos > 0 {
195                    in_class = false;
196                    out.push(']');
197                    i += 1;
198                    continue;
199                }
200                if c == '[' {
201                    out.push_str("\\[");
202                    class_pos += 1;
203                    i += 1;
204                    continue;
205                }
206            }
207        }
208        match c {
209            '\\' => {
210                class_pos += 1;
211                match chars.get(i + 1).copied() {
212                    // `\uXXXX` / `\u{...}` → `\x{...}` (surrogates remapped).
213                    Some('u') => {
214                        i += 2;
215                        let cp_hex: String;
216                        if chars.get(i) == Some(&'{') {
217                            i += 1;
218                            let mut hex = String::new();
219                            while i < chars.len() && chars[i] != '}' {
220                                hex.push(chars[i]);
221                                i += 1;
222                            }
223                            i += 1; // consume '}'
224                            cp_hex = hex;
225                        } else {
226                            // Exactly four hex digits.
227                            cp_hex = chars[i..(i + 4).min(chars.len())].iter().collect();
228                            i += 4;
229                        }
230                        match u32::from_str_radix(cp_hex.trim(), 16) {
231                            Ok(cp) => out.push_str(&format!("\\x{{{:X}}}", remap_surrogate(cp))),
232                            // Not valid hex — emit the code point literally so the
233                            // engine surfaces its own error rather than us guessing.
234                            Err(_) => out.push_str(&format!("\\x{{{cp_hex}}}")),
235                        }
236                        continue;
237                    }
238                    // `\/` in a JS literal → a plain slash (regex rejects `\/`).
239                    Some('/') => {
240                        out.push('/');
241                        i += 2;
242                        continue;
243                    }
244                    // Everything else (`\d \w \s \b \1 \k \n \. \\` …) passes through.
245                    Some(other) => {
246                        out.push('\\');
247                        out.push(other);
248                        i += 2;
249                        continue;
250                    }
251                    None => {
252                        out.push('\\');
253                        i += 1;
254                    }
255                }
256            }
257            _ => {
258                if in_class {
259                    class_pos += 1;
260                }
261                out.push(c);
262                i += 1;
263            }
264        }
265    }
266    Ok(out)
267}
268
269/// The flags string in the spec's canonical order (22.2.6.4 reads the six
270/// reflectors in a fixed sequence), independent of how the literal spelled
271/// them.
272fn canonical_flags(flags: &str) -> String {
273    "dgimsuvy"
274        .chars()
275        .filter(|c| flags.contains(*c))
276        .collect::<String>()
277}
278
279/// A `RegExp` own data property (`source`/`flags`/`global`/…/`lastIndex`), or
280/// `None` if `name` is not one (so the caller tries methods).
281pub fn regexp_property(r: &RegExpObj, name: &str) -> Option<Value> {
282    Some(match name {
283        "source" => with_host(|h| h.new_str(r.source.clone())),
284        // `RegExp.prototype.flags` (22.2.6.4) is a GETTER that rebuilds the
285        // string in the spec's fixed `dgimsuvy` order, not the spelling the
286        // literal used: `/a/gid.flags` is `"dgi"` in node and was `"gid"` here,
287        // so any code keyed on the flags string (a cache key, a `new
288        // RegExp(src, flags)` round-trip comparison) disagreed.
289        "flags" => with_host(|h| h.new_str(canonical_flags(&r.flags))),
290        "global" => Value::Bool(r.global),
291        "ignoreCase" => Value::Bool(r.ignore_case),
292        "multiline" => Value::Bool(r.multiline),
293        "dotAll" => Value::Bool(r.dot_all),
294        "sticky" => Value::Bool(r.sticky),
295        "unicode" => Value::Bool(r.unicode),
296        // `d` is accepted and its match-indices output ignored (BUGS.md), but
297        // the flag reflector still has to report it; it read `undefined` where
298        // node says `true`/`false`. `v` is rejected at construction time, so
299        // `unicodeSets` is `false` for every regex that exists here.
300        "hasIndices" => Value::Bool(r.flags.contains('d')),
301        "unicodeSets" => Value::Bool(r.flags.contains('v')),
302        "lastIndex" => Value::Float(r.last_index.get() as f64),
303        _ => return None,
304    })
305}
306
307pub fn is_regexp_method(name: &str) -> bool {
308    matches!(name, "test" | "exec" | "toString" | "compile")
309}
310
311/// Dispatch a `RegExp.prototype` method.
312pub fn regexp_method(recv: &Value, name: &str, args: Vec<Value>) -> Result<Value, String> {
313    match name {
314        "test" => {
315            let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
316            Ok(Value::Bool(regexp_test(recv, &s)))
317        }
318        "exec" => {
319            let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
320            regexp_exec(recv, &s)
321        }
322        "toString" => Ok(with_host(|h| {
323            let s = h.str_of(recv);
324            h.new_str(s)
325        })),
326        // `compile` is a legacy no-op here (the pattern is already compiled).
327        "compile" => Ok(recv.clone()),
328        _ => Err(host::type_error(&format!("{name} is not a function"))),
329    }
330}
331
332/// Snapshot the fields we need without holding the host borrow across a match.
333fn regexp_snapshot(recv: &Value) -> Option<(Rc<Regex>, bool, bool, U16Index)> {
334    with_host(|h| match h.get(recv) {
335        Some(JsObj::RegExp(r)) => Some((r.re.clone(), r.global, r.sticky, r.last_index)),
336        _ => None,
337    })
338}
339
340fn set_last_index(recv: &Value, idx: U16Index) {
341    with_host(|h| {
342        if let Some(JsObj::RegExp(r)) = h.get_mut(recv) {
343            r.last_index = idx;
344        }
345    });
346}
347
348/// Byte offset of a UTF-16 index (clamped to the string length).
349///
350/// `lastIndex` and `.index` are UTF-16 code-unit offsets in JS, while the regex
351/// engine works in UTF-8 byte offsets. Both are `usize`-shaped, so the newtype
352/// is what stops one being passed where the other belongs.
353fn byte_of_index(s: &str, n: U16Index) -> usize {
354    utf16::byte_of_index(s, n)
355}
356/// UTF-16 index of a byte offset.
357fn index_of_byte(s: &str, byte: usize) -> U16Index {
358    utf16::index_of_byte(s, byte)
359}
360
361/// `re.test(s)` — honoring `g`/`y` `lastIndex` advancement, exactly like `exec`.
362pub fn regexp_test(recv: &Value, s: &str) -> bool {
363    let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
364        return false;
365    };
366    let start_idx = if global || sticky {
367        last
368    } else {
369        U16Index::ZERO
370    };
371    if start_idx.get() > utf16::len(s) {
372        if global || sticky {
373            set_last_index(recv, U16Index::ZERO);
374        }
375        return false;
376    }
377    let start_byte = byte_of_index(s, start_idx);
378    // A backtracking match can fail (catastrophic backtracking guard); treat an
379    // engine error as "no match" so a pathological pattern never panics the VM.
380    match re.find_from_pos(s, start_byte) {
381        Ok(Some(m)) if !sticky || m.start() == start_byte => {
382            if global || sticky {
383                set_last_index(recv, index_of_byte(s, m.end()));
384            }
385            true
386        }
387        _ => {
388            if global || sticky {
389                set_last_index(recv, U16Index::ZERO);
390            }
391            false
392        }
393    }
394}
395
396/// `re.exec(s)` — returns a match array (`[full, ...captures]` with `.index`,
397/// `.input`, `.groups`), or `null`. Advances `lastIndex` under `g`/`y`.
398pub fn regexp_exec(recv: &Value, s: &str) -> Result<Value, String> {
399    let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
400        return Ok(with_host(|h| h.null()));
401    };
402    let start_idx = if global || sticky {
403        last
404    } else {
405        U16Index::ZERO
406    };
407    if start_idx.get() > utf16::len(s) {
408        if global || sticky {
409            set_last_index(recv, U16Index::ZERO);
410        }
411        return Ok(with_host(|h| h.null()));
412    }
413    let start_byte = byte_of_index(s, start_idx);
414    let caps = re.captures_from_pos(s, start_byte).ok().flatten();
415    let caps = match caps {
416        Some(c) if !sticky || c.get(0).map(|m| m.start()) == Some(start_byte) => c,
417        _ => {
418            if global || sticky {
419                set_last_index(recv, U16Index::ZERO);
420            }
421            return Ok(with_host(|h| h.null()));
422        }
423    };
424    let whole = caps.get(0).unwrap();
425    if global || sticky {
426        set_last_index(recv, index_of_byte(s, whole.end()));
427    }
428    Ok(build_match_array(&re, &caps, s))
429}
430
431/// Build the JS match-result array from a `Captures`, attaching `.index`,
432/// `.input`, and (named-group) `.groups`.
433fn build_match_array(re: &Regex, caps: &Captures, s: &str) -> Value {
434    let mut items: Vec<Value> = Vec::with_capacity(caps.len());
435    for i in 0..caps.len() {
436        items.push(match caps.get(i) {
437            Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
438            None => Value::Undef, // a non-participating optional group
439        });
440    }
441    let whole = caps.get(0).unwrap();
442    let arr = with_host(|h| h.new_array(items));
443    let index = index_of_byte(s, whole.start()).get();
444    with_host(|h| {
445        let idx = Value::Float(index as f64);
446        h.set_fn_prop(&arr, "index", idx);
447        let input = h.new_str(s.to_string());
448        h.set_fn_prop(&arr, "input", input);
449    });
450    // Named groups → a `.groups` object (or `undefined` if the regex has none).
451    let names: Vec<&str> = re.capture_names().flatten().collect();
452    if names.is_empty() {
453        with_host(|h| h.set_fn_prop(&arr, "groups", Value::Undef));
454    } else {
455        let mut g: IndexMap<String, Value> = IndexMap::new();
456        for name in names {
457            let v = match caps.name(name) {
458                Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
459                None => Value::Undef,
460            };
461            g.insert(name.to_string(), v);
462        }
463        with_host(|h| {
464            let obj = h.new_object(g);
465            // `groups` is an `OrdinaryObjectCreate(null)` (22.2.7.2 step 30), so
466            // it inherits nothing and inspects as `[Object: null prototype]`.
467            let null = h.null();
468            h.set_proto(&obj, null);
469            h.set_fn_prop(&arr, "groups", obj);
470        });
471    }
472    arr
473}
474
475// ── String.prototype regex methods (called from builtins::string_method) ──────
476
477/// `str.match(re)`: without `g`, same as `exec` (array or null); with `g`, an
478/// array of every whole-match string (or null if none).
479pub fn str_match(s: &str, re_val: &Value) -> Result<Value, String> {
480    let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
481        return Ok(with_host(|h| h.null()));
482    };
483    if !global {
484        // Non-global match ignores lastIndex and searches from the start.
485        set_last_index(re_val, U16Index::ZERO);
486        return regexp_exec_from_zero(&re, s);
487    }
488    let matches: Vec<Value> = re
489        .find_iter(s)
490        .filter_map(|m| m.ok())
491        .map(|m| with_host(|h| h.new_str(m.as_str().to_string())))
492        .collect();
493    if matches.is_empty() {
494        Ok(with_host(|h| h.null()))
495    } else {
496        Ok(with_host(|h| h.new_array(matches)))
497    }
498}
499
500/// Non-global exec searching from offset 0 (for `str.match` without `g`).
501fn regexp_exec_from_zero(re: &Regex, s: &str) -> Result<Value, String> {
502    match re.captures(s).ok().flatten() {
503        Some(caps) => Ok(build_match_array(re, &caps, s)),
504        None => Ok(with_host(|h| h.null())),
505    }
506}
507
508/// `str.matchAll(re)`: an iterator over every match array (requires the `g` flag
509/// in Node, but we accept a non-global regex too and still iterate all matches).
510pub fn str_match_all(s: &str, re_val: &Value) -> Result<Value, String> {
511    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
512        return Ok(with_host(|h| h.new_array(Vec::new())));
513    };
514    let mut items = Vec::new();
515    for caps in re.captures_iter(s).flatten() {
516        items.push(build_match_array(&re, &caps, s));
517    }
518    // Return a live iterator so `for-of`/spread/`Array.from` all work.
519    Ok(with_host(|h| h.alloc(JsObj::Iter { items, idx: 0 })))
520}
521
522/// `str.search(re)`: char index of the first match, or -1.
523pub fn str_search(s: &str, re_val: &Value) -> Result<Value, String> {
524    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
525        return Ok(Value::Float(-1.0));
526    };
527    Ok(match re.find(s).ok().flatten() {
528        Some(m) => Value::Float(index_of_byte(s, m.start()).get() as f64),
529        None => Value::Float(-1.0),
530    })
531}
532
533/// `str.split(re[, limit])`: split on regex matches; captured groups are spliced
534/// into the output (JS semantics).
535pub fn str_split_regex(s: &str, re_val: &Value, limit: Option<usize>) -> Result<Value, String> {
536    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
537        return Ok(with_host(|h| h.new_array(Vec::new())));
538    };
539    let mut out: Vec<Value> = Vec::new();
540    let mut last_end = 0usize;
541    for caps in re.captures_iter(s).flatten() {
542        let m = caps.get(0).unwrap();
543        // Zero-width match at the very start is skipped (matches JS closely).
544        if m.start() == m.end() && m.start() == last_end && last_end == 0 {
545            continue;
546        }
547        out.push(with_host(|h| h.new_str(s[last_end..m.start()].to_string())));
548        // Splice in captured groups (1..).
549        for i in 1..caps.len() {
550            out.push(match caps.get(i) {
551                Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
552                None => Value::Undef,
553            });
554        }
555        last_end = m.end();
556        if let Some(l) = limit {
557            if out.len() >= l {
558                out.truncate(l);
559                return Ok(with_host(|h| h.new_array(out)));
560            }
561        }
562    }
563    out.push(with_host(|h| h.new_str(s[last_end..].to_string())));
564    if let Some(l) = limit {
565        out.truncate(l);
566    }
567    Ok(with_host(|h| h.new_array(out)))
568}
569
570/// `str.replace(re, repl)` / `str.replaceAll(re, repl)`. `repl` is either a string
571/// (with `$1`/`$&`/`` $` ``/`$'`/`$<name>`/`$$` patterns) or a function replacer.
572pub fn str_replace_regex(
573    s: &str,
574    re_val: &Value,
575    repl: &Value,
576    all: bool,
577) -> Result<Value, String> {
578    let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
579        return Ok(with_host(|h| h.new_str(s.to_string())));
580    };
581    let replace_all = all || global;
582    let is_fn = with_host(|h| host::is_callable(h, repl));
583
584    let mut out = String::new();
585    let mut last = 0usize;
586    let mut count = 0;
587    for caps in re.captures_iter(s).flatten() {
588        let m = caps.get(0).unwrap();
589        out.push_str(&s[last..m.start()]);
590        if is_fn {
591            // fn(match, p1, …, offset, whole_string)
592            let mut call_args: Vec<Value> = Vec::new();
593            for i in 0..caps.len() {
594                call_args.push(match caps.get(i) {
595                    Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
596                    None => Value::Undef,
597                });
598            }
599            call_args.push(Value::Float(index_of_byte(s, m.start()).get() as f64));
600            call_args.push(with_host(|h| h.new_str(s.to_string())));
601            let r = host::invoke(repl, call_args, None)?;
602            out.push_str(&with_host(|h| h.str_of(&r)));
603        } else {
604            let repl_str = with_host(|h| h.str_of(repl));
605            out.push_str(&expand_replacement(&repl_str, &caps, s));
606        }
607        last = m.end();
608        count += 1;
609        if !replace_all && count >= 1 {
610            break;
611        }
612    }
613    out.push_str(&s[last..]);
614    Ok(with_host(|h| h.new_str(out)))
615}
616
617/// Expand a replacement template's `$` patterns against a match.
618fn expand_replacement(templ: &str, caps: &Captures, s: &str) -> String {
619    let chars: Vec<char> = templ.chars().collect();
620    let mut out = String::new();
621    let mut i = 0;
622    let whole = caps.get(0).unwrap();
623    while i < chars.len() {
624        if chars[i] == '$' && i + 1 < chars.len() {
625            let n = chars[i + 1];
626            match n {
627                '$' => {
628                    out.push('$');
629                    i += 2;
630                }
631                '&' => {
632                    out.push_str(whole.as_str());
633                    i += 2;
634                }
635                '`' => {
636                    out.push_str(&s[..whole.start()]);
637                    i += 2;
638                }
639                '\'' => {
640                    out.push_str(&s[whole.end()..]);
641                    i += 2;
642                }
643                '<' => {
644                    // `$<name>` named-group reference.
645                    let mut j = i + 2;
646                    let mut name = String::new();
647                    while j < chars.len() && chars[j] != '>' {
648                        name.push(chars[j]);
649                        j += 1;
650                    }
651                    if let Some(m) = caps.name(&name) {
652                        out.push_str(m.as_str());
653                    }
654                    i = j + 1; // consume '>'
655                }
656                d if d.is_ascii_digit() => {
657                    // `$1`..`$99`: prefer a two-digit group if it exists.
658                    let d2 = chars.get(i + 2).copied().filter(|c| c.is_ascii_digit());
659                    let two = d2.and_then(|c2| format!("{d}{c2}").parse::<usize>().ok());
660                    if let Some(gi) = two.filter(|gi| *gi < caps.len()) {
661                        if let Some(g) = caps.get(gi) {
662                            out.push_str(g.as_str());
663                        }
664                        i += 3;
665                    } else {
666                        let gi = d.to_digit(10).unwrap() as usize;
667                        if gi >= 1 && gi < caps.len() {
668                            if let Some(g) = caps.get(gi) {
669                                out.push_str(g.as_str());
670                            }
671                            i += 2;
672                        } else {
673                            out.push('$');
674                            i += 1;
675                        }
676                    }
677                }
678                _ => {
679                    out.push('$');
680                    i += 1;
681                }
682            }
683        } else {
684            out.push(chars[i]);
685            i += 1;
686        }
687    }
688    out
689}