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 fancy_regex::{Captures, Regex};
28use fusevm::Value;
29use indexmap::IndexMap;
30
31/// Lone-surrogate code points are not valid Unicode scalar values, so they can
32/// never appear in a Rust `&str` and `regex` refuses to compile `\x{D800}`. Map
33/// the 2048-code-point surrogate block bijectively into Plane-15 PUA-B, which is
34/// valid, contiguous (so class ranges stay ranges), and never occurs in normal
35/// text — the surrogate alternatives thus compile and stay inert on valid input.
36const SURROGATE_LO: u32 = 0xD800;
37const SURROGATE_HI: u32 = 0xDFFF;
38const SURROGATE_PUA_BASE: u32 = 0xF_0000;
39
40/// Remap a surrogate code point into the inert PUA block; pass others through.
41fn remap_surrogate(cp: u32) -> u32 {
42    if (SURROGATE_LO..=SURROGATE_HI).contains(&cp) {
43        SURROGATE_PUA_BASE + (cp - SURROGATE_LO)
44    } else {
45        cp
46    }
47}
48
49/// Build a `RegExp` value from a JS `pattern` + `flags`, or a `SyntaxError` if the
50/// pattern uses an unsupported construct or is otherwise invalid.
51pub fn build_regexp(pattern: &str, flags: &str) -> Result<Value, String> {
52    // Validate flags (Node throws on an unknown/repeated flag).
53    let mut seen = String::new();
54    for c in flags.chars() {
55        if !"gimsuyd".contains(c) || seen.contains(c) {
56            return Err(format!(
57                "SyntaxError: Invalid flags supplied to RegExp constructor '{flags}'"
58            ));
59        }
60        seen.push(c);
61    }
62    let global = flags.contains('g');
63    let ignore_case = flags.contains('i');
64    let multiline = flags.contains('m');
65    let dot_all = flags.contains('s');
66    let sticky = flags.contains('y');
67    let unicode = flags.contains('u');
68
69    let rust_pat = translate(pattern)?;
70    // Assemble the inline-flag prefix fancy-regex (via the regex layer) understands.
71    let mut prefixed = String::new();
72    if ignore_case || multiline || dot_all {
73        prefixed.push_str("(?");
74        if ignore_case {
75            prefixed.push('i');
76        }
77        if multiline {
78            prefixed.push('m');
79        }
80        if dot_all {
81            prefixed.push('s');
82        }
83        prefixed.push(')');
84    }
85    prefixed.push_str(&rust_pat);
86
87    let re = Regex::new(&prefixed).map_err(|e| {
88        // Collapse the multi-line error to one line for a JS-shaped message.
89        let msg = e.to_string().lines().collect::<Vec<_>>().join(" ");
90        format!("SyntaxError: Invalid regular expression: /{pattern}/: {msg}")
91    })?;
92
93    let obj = RegExpObj {
94        re,
95        source: if pattern.is_empty() {
96            "(?:)".to_string()
97        } else {
98            pattern.to_string()
99        },
100        flags: flags.to_string(),
101        global,
102        ignore_case,
103        multiline,
104        dot_all,
105        sticky,
106        unicode,
107        last_index: 0,
108    };
109    Ok(with_host(|h| h.alloc(JsObj::RegExp(Box::new(obj)))))
110}
111
112/// Translate a JS regex source into fancy-regex syntax. The rewrites needed are
113/// the `\u`→`\x{}` code-point spelling (with surrogate remapping), the redundant
114/// `\/` escape, and escaping a bare `[` inside a character class — JS treats it
115/// as a literal, but the `regex` layer parses it as a (nested) class open and
116/// errors ("Invalid character class"). lookaround/backrefs/named groups pass
117/// through verbatim.
118fn translate(pat: &str) -> Result<String, String> {
119    let chars: Vec<char> = pat.chars().collect();
120    let mut out = String::new();
121    let mut i = 0;
122    // Track whether we're inside a `[...]` class. `class_pos` is how many chars
123    // into the current class we are, so we can spot the `]` that would close an
124    // empty class (`[]` / `[^]`) vs. a literal leading `]`.
125    let mut in_class = false;
126    let mut class_pos = 0usize;
127    while i < chars.len() {
128        let c = chars[i];
129        // Character-class bookkeeping. A `\` escape is handled below and never
130        // toggles class state (it consumes its own two chars).
131        if c != '\\' {
132            if !in_class && c == '[' {
133                in_class = true;
134                class_pos = 0;
135                out.push('[');
136                i += 1;
137                // A leading `^` is the negation, not the first member.
138                if chars.get(i) == Some(&'^') {
139                    out.push('^');
140                    i += 1;
141                }
142                continue;
143            }
144            if in_class {
145                // The first char of a class, if `]`, is a literal `]` in JS; a
146                // later bare `[` must be escaped for the regex layer.
147                if c == ']' && class_pos > 0 {
148                    in_class = false;
149                    out.push(']');
150                    i += 1;
151                    continue;
152                }
153                if c == '[' {
154                    out.push_str("\\[");
155                    class_pos += 1;
156                    i += 1;
157                    continue;
158                }
159            }
160        }
161        match c {
162            '\\' => {
163                class_pos += 1;
164                match chars.get(i + 1).copied() {
165                    // `\uXXXX` / `\u{...}` → `\x{...}` (surrogates remapped).
166                    Some('u') => {
167                        i += 2;
168                        let cp_hex: String;
169                        if chars.get(i) == Some(&'{') {
170                            i += 1;
171                            let mut hex = String::new();
172                            while i < chars.len() && chars[i] != '}' {
173                                hex.push(chars[i]);
174                                i += 1;
175                            }
176                            i += 1; // consume '}'
177                            cp_hex = hex;
178                        } else {
179                            // Exactly four hex digits.
180                            cp_hex = chars[i..(i + 4).min(chars.len())].iter().collect();
181                            i += 4;
182                        }
183                        match u32::from_str_radix(cp_hex.trim(), 16) {
184                            Ok(cp) => out.push_str(&format!("\\x{{{:X}}}", remap_surrogate(cp))),
185                            // Not valid hex — emit the code point literally so the
186                            // engine surfaces its own error rather than us guessing.
187                            Err(_) => out.push_str(&format!("\\x{{{cp_hex}}}")),
188                        }
189                        continue;
190                    }
191                    // `\/` in a JS literal → a plain slash (regex rejects `\/`).
192                    Some('/') => {
193                        out.push('/');
194                        i += 2;
195                        continue;
196                    }
197                    // Everything else (`\d \w \s \b \1 \k \n \. \\` …) passes through.
198                    Some(other) => {
199                        out.push('\\');
200                        out.push(other);
201                        i += 2;
202                        continue;
203                    }
204                    None => {
205                        out.push('\\');
206                        i += 1;
207                    }
208                }
209            }
210            _ => {
211                if in_class {
212                    class_pos += 1;
213                }
214                out.push(c);
215                i += 1;
216            }
217        }
218    }
219    Ok(out)
220}
221
222/// A `RegExp` own data property (`source`/`flags`/`global`/…/`lastIndex`), or
223/// `None` if `name` is not one (so the caller tries methods).
224pub fn regexp_property(r: &RegExpObj, name: &str) -> Option<Value> {
225    Some(match name {
226        "source" => with_host(|h| h.new_str(r.source.clone())),
227        "flags" => with_host(|h| h.new_str(r.flags.clone())),
228        "global" => Value::Bool(r.global),
229        "ignoreCase" => Value::Bool(r.ignore_case),
230        "multiline" => Value::Bool(r.multiline),
231        "dotAll" => Value::Bool(r.dot_all),
232        "sticky" => Value::Bool(r.sticky),
233        "unicode" => Value::Bool(r.unicode),
234        "lastIndex" => Value::Float(r.last_index as f64),
235        _ => return None,
236    })
237}
238
239pub fn is_regexp_method(name: &str) -> bool {
240    matches!(name, "test" | "exec" | "toString" | "compile")
241}
242
243/// Dispatch a `RegExp.prototype` method.
244pub fn regexp_method(recv: &Value, name: &str, args: Vec<Value>) -> Result<Value, String> {
245    match name {
246        "test" => {
247            let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
248            Ok(Value::Bool(regexp_test(recv, &s)))
249        }
250        "exec" => {
251            let s = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
252            regexp_exec(recv, &s)
253        }
254        "toString" => Ok(with_host(|h| {
255            let s = h.str_of(recv);
256            h.new_str(s)
257        })),
258        // `compile` is a legacy no-op here (the pattern is already compiled).
259        "compile" => Ok(recv.clone()),
260        _ => Err(host::type_error(&format!("{name} is not a function"))),
261    }
262}
263
264/// Snapshot the fields we need without holding the host borrow across a match.
265fn regexp_snapshot(recv: &Value) -> Option<(Regex, bool, bool, usize)> {
266    with_host(|h| match h.get(recv) {
267        Some(JsObj::RegExp(r)) => Some((r.re.clone(), r.global, r.sticky, r.last_index)),
268        _ => None,
269    })
270}
271
272fn set_last_index(recv: &Value, idx: usize) {
273    with_host(|h| {
274        if let Some(JsObj::RegExp(r)) = h.get_mut(recv) {
275            r.last_index = idx;
276        }
277    });
278}
279
280/// Byte offset of the `n`-th char (clamped to the string length).
281fn byte_of_char(s: &str, n: usize) -> usize {
282    s.char_indices().nth(n).map(|(b, _)| b).unwrap_or(s.len())
283}
284/// Char index of a byte offset.
285fn char_of_byte(s: &str, byte: usize) -> usize {
286    s[..byte.min(s.len())].chars().count()
287}
288
289/// `re.test(s)` — honoring `g`/`y` `lastIndex` advancement, exactly like `exec`.
290pub fn regexp_test(recv: &Value, s: &str) -> bool {
291    let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
292        return false;
293    };
294    let start_char = if global || sticky { last } else { 0 };
295    if start_char > s.chars().count() {
296        if global || sticky {
297            set_last_index(recv, 0);
298        }
299        return false;
300    }
301    let start_byte = byte_of_char(s, start_char);
302    // A backtracking match can fail (catastrophic backtracking guard); treat an
303    // engine error as "no match" so a pathological pattern never panics the VM.
304    match re.find_from_pos(s, start_byte) {
305        Ok(Some(m)) if !sticky || m.start() == start_byte => {
306            if global || sticky {
307                set_last_index(recv, char_of_byte(s, m.end()));
308            }
309            true
310        }
311        _ => {
312            if global || sticky {
313                set_last_index(recv, 0);
314            }
315            false
316        }
317    }
318}
319
320/// `re.exec(s)` — returns a match array (`[full, ...captures]` with `.index`,
321/// `.input`, `.groups`), or `null`. Advances `lastIndex` under `g`/`y`.
322pub fn regexp_exec(recv: &Value, s: &str) -> Result<Value, String> {
323    let Some((re, global, sticky, last)) = regexp_snapshot(recv) else {
324        return Ok(with_host(|h| h.null()));
325    };
326    let start_char = if global || sticky { last } else { 0 };
327    if start_char > s.chars().count() {
328        if global || sticky {
329            set_last_index(recv, 0);
330        }
331        return Ok(with_host(|h| h.null()));
332    }
333    let start_byte = byte_of_char(s, start_char);
334    let caps = re.captures_from_pos(s, start_byte).ok().flatten();
335    let caps = match caps {
336        Some(c) if !sticky || c.get(0).map(|m| m.start()) == Some(start_byte) => c,
337        _ => {
338            if global || sticky {
339                set_last_index(recv, 0);
340            }
341            return Ok(with_host(|h| h.null()));
342        }
343    };
344    let whole = caps.get(0).unwrap();
345    if global || sticky {
346        set_last_index(recv, char_of_byte(s, whole.end()));
347    }
348    Ok(build_match_array(&re, &caps, s))
349}
350
351/// Build the JS match-result array from a `Captures`, attaching `.index`,
352/// `.input`, and (named-group) `.groups`.
353fn build_match_array(re: &Regex, caps: &Captures, s: &str) -> Value {
354    let mut items: Vec<Value> = Vec::with_capacity(caps.len());
355    for i in 0..caps.len() {
356        items.push(match caps.get(i) {
357            Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
358            None => Value::Undef, // a non-participating optional group
359        });
360    }
361    let whole = caps.get(0).unwrap();
362    let arr = with_host(|h| h.new_array(items));
363    let index = char_of_byte(s, whole.start());
364    with_host(|h| {
365        let idx = Value::Float(index as f64);
366        h.set_fn_prop(&arr, "index", idx);
367        let input = h.new_str(s.to_string());
368        h.set_fn_prop(&arr, "input", input);
369    });
370    // Named groups → a `.groups` object (or `undefined` if the regex has none).
371    let names: Vec<&str> = re.capture_names().flatten().collect();
372    if names.is_empty() {
373        with_host(|h| h.set_fn_prop(&arr, "groups", Value::Undef));
374    } else {
375        let mut g: IndexMap<String, Value> = IndexMap::new();
376        for name in names {
377            let v = match caps.name(name) {
378                Some(m) => with_host(|h| h.new_str(m.as_str().to_string())),
379                None => Value::Undef,
380            };
381            g.insert(name.to_string(), v);
382        }
383        with_host(|h| {
384            let obj = h.new_object(g);
385            h.set_fn_prop(&arr, "groups", obj);
386        });
387    }
388    arr
389}
390
391// ── String.prototype regex methods (called from builtins::string_method) ──────
392
393/// `str.match(re)`: without `g`, same as `exec` (array or null); with `g`, an
394/// array of every whole-match string (or null if none).
395pub fn str_match(s: &str, re_val: &Value) -> Result<Value, String> {
396    let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
397        return Ok(with_host(|h| h.null()));
398    };
399    if !global {
400        // Non-global match ignores lastIndex and searches from the start.
401        set_last_index(re_val, 0);
402        return regexp_exec_from_zero(&re, s);
403    }
404    let matches: Vec<Value> = re
405        .find_iter(s)
406        .filter_map(|m| m.ok())
407        .map(|m| with_host(|h| h.new_str(m.as_str().to_string())))
408        .collect();
409    if matches.is_empty() {
410        Ok(with_host(|h| h.null()))
411    } else {
412        Ok(with_host(|h| h.new_array(matches)))
413    }
414}
415
416/// Non-global exec searching from offset 0 (for `str.match` without `g`).
417fn regexp_exec_from_zero(re: &Regex, s: &str) -> Result<Value, String> {
418    match re.captures(s).ok().flatten() {
419        Some(caps) => Ok(build_match_array(re, &caps, s)),
420        None => Ok(with_host(|h| h.null())),
421    }
422}
423
424/// `str.matchAll(re)`: an iterator over every match array (requires the `g` flag
425/// in Node, but we accept a non-global regex too and still iterate all matches).
426pub fn str_match_all(s: &str, re_val: &Value) -> Result<Value, String> {
427    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
428        return Ok(with_host(|h| h.new_array(Vec::new())));
429    };
430    let mut items = Vec::new();
431    for caps in re.captures_iter(s).flatten() {
432        items.push(build_match_array(&re, &caps, s));
433    }
434    // Return a live iterator so `for-of`/spread/`Array.from` all work.
435    Ok(with_host(|h| h.alloc(JsObj::Iter { items, idx: 0 })))
436}
437
438/// `str.search(re)`: char index of the first match, or -1.
439pub fn str_search(s: &str, re_val: &Value) -> Result<Value, String> {
440    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
441        return Ok(Value::Float(-1.0));
442    };
443    Ok(match re.find(s).ok().flatten() {
444        Some(m) => Value::Float(char_of_byte(s, m.start()) as f64),
445        None => Value::Float(-1.0),
446    })
447}
448
449/// `str.split(re[, limit])`: split on regex matches; captured groups are spliced
450/// into the output (JS semantics).
451pub fn str_split_regex(s: &str, re_val: &Value, limit: Option<usize>) -> Result<Value, String> {
452    let Some((re, _, _, _)) = regexp_snapshot(re_val) else {
453        return Ok(with_host(|h| h.new_array(Vec::new())));
454    };
455    let mut out: Vec<Value> = Vec::new();
456    let mut last_end = 0usize;
457    for caps in re.captures_iter(s).flatten() {
458        let m = caps.get(0).unwrap();
459        // Zero-width match at the very start is skipped (matches JS closely).
460        if m.start() == m.end() && m.start() == last_end && last_end == 0 {
461            continue;
462        }
463        out.push(with_host(|h| h.new_str(s[last_end..m.start()].to_string())));
464        // Splice in captured groups (1..).
465        for i in 1..caps.len() {
466            out.push(match caps.get(i) {
467                Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
468                None => Value::Undef,
469            });
470        }
471        last_end = m.end();
472        if let Some(l) = limit {
473            if out.len() >= l {
474                out.truncate(l);
475                return Ok(with_host(|h| h.new_array(out)));
476            }
477        }
478    }
479    out.push(with_host(|h| h.new_str(s[last_end..].to_string())));
480    if let Some(l) = limit {
481        out.truncate(l);
482    }
483    Ok(with_host(|h| h.new_array(out)))
484}
485
486/// `str.replace(re, repl)` / `str.replaceAll(re, repl)`. `repl` is either a string
487/// (with `$1`/`$&`/`` $` ``/`$'`/`$<name>`/`$$` patterns) or a function replacer.
488pub fn str_replace_regex(
489    s: &str,
490    re_val: &Value,
491    repl: &Value,
492    all: bool,
493) -> Result<Value, String> {
494    let Some((re, global, _, _)) = regexp_snapshot(re_val) else {
495        return Ok(with_host(|h| h.new_str(s.to_string())));
496    };
497    let replace_all = all || global;
498    let is_fn = with_host(|h| host::is_callable(h, repl));
499
500    let mut out = String::new();
501    let mut last = 0usize;
502    let mut count = 0;
503    for caps in re.captures_iter(s).flatten() {
504        let m = caps.get(0).unwrap();
505        out.push_str(&s[last..m.start()]);
506        if is_fn {
507            // fn(match, p1, …, offset, whole_string)
508            let mut call_args: Vec<Value> = Vec::new();
509            for i in 0..caps.len() {
510                call_args.push(match caps.get(i) {
511                    Some(g) => with_host(|h| h.new_str(g.as_str().to_string())),
512                    None => Value::Undef,
513                });
514            }
515            call_args.push(Value::Float(char_of_byte(s, m.start()) as f64));
516            call_args.push(with_host(|h| h.new_str(s.to_string())));
517            let r = host::invoke(repl, call_args, None)?;
518            out.push_str(&with_host(|h| h.str_of(&r)));
519        } else {
520            let repl_str = with_host(|h| h.str_of(repl));
521            out.push_str(&expand_replacement(&repl_str, &caps, s));
522        }
523        last = m.end();
524        count += 1;
525        if !replace_all && count >= 1 {
526            break;
527        }
528    }
529    out.push_str(&s[last..]);
530    Ok(with_host(|h| h.new_str(out)))
531}
532
533/// Expand a replacement template's `$` patterns against a match.
534fn expand_replacement(templ: &str, caps: &Captures, s: &str) -> String {
535    let chars: Vec<char> = templ.chars().collect();
536    let mut out = String::new();
537    let mut i = 0;
538    let whole = caps.get(0).unwrap();
539    while i < chars.len() {
540        if chars[i] == '$' && i + 1 < chars.len() {
541            let n = chars[i + 1];
542            match n {
543                '$' => {
544                    out.push('$');
545                    i += 2;
546                }
547                '&' => {
548                    out.push_str(whole.as_str());
549                    i += 2;
550                }
551                '`' => {
552                    out.push_str(&s[..whole.start()]);
553                    i += 2;
554                }
555                '\'' => {
556                    out.push_str(&s[whole.end()..]);
557                    i += 2;
558                }
559                '<' => {
560                    // `$<name>` named-group reference.
561                    let mut j = i + 2;
562                    let mut name = String::new();
563                    while j < chars.len() && chars[j] != '>' {
564                        name.push(chars[j]);
565                        j += 1;
566                    }
567                    if let Some(m) = caps.name(&name) {
568                        out.push_str(m.as_str());
569                    }
570                    i = j + 1; // consume '>'
571                }
572                d if d.is_ascii_digit() => {
573                    // `$1`..`$99`: prefer a two-digit group if it exists.
574                    let d2 = chars.get(i + 2).copied().filter(|c| c.is_ascii_digit());
575                    let two = d2.and_then(|c2| format!("{d}{c2}").parse::<usize>().ok());
576                    if let Some(gi) = two.filter(|gi| *gi < caps.len()) {
577                        if let Some(g) = caps.get(gi) {
578                            out.push_str(g.as_str());
579                        }
580                        i += 3;
581                    } else {
582                        let gi = d.to_digit(10).unwrap() as usize;
583                        if gi >= 1 && gi < caps.len() {
584                            if let Some(g) = caps.get(gi) {
585                                out.push_str(g.as_str());
586                            }
587                            i += 2;
588                        } else {
589                            out.push('$');
590                            i += 1;
591                        }
592                    }
593                }
594                _ => {
595                    out.push('$');
596                    i += 1;
597                }
598            }
599        } else {
600            out.push(chars[i]);
601            i += 1;
602        }
603    }
604    out
605}