From 08739235ac82fbbbb0de6d8d4ef74544e95f0491 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 8 Jul 2026 14:04:08 +0300 Subject: [PATCH] Fix sticky+global [Symbol.match] .NET fast path returning wrong results The sticky branch compared match.Index (a string position) against a match counter and set the array length to the counter after writing index counter, so: - "aaa".match(/a/gy) returned ["a","a"] instead of ["a","a","a"] (off-by-one truncation), and - "ababab".match(/ab/gy) returned ["ab"] instead of ["ab","ab","ab"] (any match wider than one character broke the position/counter comparison). Track the expected continuation position (previous match end, +1 for empty matches per AdvanceStringIndex - this fast path is never unicode) and set the array length to the number of matches written. Regression tests cover adjacent multi-char matches, gap termination, empty-match advancement and lastIndex reset, each against both the .NET fast path and the generic exec loop. Co-Authored-By: Claude Fable 5 --- Jint.Tests/Runtime/RegExpTests.cs | 44 +++++++++++++++++++++++++++ Jint/Native/RegExp/RegExpPrototype.cs | 19 ++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Jint.Tests/Runtime/RegExpTests.cs b/Jint.Tests/Runtime/RegExpTests.cs index 0efbd6901..1d923f2e7 100644 --- a/Jint.Tests/Runtime/RegExpTests.cs +++ b/Jint.Tests/Runtime/RegExpTests.cs @@ -34,6 +34,50 @@ public void MatchGlobalUnicodeEmptyMatchesAdvanceByCodePoint() Assert.Equal(3, result); } + [Theory] + [InlineData("gy")] + [InlineData("guy")] + public void MatchStickyGlobalCollectsAllAdjacentMatches(string flags) + { + // without 'u' exercises the .NET sticky fast path, with 'u' the generic exec loop + var engine = new Engine(); + var result = engine.Evaluate($"JSON.stringify(['aaa'.match(/a/{flags}), 'ababab'.match(/ab/{flags})])").AsString(); + + Assert.Equal("[[\"a\",\"a\",\"a\"],[\"ab\",\"ab\",\"ab\"]]", result); + } + + [Theory] + [InlineData("gy")] + [InlineData("guy")] + public void MatchStickyGlobalStopsAtFirstGap(string flags) + { + // matches after the gap exist but are not adjacent, so sticky matching must not include them + var engine = new Engine(); + var result = engine.Evaluate($"JSON.stringify(['aabaa'.match(/a/{flags}), 'baa'.match(/a/{flags})])").AsString(); + + Assert.Equal("[[\"a\",\"a\"],null]", result); + } + + [Theory] + [InlineData("gy")] + [InlineData("guy")] + public void MatchStickyGlobalAdvancesOverEmptyMatches(string flags) + { + var engine = new Engine(); + var result = engine.Evaluate($"JSON.stringify('aab'.match(/a*/{flags}))").AsString(); + + Assert.Equal("[\"aa\",\"\",\"\"]", result); + } + + [Fact] + public void MatchStickyGlobalResetsLastIndex() + { + var engine = new Engine(); + var result = engine.Evaluate("var r = /a/gy; r.lastIndex = 2; JSON.stringify(['aaa'.match(r), r.lastIndex])").AsString(); + + Assert.Equal("[[\"a\",\"a\",\"a\"],0]", result); + } + [Theory] [InlineData("")] [InlineData("u")] diff --git a/Jint/Native/RegExp/RegExpPrototype.cs b/Jint/Native/RegExp/RegExpPrototype.cs index 1f8aff847..50a647556 100644 --- a/Jint/Native/RegExp/RegExpPrototype.cs +++ b/Jint/Native/RegExp/RegExpPrototype.cs @@ -930,21 +930,28 @@ private JsValue Match(JsValue thisObject, JsValue stringArg) return Null; } - a.SetIndexValue(0, match.Value, updateLength: false); - uint li = 0; + uint matchCount = 0; while (true) { - if (li > 0 && li % ConstraintCheckInterval == 0) + a.SetIndexValue(matchCount, match.Value, updateLength: false); + matchCount++; + + if (matchCount % ConstraintCheckInterval == 0) { _engine.Constraints.Check(); } + // sticky continuation: the next match must start exactly where the previous one + // ended; an empty match advances by one (AdvanceStringIndex, never unicode here) + var expectedIndex = match.Length == 0 ? match.Index + 1 : match.Index + match.Length; match = match.NextMatch(); - if (!match.Success || match.Index != ++li) + if (!match.Success || match.Index != expectedIndex) + { break; - a.SetIndexValue(li, match.Value, updateLength: false); + } } - a.SetLength(li); + + a.SetLength(matchCount); return a; } else