LibRegex: Respect V8 astral literal lastIndex behavior

Preserve V8's behavior for bare single-astral literals when a unicode
global search starts in the middle of a surrogate pair. We were
snapping that lastIndex back to the pair start unconditionally,
which let /😀/gu and /\u{1F600}/gu match where V8 returns null.

Expose that literal shape from LibRegex to LibJS and add runtime
coverage for the bare literal case alongside a grouped control.
This commit is contained in:
Andreas Kling 2026-03-25 22:51:10 +01:00 committed by Ali Mohammad Pur
parent 29c2fb9574
commit f627b7dcbb
8 changed files with 52 additions and 1 deletions

View file

@ -168,7 +168,8 @@ static ThrowCompletionOr<Value> regexp_builtin_exec(VM& vm, RegExpObject& regexp
|| has_flag(flag_bits, RegExpObject::Flags::UnicodeSets);
if (unicode_mode && last_index > 0 && last_index < utf16_view.length_in_code_units()) {
if (utf16_view.code_unit_at(last_index) >= 0xDC00 && utf16_view.code_unit_at(last_index) <= 0xDFFF
&& utf16_view.code_unit_at(last_index - 1) >= 0xD800 && utf16_view.code_unit_at(last_index - 1) <= 0xDBFF) {
&& utf16_view.code_unit_at(last_index - 1) >= 0xD800 && utf16_view.code_unit_at(last_index - 1) <= 0xDBFF
&& !compiled_regex->is_single_non_bmp_literal()) {
--last_index;
}
}

View file

@ -90,6 +90,11 @@ unsigned int ECMAScriptRegex::total_groups() const
return m_impl->rust_regex.total_groups();
}
bool ECMAScriptRegex::is_single_non_bmp_literal() const
{
return m_impl->rust_regex.is_single_non_bmp_literal();
}
Vector<ECMAScriptNamedCaptureGroup> const& ECMAScriptRegex::named_groups() const
{
return m_impl->named_groups;

View file

@ -66,6 +66,8 @@ public:
/// Total number of capture groups including group 0.
unsigned int total_groups() const;
bool is_single_non_bmp_literal() const;
/// Named capture groups with their indices.
Vector<ECMAScriptNamedCaptureGroup> const& named_groups() const;

View file

@ -254,6 +254,20 @@ pub unsafe extern "C" fn rust_regex_capture_count(regex: *const RustRegex) -> u3
regex.0.capture_count()
}
/// Return whether this regex is a whole-pattern literal for a single non-BMP
/// code point in unicode mode.
///
/// # Safety
/// `regex` must be a valid pointer from `rust_regex_compile`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_regex_is_single_non_bmp_literal(regex: *const RustRegex) -> bool {
if regex.is_null() {
return false;
}
let regex = unsafe { &*regex };
regex.0.is_single_non_bmp_literal()
}
/// Find all non-overlapping matches and return a flat array of (start, end) i32 pairs.
/// Returns the number of matches (NOT the number of i32s). The output buffer must have
/// space for at least `max_matches * 2` i32s. Returns -1 if the buffer is too small.

View file

@ -332,6 +332,15 @@ impl Regex {
&self.program.named_groups
}
pub fn is_single_non_bmp_literal(&self) -> bool {
(self.flags.unicode || self.flags.unicode_sets)
&& matches!(
self.literal_u16.as_deref(),
Some([high, low])
if (0xD800..=0xDBFF).contains(high) && (0xDC00..=0xDFFF).contains(low)
)
}
/// Find all non-overlapping matches starting from `start`.
/// Writes (match_start, match_end) i32 pairs directly into `result_buf`.
/// Returns number of matches found, or -1 if buffer is too small.

View file

@ -499,6 +499,11 @@ unsigned int CompiledRustRegex::total_groups() const
return m_capture_count;
}
bool CompiledRustRegex::is_single_non_bmp_literal() const
{
return rust_regex_is_single_non_bmp_literal(m_regex);
}
int CompiledRustRegex::test(Utf16View input, size_t start_pos) const
{
if (input.has_ascii_storage()) {

View file

@ -45,6 +45,7 @@ public:
unsigned int capture_count() const;
/// Total number of capture groups including group 0.
unsigned int total_groups() const;
bool is_single_non_bmp_literal() const;
/// Find all non-overlapping matches. Returns number of matches found.
/// Results are written as (start, end) i32 pairs to the internal find_all buffer.

View file

@ -0,0 +1,14 @@
test("bare astral literals do not snap a global unicode search back to the pair start", () => {
let matcher = /😀/gu;
matcher.lastIndex = 1;
expect(matcher.exec("😀")).toBeNull();
expect(matcher.lastIndex).toBe(0);
matcher = /(?:😀)/gu;
matcher.lastIndex = 1;
let match = matcher.exec("😀");
expect(match).not.toBeNull();
expect(match[0]).toBe("😀");
expect(match.index).toBe(0);
expect(matcher.lastIndex).toBe(2);
});