LibRegex: Preserve captures when loops reject empty matches

RepeatMatcher retries a quantified atom with its own captures cleared,
but if an additional greedy iteration matches the empty string the
engine must fall back to the pre-iteration state. The fast VM path was
clearing capture registers after backtracking from ProgressCheck,
which meant the restored state from the previous successful iteration
was immediately wiped out.

That showed up with nested quantified captures like
"xyz123xyz".match(/((123)|(xyz)*)*/), where the final empty expansion
of the outer `*` discarded the last non-empty captures and returned
undefined for groups 1 and 4.

The same area also needs to track each zero-width-capable iteration's
start position explicitly. Initializing that state with ProgressCheck
stored the end of the previous repetition instead, which regressed
patterns like `/(a*)*/` by letting an empty iteration commit `""`
into the capture instead of falling back to the pre-iteration state
with an undefined capture.

Clear captures before backtracking from a rejected empty iteration,
and save iteration starts before entering quantified bodies so
ProgressCheck only decides whether that iteration made progress.

Add regressions for the reported nested quantified capture case and
for `/(a*)*/.exec("b")`, which should leave the capture undefined.
This commit is contained in:
Andreas Kling 2026-03-25 21:07:50 +01:00 committed by Ali Mohammad Pur
parent a08b334603
commit 4f6be8ab5d
4 changed files with 27 additions and 20 deletions

View file

@ -459,10 +459,7 @@ impl Compiler {
self.emit(Instruction::RepeatStart { counter_reg });
let body_start = self.current_offset();
if let Some(reg) = progress_reg {
self.emit(Instruction::ProgressCheck {
reg,
clear_captures: Self::capture_registers(atom),
});
self.emit(Instruction::Save(reg));
}
self.emit_clear_captures(atom);
self.compile_atom(atom);
@ -516,16 +513,11 @@ impl Compiler {
other: u32::MAX,
});
if let Some(reg) = progress_reg {
// First ProgressCheck initializes the register.
self.emit(Instruction::ProgressCheck {
reg,
clear_captures: Self::capture_registers(atom),
});
self.emit(Instruction::Save(reg));
}
self.emit_clear_captures(atom);
self.compile_atom(atom);
if let Some(reg) = progress_reg {
// Second ProgressCheck rejects zero-width matches.
self.emit(Instruction::ProgressCheck {
reg,
clear_captures: Self::capture_registers(atom),
@ -543,10 +535,7 @@ impl Compiler {
other: self.current_offset() + 1,
});
if let Some(reg) = progress_reg {
self.emit(Instruction::ProgressCheck {
reg,
clear_captures: Self::capture_registers(atom),
});
self.emit(Instruction::Save(reg));
}
self.emit_clear_captures(atom);
self.compile_atom(atom);
@ -579,6 +568,9 @@ impl Compiler {
prefer: self.current_offset() + 1,
other: u32::MAX,
});
if can_be_zero_width {
self.emit(Instruction::Save(progress_reg));
}
self.emit_clear_captures(atom);
self.compile_atom(atom);
if can_be_zero_width {
@ -598,6 +590,9 @@ impl Compiler {
prefer: u32::MAX,
other: self.current_offset() + 1,
});
if can_be_zero_width {
self.emit(Instruction::Save(progress_reg));
}
self.emit_clear_captures(atom);
self.compile_atom(atom);
if can_be_zero_width {

View file

@ -1418,17 +1418,15 @@ impl<'a, I: Input> Vm<'a, I> {
if last_pos == self.pos as i32 {
// Zero-width match detected. Per ECMA-262, captures from the
// body should be cleared to undefined before exiting.
if !self.backtrack() {
return VmResult::NoMatch;
}
// Clear captures AFTER backtrack, since backtrack restores
// registers from a snapshot that may contain stale captures.
for &cap_reg in clear_captures {
let r = cap_reg as usize;
if r < self.registers.len() {
self.registers[r] = -1;
}
}
if !self.backtrack() {
return VmResult::NoMatch;
}
} else {
self.registers[reg] = self.pos as i32;
self.pc += 1;
@ -1648,7 +1646,8 @@ impl<'a, I: Input> Vm<'a, I> {
let mut reg = *reg as usize;
// In backward mode, swap start/end registers for capture groups
// since we're traversing right-to-left.
if self.backward {
let capture_register_count = (self.program.capture_count as usize + 1) * 2;
if self.backward && reg < capture_register_count {
if reg.is_multiple_of(2) {
reg += 1; // start → end
} else {

View file

@ -146,6 +146,15 @@ test("lazy quantified capture restores the previous iteration when backtracking"
expect(res.index).toBe(0);
});
test("zero-width quantified captures fall back to the pre-iteration state", () => {
let res = /(a*)*/.exec("b");
expect(res.length).toBe(2);
expect(res[0]).toBe("");
expect(res[1]).toBeUndefined();
expect(res.index).toBe(0);
});
// #6256
test("empty character class semantics", () => {
// Should not match zero-length strings.

View file

@ -83,6 +83,10 @@ test("global match with many empty matches", () => {
}
});
test("nested quantified captures keep the last non-empty iteration", () => {
expect("xyz123xyz".match(/((123)|(xyz)*)*/)).toEqual(["xyz123xyz", "xyz", undefined, "xyz"]);
});
test("sticky and global flag set", () => {
const string = "aaba";
expect(string.match(/a/)).toEqual(["a"]);