From 41dedc51b45a53ec257ef0c8890d194b8d226da4 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 10 May 2026 14:55:21 +0100 Subject: [PATCH 1/4] =?UTF-8?q?Implement=20IsRegExp=20check=20via=20Symbol?= =?UTF-8?q?.match=20(ES2026=20=C2=A77.2.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace IsRegExpValue with two distinct operations: IsRegExpInstance (brand check for RegExp prototype methods) and IsRegExp (Symbol.match lookup for the RegExp constructor and String.prototype guards). The RegExp constructor now reads .source/.flags from any object with a truthy Symbol.match, and String.prototype.{startsWith,endsWith,includes} reject regexp-like arguments via the same check. The replaceAll/matchAll global-flag guards now read the flags property per spec instead of the .global accessor. Closes #610 Co-Authored-By: Claude Opus 4.6 (1M context) --- source/units/Goccia.Builtins.GlobalRegExp.pas | 70 +++++++++++-------- .../units/Goccia.Builtins.TestingLibrary.pas | 4 +- source/units/Goccia.Error.Messages.pas | 1 + source/units/Goccia.Error.Suggestions.pas | 1 + source/units/Goccia.REPL.Formatter.pas | 2 +- source/units/Goccia.RegExp.Runtime.pas | 18 ++++- .../units/Goccia.Values.StringObjectValue.pas | 70 ++++++++++--------- tests/built-ins/RegExp/constructor.js | 60 ++++++++++++++++ tests/built-ins/String/prototype/endsWith.js | 12 ++++ tests/built-ins/String/prototype/includes.js | 12 ++++ tests/built-ins/String/prototype/matchAll.js | 15 ++++ .../built-ins/String/prototype/replaceAll.js | 15 ++++ .../built-ins/String/prototype/startsWith.js | 12 ++++ 13 files changed, 226 insertions(+), 66 deletions(-) diff --git a/source/units/Goccia.Builtins.GlobalRegExp.pas b/source/units/Goccia.Builtins.GlobalRegExp.pas index dd423594..87d2f700 100644 --- a/source/units/Goccia.Builtins.GlobalRegExp.pas +++ b/source/units/Goccia.Builtins.GlobalRegExp.pas @@ -397,7 +397,7 @@ constructor TGocciaGlobalRegExp.Create(const AName: string; function RequireRegExpThis(const AThisValue: TGocciaValue; const AMethodName: string): TGocciaObjectValue; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(AMethodName + ' requires a RegExp object'); Result := TGocciaObjectValue(AThisValue); end; @@ -685,9 +685,9 @@ function TGocciaGlobalRegExp.RegExpConstructorFn( const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; var - PatternArg: TGocciaValue; + PatternArg, PropVal: TGocciaValue; Pattern, Flags: string; - IsConstructCall: Boolean; + IsConstructCall, PatternIsRegExp: Boolean; begin Pattern := ''; Flags := ''; @@ -696,21 +696,29 @@ function TGocciaGlobalRegExp.RegExpConstructorFn( if AArgs.Length > 0 then begin PatternArg := AArgs.GetElement(0); - if IsRegExpValue(PatternArg) then - begin - if not IsConstructCall and - ((AArgs.Length <= 1) or - (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue)) then - Exit(PatternArg); + PatternIsRegExp := IsRegExp(PatternArg); + + // §22.2.3.1 step 2b: non-construct, regexp-like, no flags → return as-is + if PatternIsRegExp and not IsConstructCall and + ((AArgs.Length <= 1) or + (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue)) then + Exit(PatternArg); - Pattern := TGocciaObjectValue(PatternArg).GetProperty(PROP_SOURCE) - .ToStringLiteral.Value; + // §22.2.3.1 steps 3–4: read source/flags when regexp-like + if PatternIsRegExp then + begin + PropVal := TGocciaObjectValue(PatternArg).GetProperty(PROP_SOURCE); + if not (PropVal is TGocciaUndefinedLiteralValue) then + Pattern := PropVal.ToStringLiteral.Value; if (AArgs.Length > 1) and not (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue) then Flags := AArgs.GetElement(1).ToStringLiteral.Value else - Flags := TGocciaObjectValue(PatternArg).GetProperty(PROP_FLAGS) - .ToStringLiteral.Value; + begin + PropVal := TGocciaObjectValue(PatternArg).GetProperty(PROP_FLAGS); + if not (PropVal is TGocciaUndefinedLiteralValue) then + Flags := PropVal.ToStringLiteral.Value; + end; end else begin @@ -729,7 +737,7 @@ function TGocciaGlobalRegExp.RegExpConstruct( const ANewTarget: TGocciaValue): TGocciaValue; var Proto: TGocciaObjectValue; - PatternArg: TGocciaValue; + PatternArg, PropVal: TGocciaValue; Pattern, Flags: string; PatternIsRegExp, FlagsProvided: Boolean; begin @@ -739,18 +747,22 @@ function TGocciaGlobalRegExp.RegExpConstruct( FlagsProvided := (AArgs.Length > 1) and not (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue); - // §22.2.4.1 step 3: if pattern is a RegExp, capture source before step 6 + // §22.2.4.1 steps 1, 3–4: IsRegExp check, then read source/flags if regexp-like if AArgs.Length > 0 then begin PatternArg := AArgs.GetElement(0); - if IsRegExpValue(PatternArg) then + PatternIsRegExp := IsRegExp(PatternArg); + if PatternIsRegExp then begin - PatternIsRegExp := True; - Pattern := TGocciaObjectValue(PatternArg).GetProperty(PROP_SOURCE) - .ToStringLiteral.Value; + PropVal := TGocciaObjectValue(PatternArg).GetProperty(PROP_SOURCE); + if not (PropVal is TGocciaUndefinedLiteralValue) then + Pattern := PropVal.ToStringLiteral.Value; if not FlagsProvided then - Flags := TGocciaObjectValue(PatternArg).GetProperty(PROP_FLAGS) - .ToStringLiteral.Value; + begin + PropVal := TGocciaObjectValue(PatternArg).GetProperty(PROP_FLAGS); + if not (PropVal is TGocciaUndefinedLiteralValue) then + Flags := PropVal.ToStringLiteral.Value; + end; end; end; @@ -774,7 +786,7 @@ function TGocciaGlobalRegExp.RegExpExec(const AArgs: TGocciaArgumentsCollection; Input: string; MatchValue: TGocciaValue; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpExecNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -795,7 +807,7 @@ function TGocciaGlobalRegExp.RegExpTest(const AArgs: TGocciaArgumentsCollection; Input: string; MatchValue: TGocciaValue; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpTestNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -812,7 +824,7 @@ function TGocciaGlobalRegExp.RegExpToStringMethod( const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpToStringNonRegExp, SSuggestRegExpThisType); Result := TGocciaStringLiteralValue.Create(RegExpObjectToString(AThisValue)); @@ -829,7 +841,7 @@ function TGocciaGlobalRegExp.RegExpSymbolMatch( ResultArray: TGocciaArrayValue; MatchIndex, MatchEnd, NextIndex: Integer; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpMatchNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -869,7 +881,7 @@ function TGocciaGlobalRegExp.RegExpSymbolMatchAll( RegexClone: TGocciaObjectValue; IsGlobal: Boolean; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpMatchAllNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -896,7 +908,7 @@ function TGocciaGlobalRegExp.RegExpSymbolReplace( MatchArray: TGocciaObjectValue; MatchIndex, MatchEnd, NextIndex, SearchIndex, OutputIndex: Integer; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpReplaceNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -961,7 +973,7 @@ function TGocciaGlobalRegExp.RegExpSymbolSearch( MatchArray: TGocciaObjectValue; MatchIndex, MatchEnd, NextIndex: Integer; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpSearchNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then @@ -992,7 +1004,7 @@ function TGocciaGlobalRegExp.RegExpSymbolSplit( LastMatchWasZeroWidth: Boolean; I: Integer; begin - if not IsRegExpValue(AThisValue) then + if not IsRegExpInstance(AThisValue) then ThrowTypeError(SErrorRegExpSplitNonRegExp, SSuggestRegExpThisType); if AArgs.Length > 0 then diff --git a/source/units/Goccia.Builtins.TestingLibrary.pas b/source/units/Goccia.Builtins.TestingLibrary.pas index 0bd6e90f..c242e397 100644 --- a/source/units/Goccia.Builtins.TestingLibrary.pas +++ b/source/units/Goccia.Builtins.TestingLibrary.pas @@ -902,7 +902,7 @@ function TGocciaExpectationValue.ToMatch(const AArgs: TGocciaArgumentsCollection end; Expected := AArgs.GetElement(0); - if not (Expected is TGocciaStringLiteralValue) and not IsRegExpValue(Expected) then + if not (Expected is TGocciaStringLiteralValue) and not IsRegExpInstance(Expected) then begin if FIsNegated then TGocciaTestAssertions(FTestAssertions).AssertionPassed('toMatch') @@ -915,7 +915,7 @@ function TGocciaExpectationValue.ToMatch(const AArgs: TGocciaArgumentsCollection end; ActualString := FormatForDisplay(FActualValue); - if IsRegExpValue(Expected) then + if IsRegExpInstance(Expected) then begin ExpectedDescription := RegExpObjectToString(Expected); Matches := MatchRegExpObject(Expected, ActualString, 0, False, False, diff --git a/source/units/Goccia.Error.Messages.pas b/source/units/Goccia.Error.Messages.pas index 97420322..cc9e2da9 100644 --- a/source/units/Goccia.Error.Messages.pas +++ b/source/units/Goccia.Error.Messages.pas @@ -396,6 +396,7 @@ interface SErrorMatchAllRequiresGlobalRegExp = 'String.prototype.matchAll requires a global RegExp'; SErrorSymbolMatchAllNotCallable = '@@matchAll is not callable'; SErrorSymbolSearchNotCallable = '@@search is not callable'; + SErrorFirstArgMustNotBeRegExp = 'First argument to %s must not be a regular expression'; SErrorInvalidRepeatCount = 'Invalid count value: %s'; SErrorInvalidNormalizationForm = 'The normalization form should be one of NFC, NFD, NFKC, NFKD'; SErrorStringPrototypeRequiresNonNullish = 'String.prototype method requires that ''this'' not be null or undefined'; diff --git a/source/units/Goccia.Error.Suggestions.pas b/source/units/Goccia.Error.Suggestions.pas index f753c6f6..9538fa32 100644 --- a/source/units/Goccia.Error.Suggestions.pas +++ b/source/units/Goccia.Error.Suggestions.pas @@ -225,6 +225,7 @@ interface // Runtime errors — string SSuggestWellKnownSymbolCallable = 'the well-known Symbol method must be a function on the object'; SSuggestReplaceAllGlobalFlag = 'add the ''g'' flag to the RegExp: /pattern/g'; + SSuggestUseMatchOrSearch = 'use String.prototype.match or String.prototype.search instead'; SSuggestRepeatCountRange = 'the count must be a non-negative finite integer'; SSuggestNormalizationForm = 'valid forms are NFC, NFD, NFKC, or NFKD'; diff --git a/source/units/Goccia.REPL.Formatter.pas b/source/units/Goccia.REPL.Formatter.pas index 044a54cb..eec20c0a 100644 --- a/source/units/Goccia.REPL.Formatter.pas +++ b/source/units/Goccia.REPL.Formatter.pas @@ -202,7 +202,7 @@ function FormatREPLValue(const AValue: TGocciaValue; end; // RegExp — JSON.stringify would show empty object - if IsRegExpValue(AValue) then + if IsRegExpInstance(AValue) then Exit(Colorize(RegExpObjectToString(AValue), ANSI_RED, AUseColor)); // Maps — JSON.stringify can't see internal entries diff --git a/source/units/Goccia.RegExp.Runtime.pas b/source/units/Goccia.RegExp.Runtime.pas index 29d3f48d..1c4ffc7e 100644 --- a/source/units/Goccia.RegExp.Runtime.pas +++ b/source/units/Goccia.RegExp.Runtime.pas @@ -11,7 +11,8 @@ interface function GetRegExpPrototype: TGocciaValue; procedure SetRegExpPrototype(const APrototype: TGocciaValue); -function IsRegExpValue(const AValue: TGocciaValue): Boolean; +function IsRegExpInstance(const AValue: TGocciaValue): Boolean; +function IsRegExp(const AValue: TGocciaValue): Boolean; function CreateRegExpObject(const APattern, AFlags: string): TGocciaValue; function CloneRegExpObject(const AValue: TGocciaValue): TGocciaValue; function MatchRegExpObjectOnce(const AValue: TGocciaValue; const AInput: string; @@ -117,7 +118,7 @@ function BuildMatchArray(const AInput: string; Result := MatchArray; end; -function IsRegExpValue(const AValue: TGocciaValue): Boolean; +function IsRegExpInstance(const AValue: TGocciaValue): Boolean; begin if not (AValue is TGocciaObjectValue) then Exit(False); @@ -125,6 +126,19 @@ function IsRegExpValue(const AValue: TGocciaValue): Boolean; TGocciaObjectValue(AValue).HasOwnProperty(PROP_FLAGS); end; +function IsRegExp(const AValue: TGocciaValue): Boolean; +var + Matcher: TGocciaValue; +begin + if not (AValue is TGocciaObjectValue) then + Exit(False); + Matcher := TGocciaObjectValue(AValue).GetSymbolProperty( + TGocciaSymbolValue.WellKnownMatch); + if not (Matcher is TGocciaUndefinedLiteralValue) then + Exit(Matcher.ToBooleanLiteral.Value); + Result := IsRegExpInstance(AValue); +end; + function CreateRegExpObject(const APattern, AFlags: string): TGocciaValue; var Obj: TGocciaObjectValue; diff --git a/source/units/Goccia.Values.StringObjectValue.pas b/source/units/Goccia.Values.StringObjectValue.pas index 275e3840..b7931f6c 100644 --- a/source/units/Goccia.Values.StringObjectValue.pas +++ b/source/units/Goccia.Values.StringObjectValue.pas @@ -128,7 +128,7 @@ function CoerceRegExpValue(const AValue: TGocciaValue; var Pattern: string; begin - if IsRegExpValue(AValue) then + if IsRegExpInstance(AValue) then Result := TGocciaObjectValue(CloneRegExpObject(AValue)) else begin @@ -827,11 +827,15 @@ function TGocciaStringObjectValue.StringIncludes(const AArgs: TGocciaArgumentsCo FoundIndex: Integer; Len: Integer; begin - // Step 1: Let O be RequireObjectCoercible(this value) - // Step 2: Let S be ToString(O) + // Step 1–2: Let S be ToString(RequireObjectCoercible(this)) StringValue := ExtractStringValue(AThisValue); - // Step 3: Let searchStr be ToString(searchString) + // §22.1.3.8 steps 3–4: throw if searchString is regexp-like + if (AArgs.Length > 0) and IsRegExp(AArgs.GetElement(0)) then + ThrowTypeError(Format(SErrorFirstArgMustNotBeRegExp, + ['String.prototype.includes']), SSuggestUseMatchOrSearch); + + // Step 5: Let searchStr be ToString(searchString) if AArgs.Length > 0 then SearchValue := AArgs.GetElement(0).ToStringLiteral.Value else @@ -875,23 +879,25 @@ function TGocciaStringObjectValue.StringStartsWith(const AArgs: TGocciaArguments StartPosition: Integer; Len: Integer; begin - // Step 1: Let O be RequireObjectCoercible(this value) - // Step 2: Let S be ToString(O) + // Step 1–2: Let S be ToString(RequireObjectCoercible(this)) StringValue := ExtractStringValue(AThisValue); - // Step 3: Let searchStr be ToString(searchString) + // §22.1.3.24 steps 3–4: throw if searchString is regexp-like + if (AArgs.Length > 0) and IsRegExp(AArgs.GetElement(0)) then + ThrowTypeError(Format(SErrorFirstArgMustNotBeRegExp, + ['String.prototype.startsWith']), SSuggestUseMatchOrSearch); + + // Step 5: Let searchStr be ToString(searchString) if AArgs.Length > 0 then SearchValue := AArgs.GetElement(0).ToStringLiteral.Value else SearchValue := 'undefined'; - // Step 4: Let searchLength be the length of searchStr - // Step 5: Let start be min(max(ToIntegerOrInfinity(position), 0), len) + // Step 6: Let start be min(max(ToIntegerOrInfinity(position), 0), len) Len := UTF16CodeUnitLength(StringValue); StartPosition := Min(Max(0, ToIntegerFromArgs(AArgs, 1)), Len); - // Step 6: If searchLength + start > len(S), return false - // Step 7: If the code units of S starting at start match searchStr, return true; else false + // Step 7–8: If searchLength + start > len(S), return false; else compare if StartPosition + UTF16CodeUnitLength(SearchValue) > Len then Result := TGocciaBooleanLiteralValue.FalseValue else if UTF16IndexOf(StringValue, SearchValue, StartPosition) = StartPosition then @@ -908,19 +914,23 @@ function TGocciaStringObjectValue.StringEndsWith(const AArgs: TGocciaArgumentsCo SearchLength: Integer; StartPosition: Integer; begin - // Step 1: Let O be RequireObjectCoercible(this value) - // Step 2: Let S be ToString(O) + // Step 1–2: Let S be ToString(RequireObjectCoercible(this)) StringValue := ExtractStringValue(AThisValue); - // Step 3: Let searchStr be ToString(searchString) + // §22.1.3.6 steps 3–4: throw if searchString is regexp-like + if (AArgs.Length > 0) and IsRegExp(AArgs.GetElement(0)) then + ThrowTypeError(Format(SErrorFirstArgMustNotBeRegExp, + ['String.prototype.endsWith']), SSuggestUseMatchOrSearch); + + // Step 5: Let searchStr be ToString(searchString) if AArgs.Length > 0 then SearchValue := AArgs.GetElement(0).ToStringLiteral.Value else SearchValue := 'undefined'; - // Step 4: Let searchLength be the length of searchStr - // Step 5: If endPosition is undefined, let pos be len; else let pos be ToIntegerOrInfinity(endPosition) - // Step 6: Let end be min(max(pos, 0), len) + // Step 6: Let searchLength be the length of searchStr + // Step 7: If endPosition is undefined, let pos be len; else let pos be ToIntegerOrInfinity(endPosition) + // Step 8: Let end be min(max(pos, 0), len) EndPosition := Min(Max(0, ToIntegerFromArgs(AArgs, 1, UTF16CodeUnitLength(StringValue))), UTF16CodeUnitLength(StringValue)); @@ -1076,7 +1086,7 @@ function TGocciaStringObjectValue.StringReplaceMethod(const AArgs: TGocciaArgume // Step 3: Let string be ? ToString(O). StringValue := ExtractStringValue(AThisValue); - if IsRegExpValue(SearchArg) then + if IsRegExpInstance(SearchArg) then begin RegexValue := CoerceRegExpValue(SearchArg); TGarbageCollector.Instance.AddTempRoot(RegexValue); @@ -1196,15 +1206,10 @@ function TGocciaStringObjectValue.StringReplaceAllMethod(const AArgs: TGocciaArg else ReplaceArg := TGocciaUndefinedLiteralValue.UndefinedValue; - // Step 2 (spec): If searchValue is neither undefined nor null, invoke - // GetMethod(searchValue, @@replace) and dispatch through it BEFORE doing - // any ToString on `O` or the args. ES2026 §22.1.3.20 step 2.a.iii: the - // RegExp-flags global check uses Get + ToString on searchValue.flags - // only when searchValue is itself a RegExp; for arbitrary user-supplied - // searchValues with @@replace, the replacer call is the only observable - // side effect. - if IsRegExpValue(SearchArg) and - not GetRegExpBooleanProperty(TGocciaObjectValue(SearchArg), PROP_GLOBAL) then + // §22.1.3.20 step 2a–b: IsRegExp check; if regexp-like without 'g' flag, throw + if IsRegExp(SearchArg) and + (Pos('g', TGocciaObjectValue(SearchArg).GetProperty(PROP_FLAGS) + .ToStringLiteral.Value) = 0) then ThrowTypeError(SErrorReplaceAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); ReplaceMethod := GetMethodBySymbol(SearchArg, @@ -1231,7 +1236,7 @@ function TGocciaStringObjectValue.StringReplaceAllMethod(const AArgs: TGocciaArg // Step 3 (spec): Let string be ? ToString(O) StringValue := ExtractStringValue(AThisValue); - if IsRegExpValue(SearchArg) then + if IsRegExpInstance(SearchArg) then begin RegexValue := CoerceRegExpValue(SearchArg); TGarbageCollector.Instance.AddTempRoot(RegexValue); @@ -1452,7 +1457,7 @@ function TGocciaStringObjectValue.StringSplit(const AArgs: TGocciaArgumentsColle // Step 3 (spec): Let S be ? ToString(O). Only reached when no @@split. StringValue := ExtractStringValue(AThisValue); - if IsRegExpValue(SeparatorArg) then + if IsRegExpInstance(SeparatorArg) then begin RegexValue := CoerceRegExpValue(SeparatorArg); TGarbageCollector.Instance.AddTempRoot(RegexValue); @@ -1660,9 +1665,10 @@ function TGocciaStringObjectValue.StringMatchAll(const AArgs: TGocciaArgumentsCo (AThisValue is TGocciaNullLiteralValue) then ThrowTypeError(SErrorStringPrototypeRequiresNonNullish, SSuggestCheckNullBeforeAccess); - if (AArgs.Length > 0) and IsRegExpValue(AArgs.GetElement(0)) and - not GetRegExpBooleanProperty(TGocciaObjectValue(AArgs.GetElement(0)), - PROP_GLOBAL) then + // §22.1.3.13 step 2a–b: IsRegExp check; if regexp-like without 'g' flag, throw + if (AArgs.Length > 0) and IsRegExp(AArgs.GetElement(0)) and + (Pos('g', TGocciaObjectValue(AArgs.GetElement(0)).GetProperty(PROP_FLAGS) + .ToStringLiteral.Value) = 0) then ThrowTypeError(SErrorMatchAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); if AArgs.Length > 0 then diff --git a/tests/built-ins/RegExp/constructor.js b/tests/built-ins/RegExp/constructor.js index 392fb7b7..b18322ee 100644 --- a/tests/built-ins/RegExp/constructor.js +++ b/tests/built-ins/RegExp/constructor.js @@ -134,3 +134,63 @@ test("test on Object.create(RegExp.prototype) throws TypeError", () => { const obj = Object.create(RegExp.prototype); expect(() => { RegExp.prototype.test.call(obj, "test"); }).toThrow(TypeError); }); + +// --- IsRegExp via Symbol.match --- + +test("new RegExp reads source/flags from object with Symbol.match truthy", () => { + const obj = { source: "abc", flags: "g", [Symbol.match]: true }; + const r = new RegExp(obj); + expect(r.source).toBe("abc"); + expect(r.flags).toBe("g"); + expect(r.toString()).toBe("/abc/g"); +}); + +test("new RegExp uses empty strings when source/flags are absent on Symbol.match object", () => { + const obj = { [Symbol.match]: true }; + const r = new RegExp(obj); + expect(r.source).toBe("(?:)"); + expect(r.flags).toBe(""); +}); + +test("new RegExp stringifies object when Symbol.match is false", () => { + const obj = { source: "abc", flags: "", [Symbol.match]: false }; + const r = new RegExp(obj); + expect(r.source).toBe("[object Object]"); +}); + +test("RegExp() without new returns Symbol.match object as-is when flags omitted", () => { + const obj = { source: "abc", flags: "", [Symbol.match]: true }; + const result = RegExp(obj); + expect(result).toBe(obj); +}); + +test("new RegExp overrides flags from Symbol.match object when second arg provided", () => { + const obj = { source: "abc", flags: "g", [Symbol.match]: true }; + const r = new RegExp(obj, "i"); + expect(r.source).toBe("abc"); + expect(r.flags).toBe("i"); +}); + +test("new RegExp treats Symbol.match with truthy non-boolean as regexp-like", () => { + const obj = { source: "x", flags: "", [Symbol.match]: 1 }; + const r = new RegExp(obj); + expect(r.source).toBe("x"); +}); + +test("new RegExp stringifies object when Symbol.match is null", () => { + const obj = { source: "abc", flags: "", [Symbol.match]: null }; + const r = new RegExp(obj); + expect(r.source).toBe("[object Object]"); +}); + +test("new RegExp stringifies object when Symbol.match is 0", () => { + const obj = { source: "abc", flags: "", [Symbol.match]: 0 }; + const r = new RegExp(obj); + expect(r.source).toBe("[object Object]"); +}); + +test("new RegExp stringifies object when Symbol.match is empty string", () => { + const obj = { source: "abc", flags: "", [Symbol.match]: "" }; + const r = new RegExp(obj); + expect(r.source).toBe("[object Object]"); +}); diff --git a/tests/built-ins/String/prototype/endsWith.js b/tests/built-ins/String/prototype/endsWith.js index 9d72e5a5..e5f15ded 100644 --- a/tests/built-ins/String/prototype/endsWith.js +++ b/tests/built-ins/String/prototype/endsWith.js @@ -30,4 +30,16 @@ describe("String.prototype.endsWith", () => { test("entire string match", () => { expect("hello".endsWith("hello")).toBe(true); }); + + test("throws TypeError for regexp argument", () => { + expect(() => { "hello".endsWith(/lo/); }).toThrow(TypeError); + }); + + test("throws TypeError for object with Symbol.match truthy", () => { + expect(() => { "hello".endsWith({ [Symbol.match]: true }); }).toThrow(TypeError); + }); + + test("allows object with Symbol.match false", () => { + expect("hello".endsWith({ [Symbol.match]: false })).toBe(false); + }); }); diff --git a/tests/built-ins/String/prototype/includes.js b/tests/built-ins/String/prototype/includes.js index 583532de..a27aa532 100644 --- a/tests/built-ins/String/prototype/includes.js +++ b/tests/built-ins/String/prototype/includes.js @@ -38,4 +38,16 @@ describe("String.prototype.includes", () => { expect(String.prototype.includes.name).toBe("includes"); expect(String.prototype.includes.length).toBe(1); }); + + test("throws TypeError for regexp argument", () => { + expect(() => { "hello".includes(/ell/); }).toThrow(TypeError); + }); + + test("throws TypeError for object with Symbol.match truthy", () => { + expect(() => { "hello".includes({ [Symbol.match]: true }); }).toThrow(TypeError); + }); + + test("allows object with Symbol.match false", () => { + expect("hello".includes({ [Symbol.match]: false })).toBe(false); + }); }); diff --git a/tests/built-ins/String/prototype/matchAll.js b/tests/built-ins/String/prototype/matchAll.js index 11570ce3..9a3e4366 100644 --- a/tests/built-ins/String/prototype/matchAll.js +++ b/tests/built-ins/String/prototype/matchAll.js @@ -149,3 +149,18 @@ test("matchAll throws TypeError when called on null or undefined", () => { expect(() => "".matchAll.call(null, /x/g)).toThrow(TypeError); expect(() => "".matchAll.call(undefined, /x/g)).toThrow(TypeError); }); + +test("matchAll rejects Symbol.match truthy object without global flag", () => { + expect(() => { + "abc".matchAll({ [Symbol.match]: true, flags: "" }); + }).toThrow(TypeError); +}); + +test("matchAll accepts Symbol.match truthy object with global flag", () => { + const obj = { + flags: "g", + [Symbol.match]: true, + [Symbol.matchAll](str) { return ["ok"]; }, + }; + expect("abc".matchAll(obj)).toEqual(["ok"]); +}); diff --git a/tests/built-ins/String/prototype/replaceAll.js b/tests/built-ins/String/prototype/replaceAll.js index 3db7dfdd..87de1b6e 100644 --- a/tests/built-ins/String/prototype/replaceAll.js +++ b/tests/built-ins/String/prototype/replaceAll.js @@ -116,4 +116,19 @@ describe('String.prototype.replaceAll', () => { expect(() => ''.replaceAll.call(null, /x/g, 'y')).toThrow(TypeError); expect(() => ''.replaceAll.call(undefined, /x/g, 'y')).toThrow(TypeError); }); + + test('rejects Symbol.match truthy object without global flag', () => { + expect(() => { + 'abc'.replaceAll({ [Symbol.match]: true, flags: '' }, 'x'); + }).toThrow(TypeError); + }); + + test('accepts Symbol.match truthy object with global flag', () => { + const obj = { + flags: 'g', + [Symbol.match]: true, + [Symbol.replace](str, rep) { return 'ok'; }, + }; + expect('abc'.replaceAll(obj, 'x')).toBe('ok'); + }); }); diff --git a/tests/built-ins/String/prototype/startsWith.js b/tests/built-ins/String/prototype/startsWith.js index b03d09dd..57ac349e 100644 --- a/tests/built-ins/String/prototype/startsWith.js +++ b/tests/built-ins/String/prototype/startsWith.js @@ -26,4 +26,16 @@ describe("String.prototype.startsWith", () => { test("case sensitive", () => { expect("Hello".startsWith("hello")).toBe(false); }); + + test("throws TypeError for regexp argument", () => { + expect(() => { "hello".startsWith(/hel/); }).toThrow(TypeError); + }); + + test("throws TypeError for object with Symbol.match truthy", () => { + expect(() => { "hello".startsWith({ [Symbol.match]: true }); }).toThrow(TypeError); + }); + + test("allows object with Symbol.match false", () => { + expect("hello".startsWith({ [Symbol.match]: false })).toBe(false); + }); }); From ab1f257c2f819ff4ec217301f4472da325bf6180 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 10 May 2026 15:11:20 +0100 Subject: [PATCH 2/4] Address review: constructor identity, RequireObjectCoercible, matchAll cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RegExpConstructorFn step 2b now checks pattern.constructor === RegExp before returning pattern as-is, per §22.2.3.1 step 2.b.i–ii. - replaceAll/matchAll flag checks now throw TypeError via RequireObjectCoercible(flags) when flags is null/undefined, before the 'g' check, per §22.1.3.20 step 2.b.ii. - matchAll caches AArgs.GetElement(0) in a local variable instead of re-evaluating it multiple times. Co-Authored-By: Claude Opus 4.6 (1M context) --- source/units/Goccia.Builtins.GlobalRegExp.pas | 6 ++- .../units/Goccia.Values.StringObjectValue.pas | 43 ++++++++++++------- tests/built-ins/RegExp/constructor.js | 11 ++++- tests/built-ins/String/prototype/matchAll.js | 12 ++++++ .../built-ins/String/prototype/replaceAll.js | 12 ++++++ 5 files changed, 64 insertions(+), 20 deletions(-) diff --git a/source/units/Goccia.Builtins.GlobalRegExp.pas b/source/units/Goccia.Builtins.GlobalRegExp.pas index 87d2f700..9699c697 100644 --- a/source/units/Goccia.Builtins.GlobalRegExp.pas +++ b/source/units/Goccia.Builtins.GlobalRegExp.pas @@ -698,10 +698,12 @@ function TGocciaGlobalRegExp.RegExpConstructorFn( PatternArg := AArgs.GetElement(0); PatternIsRegExp := IsRegExp(PatternArg); - // §22.2.3.1 step 2b: non-construct, regexp-like, no flags → return as-is + // §22.2.3.1 step 2b: non-construct, regexp-like, no flags, same constructor if PatternIsRegExp and not IsConstructCall and ((AArgs.Length <= 1) or - (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue)) then + (AArgs.GetElement(1) is TGocciaUndefinedLiteralValue)) and + (TGocciaObjectValue(PatternArg).GetProperty(PROP_CONSTRUCTOR) = + FRegExpConstructor) then Exit(PatternArg); // §22.2.3.1 steps 3–4: read source/flags when regexp-like diff --git a/source/units/Goccia.Values.StringObjectValue.pas b/source/units/Goccia.Values.StringObjectValue.pas index b7931f6c..a7cbd5aa 100644 --- a/source/units/Goccia.Values.StringObjectValue.pas +++ b/source/units/Goccia.Values.StringObjectValue.pas @@ -1181,7 +1181,7 @@ function TGocciaStringObjectValue.StringReplaceMethod(const AArgs: TGocciaArgume function TGocciaStringObjectValue.StringReplaceAllMethod(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; var StringValue, SearchValue, ReplaceValue, ResultStr: string; - SearchArg: TGocciaValue; + SearchArg, FlagsVal: TGocciaValue; ReplaceArg: TGocciaValue; CallArgs: TGocciaArgumentsCollection; CallResult: TGocciaValue; @@ -1206,11 +1206,16 @@ function TGocciaStringObjectValue.StringReplaceAllMethod(const AArgs: TGocciaArg else ReplaceArg := TGocciaUndefinedLiteralValue.UndefinedValue; - // §22.1.3.20 step 2a–b: IsRegExp check; if regexp-like without 'g' flag, throw - if IsRegExp(SearchArg) and - (Pos('g', TGocciaObjectValue(SearchArg).GetProperty(PROP_FLAGS) - .ToStringLiteral.Value) = 0) then - ThrowTypeError(SErrorReplaceAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); + // §22.1.3.20 step 2a–b: IsRegExp check; RequireObjectCoercible(flags); 'g' check + if IsRegExp(SearchArg) then + begin + FlagsVal := TGocciaObjectValue(SearchArg).GetProperty(PROP_FLAGS); + if (FlagsVal is TGocciaUndefinedLiteralValue) or + (FlagsVal is TGocciaNullLiteralValue) then + ThrowTypeError(SErrorCannotConvertNullOrUndefined, SSuggestObjectArgType); + if Pos('g', FlagsVal.ToStringLiteral.Value) = 0 then + ThrowTypeError(SErrorReplaceAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); + end; ReplaceMethod := GetMethodBySymbol(SearchArg, TGocciaSymbolValue.WellKnownReplace); @@ -1651,7 +1656,7 @@ function TGocciaStringObjectValue.StringMatch(const AArgs: TGocciaArgumentsColle function TGocciaStringObjectValue.StringMatchAll(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; var StringValue: string; - MatchAllMethod: TGocciaValue; + RegExpArg, FlagsVal, MatchAllMethod: TGocciaValue; CallArgs: TGocciaArgumentsCollection; RegexValue: TGocciaObjectValue; begin @@ -1665,15 +1670,21 @@ function TGocciaStringObjectValue.StringMatchAll(const AArgs: TGocciaArgumentsCo (AThisValue is TGocciaNullLiteralValue) then ThrowTypeError(SErrorStringPrototypeRequiresNonNullish, SSuggestCheckNullBeforeAccess); - // §22.1.3.13 step 2a–b: IsRegExp check; if regexp-like without 'g' flag, throw - if (AArgs.Length > 0) and IsRegExp(AArgs.GetElement(0)) and - (Pos('g', TGocciaObjectValue(AArgs.GetElement(0)).GetProperty(PROP_FLAGS) - .ToStringLiteral.Value) = 0) then - ThrowTypeError(SErrorMatchAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); - + // §22.1.3.13 step 2a–b: IsRegExp check; RequireObjectCoercible(flags); 'g' check if AArgs.Length > 0 then begin - MatchAllMethod := GetMethodBySymbol(AArgs.GetElement(0), + RegExpArg := AArgs.GetElement(0); + if IsRegExp(RegExpArg) then + begin + FlagsVal := TGocciaObjectValue(RegExpArg).GetProperty(PROP_FLAGS); + if (FlagsVal is TGocciaUndefinedLiteralValue) or + (FlagsVal is TGocciaNullLiteralValue) then + ThrowTypeError(SErrorCannotConvertNullOrUndefined, SSuggestObjectArgType); + if Pos('g', FlagsVal.ToStringLiteral.Value) = 0 then + ThrowTypeError(SErrorMatchAllRequiresGlobalRegExp, SSuggestReplaceAllGlobalFlag); + end; + + MatchAllMethod := GetMethodBySymbol(RegExpArg, TGocciaSymbolValue.WellKnownMatchAll); if not (MatchAllMethod is TGocciaUndefinedLiteralValue) then begin @@ -1684,7 +1695,7 @@ function TGocciaStringObjectValue.StringMatchAll(const AArgs: TGocciaArgumentsCo AThisValue ]); try - Result := InvokeCallable(MatchAllMethod, CallArgs, AArgs.GetElement(0)); + Result := InvokeCallable(MatchAllMethod, CallArgs, RegExpArg); finally CallArgs.Free; end; @@ -1696,7 +1707,7 @@ function TGocciaStringObjectValue.StringMatchAll(const AArgs: TGocciaArgumentsCo StringValue := ExtractStringValue(AThisValue); if AArgs.Length > 0 then - RegexValue := CoerceRegExpValue(AArgs.GetElement(0), 'g') + RegexValue := CoerceRegExpValue(RegExpArg, 'g') else RegexValue := CoerceRegExpValue(TGocciaUndefinedLiteralValue.UndefinedValue, 'g'); diff --git a/tests/built-ins/RegExp/constructor.js b/tests/built-ins/RegExp/constructor.js index b18322ee..979d6557 100644 --- a/tests/built-ins/RegExp/constructor.js +++ b/tests/built-ins/RegExp/constructor.js @@ -158,10 +158,17 @@ test("new RegExp stringifies object when Symbol.match is false", () => { expect(r.source).toBe("[object Object]"); }); -test("RegExp() without new returns Symbol.match object as-is when flags omitted", () => { +test("RegExp() without new returns actual RegExp as-is when flags omitted", () => { + const r = /abc/g; + const result = RegExp(r); + expect(result).toBe(r); +}); + +test("RegExp() without new wraps Symbol.match object whose constructor differs", () => { const obj = { source: "abc", flags: "", [Symbol.match]: true }; const result = RegExp(obj); - expect(result).toBe(obj); + expect(result).not.toBe(obj); + expect(result.source).toBe("abc"); }); test("new RegExp overrides flags from Symbol.match object when second arg provided", () => { diff --git a/tests/built-ins/String/prototype/matchAll.js b/tests/built-ins/String/prototype/matchAll.js index 9a3e4366..0ad53235 100644 --- a/tests/built-ins/String/prototype/matchAll.js +++ b/tests/built-ins/String/prototype/matchAll.js @@ -164,3 +164,15 @@ test("matchAll accepts Symbol.match truthy object with global flag", () => { }; expect("abc".matchAll(obj)).toEqual(["ok"]); }); + +test("matchAll throws TypeError when IsRegExp object has null flags", () => { + expect(() => { + "abc".matchAll({ [Symbol.match]: true, flags: null }); + }).toThrow(TypeError); +}); + +test("matchAll throws TypeError when IsRegExp object has undefined flags", () => { + expect(() => { + "abc".matchAll({ [Symbol.match]: true }); + }).toThrow(TypeError); +}); diff --git a/tests/built-ins/String/prototype/replaceAll.js b/tests/built-ins/String/prototype/replaceAll.js index 87de1b6e..59748812 100644 --- a/tests/built-ins/String/prototype/replaceAll.js +++ b/tests/built-ins/String/prototype/replaceAll.js @@ -131,4 +131,16 @@ describe('String.prototype.replaceAll', () => { }; expect('abc'.replaceAll(obj, 'x')).toBe('ok'); }); + + test('throws TypeError when IsRegExp object has null flags', () => { + expect(() => { + 'abc'.replaceAll({ [Symbol.match]: true, flags: null }, 'x'); + }).toThrow(TypeError); + }); + + test('throws TypeError when IsRegExp object has undefined flags', () => { + expect(() => { + 'abc'.replaceAll({ [Symbol.match]: true }, 'x'); + }).toThrow(TypeError); + }); }); From fda2c800b7b2d917115feff2150ec3bc9d2b4358 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 10 May 2026 15:45:59 +0100 Subject: [PATCH 3/4] Add HasRegExpData brand; remove regex fallback after @@replace undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsRegExpInstance now checks the internal HasRegExpData flag (set by CreateRegExpObject) instead of probing for own source/flags properties. Plain objects like {source: 'a', flags: ''} can no longer spoof a RegExp instance past prototype method guards. String.prototype.replace and replaceAll no longer fall through to regex execution when @@replace is undefined — per §22.1.3.19 step 4, the searchValue is ToString'd and treated as a plain string when the Symbol method lookup fails. Co-Authored-By: Claude Opus 4.6 (1M context) --- source/units/Goccia.RegExp.Runtime.pas | 4 +- source/units/Goccia.Values.ObjectValue.pas | 2 + .../units/Goccia.Values.StringObjectValue.pas | 72 +------------------ tests/built-ins/RegExp/constructor.js | 6 ++ 4 files changed, 12 insertions(+), 72 deletions(-) diff --git a/source/units/Goccia.RegExp.Runtime.pas b/source/units/Goccia.RegExp.Runtime.pas index 1c4ffc7e..4c637fc9 100644 --- a/source/units/Goccia.RegExp.Runtime.pas +++ b/source/units/Goccia.RegExp.Runtime.pas @@ -122,8 +122,7 @@ function IsRegExpInstance(const AValue: TGocciaValue): Boolean; begin if not (AValue is TGocciaObjectValue) then Exit(False); - Result := TGocciaObjectValue(AValue).HasOwnProperty(PROP_SOURCE) and - TGocciaObjectValue(AValue).HasOwnProperty(PROP_FLAGS); + Result := TGocciaObjectValue(AValue).HasRegExpData; end; function IsRegExp(const AValue: TGocciaValue): Boolean; @@ -155,6 +154,7 @@ function CreateRegExpObject(const APattern, AFlags: string): TGocciaValue; Source := NormalizeRegExpSource(APattern); CanonicalFlags := CanonicalizeRegExpFlags(AFlags); Obj := TGocciaObjectValue.Create(GRegExpPrototype); + Obj.HasRegExpData := True; Obj.DefineProperty(PROP_SOURCE, TGocciaPropertyDescriptorData.Create( TGocciaStringLiteralValue.Create(Source), [])); diff --git a/source/units/Goccia.Values.ObjectValue.pas b/source/units/Goccia.Values.ObjectValue.pas index a24cf019..e16ca70f 100644 --- a/source/units/Goccia.Values.ObjectValue.pas +++ b/source/units/Goccia.Values.ObjectValue.pas @@ -29,6 +29,7 @@ TGocciaObjectValue = class(TGocciaValue) FSealed: Boolean; FExtensible: Boolean; FHasErrorData: Boolean; + FHasRegExpData: Boolean; public class procedure InitializeSharedPrototype; class function GetSharedObjectPrototype: TGocciaObjectValue; static; @@ -96,6 +97,7 @@ TGocciaObjectValue = class(TGocciaValue) property Sealed: Boolean read FSealed; property Extensible: Boolean read FExtensible; property HasErrorData: Boolean read FHasErrorData write FHasErrorData; + property HasRegExpData: Boolean read FHasRegExpData write FHasRegExpData; published function ObjectPrototypeToString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function ObjectPrototypeIsPrototypeOf(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; diff --git a/source/units/Goccia.Values.StringObjectValue.pas b/source/units/Goccia.Values.StringObjectValue.pas index a7cbd5aa..b77e46cb 100644 --- a/source/units/Goccia.Values.StringObjectValue.pas +++ b/source/units/Goccia.Values.StringObjectValue.pas @@ -1086,48 +1086,7 @@ function TGocciaStringObjectValue.StringReplaceMethod(const AArgs: TGocciaArgume // Step 3: Let string be ? ToString(O). StringValue := ExtractStringValue(AThisValue); - if IsRegExpInstance(SearchArg) then - begin - RegexValue := CoerceRegExpValue(SearchArg); - TGarbageCollector.Instance.AddTempRoot(RegexValue); - try - if GetRegExpBooleanProperty(RegexValue, PROP_GLOBAL) then - begin - ResultStr := ''; - Offset := 0; - while MatchRegExpObjectValue(RegexValue, StringValue, Offset, False, - False, MatchArray, MatchIndex, MatchEnd, NextIndex) do - begin - ResultStr := ResultStr + Copy(StringValue, Offset + 1, - MatchIndex - Offset); - ResultStr := ResultStr + BuildRegexReplacement(ReplaceArg, - TGocciaArrayValue(MatchArray), MatchIndex, StringValue); - Offset := NextIndex; - if Offset > Length(StringValue) then - Break; - end; - if Offset <= Length(StringValue) then - ResultStr := ResultStr + Copy(StringValue, Offset + 1, MaxInt); - Result := TGocciaStringLiteralValue.Create(ResultStr); - end - else if MatchRegExpObjectValue(RegexValue, StringValue, 0, False, False, - MatchArray, MatchIndex, MatchEnd, NextIndex) then - begin - ReplaceValue := BuildRegexReplacement(ReplaceArg, - TGocciaArrayValue(MatchArray), - MatchIndex, StringValue); - Result := TGocciaStringLiteralValue.Create( - Copy(StringValue, 1, MatchIndex) + ReplaceValue + - Copy(StringValue, MatchEnd + 1, MaxInt)); - end - else - Result := TGocciaStringLiteralValue.Create(StringValue); - finally - TGarbageCollector.Instance.RemoveTempRoot(RegexValue); - end; - Exit; - end; - + // §22.1.3.19 step 4: Let searchString be ? ToString(searchValue). SearchValue := SearchArg.ToStringLiteral.Value; // Step 5: Let pos be StringIndexOf(string, searchString, 0) @@ -1241,34 +1200,7 @@ function TGocciaStringObjectValue.StringReplaceAllMethod(const AArgs: TGocciaArg // Step 3 (spec): Let string be ? ToString(O) StringValue := ExtractStringValue(AThisValue); - if IsRegExpInstance(SearchArg) then - begin - RegexValue := CoerceRegExpValue(SearchArg); - TGarbageCollector.Instance.AddTempRoot(RegexValue); - try - ResultStr := ''; - Offset := 0; - while MatchRegExpObjectValue(RegexValue, StringValue, Offset, False, - False, - MatchArray, MatchIndex, MatchEnd, NextIndex) do - begin - ResultStr := ResultStr + Copy(StringValue, Offset + 1, - MatchIndex - Offset); - ResultStr := ResultStr + BuildRegexReplacement(ReplaceArg, - TGocciaArrayValue(MatchArray), MatchIndex, StringValue); - Offset := NextIndex; - if Offset > Length(StringValue) then - Break; - end; - if Offset <= Length(StringValue) then - ResultStr := ResultStr + Copy(StringValue, Offset + 1, MaxInt); - Result := TGocciaStringLiteralValue.Create(ResultStr); - finally - TGarbageCollector.Instance.RemoveTempRoot(RegexValue); - end; - Exit; - end; - + // §22.1.3.20 step 7: Let searchString be ? ToString(searchValue). SearchValue := SearchArg.ToStringLiteral.Value; // Step 5: Let searchLength be the length of searchString diff --git a/tests/built-ins/RegExp/constructor.js b/tests/built-ins/RegExp/constructor.js index 979d6557..e1f73983 100644 --- a/tests/built-ins/RegExp/constructor.js +++ b/tests/built-ins/RegExp/constructor.js @@ -201,3 +201,9 @@ test("new RegExp stringifies object when Symbol.match is empty string", () => { const r = new RegExp(obj); expect(r.source).toBe("[object Object]"); }); + +test("plain object with source and flags is not a RegExp instance", () => { + const fake = { source: "abc", flags: "g" }; + expect(() => { RegExp.prototype.exec.call(fake, "abc"); }).toThrow(TypeError); + expect(() => { RegExp.prototype.test.call(fake, "abc"); }).toThrow(TypeError); +}); From 997231090596f8d38af50ae8874429acf9e53705 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 10 May 2026 16:00:46 +0100 Subject: [PATCH 4/4] CoerceRegExpValue uses IsRegExp; remove regex fallback from split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoerceRegExpValue now checks IsRegExp (Symbol.match) instead of IsRegExpInstance (brand), and reads .source/.flags with undefined handling. ANewFlags is applied when provided, fixing the matchAll fallback which previously ignored the forced 'g' flag on clones. StringSplit no longer falls through to regex execution when @@split is undefined — per §22.1.3.23 step 5, the separator is ToString'd and used as a plain string delimiter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../units/Goccia.Values.StringObjectValue.pas | 70 ++++++------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/source/units/Goccia.Values.StringObjectValue.pas b/source/units/Goccia.Values.StringObjectValue.pas index b77e46cb..fd2bbae6 100644 --- a/source/units/Goccia.Values.StringObjectValue.pas +++ b/source/units/Goccia.Values.StringObjectValue.pas @@ -126,18 +126,35 @@ function GetSharedStringPrototype: TGocciaObjectValue; inline; function CoerceRegExpValue(const AValue: TGocciaValue; const ANewFlags: string = ''): TGocciaObjectValue; var - Pattern: string; + Pattern, Flags: string; + PropVal: TGocciaValue; begin - if IsRegExpInstance(AValue) then - Result := TGocciaObjectValue(CloneRegExpObject(AValue)) + if IsRegExp(AValue) then + begin + PropVal := TGocciaObjectValue(AValue).GetProperty(PROP_SOURCE); + if PropVal is TGocciaUndefinedLiteralValue then + Pattern := '' + else + Pattern := PropVal.ToStringLiteral.Value; + if ANewFlags <> '' then + Flags := ANewFlags + else + begin + PropVal := TGocciaObjectValue(AValue).GetProperty(PROP_FLAGS); + if PropVal is TGocciaUndefinedLiteralValue then + Flags := '' + else + Flags := PropVal.ToStringLiteral.Value; + end; + Result := TGocciaObjectValue(CreateRegExpObject(Pattern, Flags)); + end else begin if AValue is TGocciaUndefinedLiteralValue then Pattern := '' else Pattern := AValue.ToStringLiteral.Value; - Result := TGocciaObjectValue(CreateRegExpObject( - Pattern, ANewFlags)); + Result := TGocciaObjectValue(CreateRegExpObject(Pattern, ANewFlags)); end; end; @@ -1394,48 +1411,7 @@ function TGocciaStringObjectValue.StringSplit(const AArgs: TGocciaArgumentsColle // Step 3 (spec): Let S be ? ToString(O). Only reached when no @@split. StringValue := ExtractStringValue(AThisValue); - if IsRegExpInstance(SeparatorArg) then - begin - RegexValue := CoerceRegExpValue(SeparatorArg); - TGarbageCollector.Instance.AddTempRoot(RegexValue); - try - PreviousIndex := 0; - SearchIndex := 0; - while MatchRegExpObjectValue(RegexValue, StringValue, SearchIndex, - False, - False, MatchArray, MatchIndex, MatchEnd, NextIndex) do - begin - ResultArray.Elements.Add(TGocciaStringLiteralValue.Create( - Copy(StringValue, PreviousIndex + 1, MatchIndex - PreviousIndex))); - if HasLimit and (Cardinal(ResultArray.Elements.Count) >= Limit) then - Break; - - for I := 1 to TGocciaArrayValue(MatchArray).Elements.Count - 1 do - begin - ResultArray.Elements.Add(TGocciaArrayValue(MatchArray).Elements[I]); - if HasLimit and (Cardinal(ResultArray.Elements.Count) >= Limit) then - Break; - end; - if HasLimit and (Cardinal(ResultArray.Elements.Count) >= Limit) then - Break; - - PreviousIndex := MatchEnd; - SearchIndex := NextIndex; - if SearchIndex > Length(StringValue) then - Break; - end; - - if not HasLimit or (Cardinal(ResultArray.Elements.Count) < Limit) then - ResultArray.Elements.Add(TGocciaStringLiteralValue.Create( - Copy(StringValue, PreviousIndex + 1, Length(StringValue) - PreviousIndex))); - - Result := ResultArray; - finally - TGarbageCollector.Instance.RemoveTempRoot(RegexValue); - end; - Exit; - end; - + // §22.1.3.23 step 5: Let R be ? ToString(separator). Separator := SeparatorArg.ToStringLiteral.Value; // Step 5: If separator is undefined, return [S]