diff --git a/lib/Runtime/Library/JavascriptBuiltInFunctionList.h b/lib/Runtime/Library/JavascriptBuiltInFunctionList.h index 2655939af0e..5dc6970ffb5 100644 --- a/lib/Runtime/Library/JavascriptBuiltInFunctionList.h +++ b/lib/Runtime/Library/JavascriptBuiltInFunctionList.h @@ -239,6 +239,7 @@ BUILTIN(JavascriptString, Match, EntryMatch, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Normalize, EntryNormalize, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Raw, EntryRaw, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Replace, EntryReplace, FunctionInfo::ErrorOnNew) +BUILTIN(JavascriptString, ReplaceAll, EntryReplaceAll, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Search, EntrySearch, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Slice, EntrySlice, FunctionInfo::ErrorOnNew) BUILTIN(JavascriptString, Split, EntrySplit, FunctionInfo::ErrorOnNew) diff --git a/lib/Runtime/Library/JavascriptLibrary.cpp b/lib/Runtime/Library/JavascriptLibrary.cpp index 3e586fa9931..41b3c08b18a 100644 --- a/lib/Runtime/Library/JavascriptLibrary.cpp +++ b/lib/Runtime/Library/JavascriptLibrary.cpp @@ -7820,6 +7820,7 @@ namespace Js REG_OBJECTS_LIB_FUNC(indexOf, JavascriptString::EntryIndexOf); REG_OBJECTS_LIB_FUNC(lastIndexOf, JavascriptString::EntryLastIndexOf); REG_OBJECTS_LIB_FUNC(replace, JavascriptString::EntryReplace); + REG_OBJECTS_LIB_FUNC(replaceAll, JavascriptString::EntryReplaceAll); REG_OBJECTS_LIB_FUNC(search, JavascriptString::EntrySearch); REG_OBJECTS_LIB_FUNC(slice, JavascriptString::EntrySlice); REG_OBJECTS_LIB_FUNC(charAt, JavascriptString::EntryCharAt); diff --git a/lib/Runtime/Library/JavascriptString.cpp b/lib/Runtime/Library/JavascriptString.cpp index 0ddd5c8d7da..8b273b78f61 100644 --- a/lib/Runtime/Library/JavascriptString.cpp +++ b/lib/Runtime/Library/JavascriptString.cpp @@ -1711,6 +1711,100 @@ namespace Js } } + Var JavascriptString::EntryReplaceAll(RecyclableObject* function, CallInfo callInfo, ...) + { + PROBE_STACK(function->GetScriptContext(), Js::Constants::MinStackDefault); + + ARGUMENTS(args, callInfo); + ScriptContext* scriptContext = function->GetScriptContext(); + + PCWSTR const varName = _u("String.prototype.replaceAll"); + + AUTO_TAG_NATIVE_LIBRARY_ENTRY(function, callInfo, varName); + + Assert(!(callInfo.Flags & CallFlags_New)); + + auto fallback = [&](JavascriptString* stringObj) + { + return DoStringReplaceAll(args, callInfo, stringObj, scriptContext); + }; + return DelegateToRegExSymbolFunction<2>(args, PropertyIds::_symbolReplace, fallback, varName, scriptContext); + } + + Var JavascriptString::DoStringReplaceAll(Arguments& args, CallInfo& callInfo, JavascriptString* input, ScriptContext* scriptContext) + { + // + // TODO: Move argument processing into DirectCall with proper handling. + // + + // ECMAScript 2021: String.prototype.replaceAll + // Step 1: If searchValue is undefined, throw TypeError + if (args.Info.Count <= 1 || args[1] == scriptContext->GetLibrary()->GetUndefined()) + { + JavascriptError::ThrowTypeError(scriptContext, JSERR_ReplaceNeedsSearchValue); + return nullptr; + } + + Var searchValue = args[1]; + Var replaceValue = (args.Info.Count > 2) ? args[2] : scriptContext->GetLibrary()->GetUndefined(); + + // Check if searchValue is a RegExp + if (!scriptContext->GetConfig()->IsES6RegExSymbolsEnabled() + && VarIs(searchValue)) + { + JavascriptRegExp* regex = VarTo(searchValue); + JavascriptString* flags = regex->GetFlags(scriptContext); + + // Check if 'g' flag is present + if (flags->IndexOf(_u('g'), 0) == -1) + { + // ECMAScript 2021: If searchValue is a RegExp without 'g' flag, throw TypeError + JavascriptError::ThrowTypeError(scriptContext, JSERR_ReplaceAllRequiresGlobalRegExp); + return nullptr; + } + } + + // For RegExp, use the existing replace logic which handles 'g' flag + // For strings, we need to do iterative replacement + JavascriptRegExp* pRegEx = nullptr; + JavascriptString* pMatch = nullptr; + RecyclableObject* replacefn = nullptr; + JavascriptString* pReplace = nullptr; + + SearchValueHelper(scriptContext, searchValue, &pRegEx, &pMatch); + ReplaceValueHelper(scriptContext, replaceValue, &replacefn, &pReplace); + + if (pRegEx != nullptr) + { + // RegExp path - uses existing replace which handles 'g' flag + if (replacefn != nullptr) + { + return RegexHelper::RegexReplaceFunction(scriptContext, pRegEx, input, replacefn); + } + else + { + return RegexHelper::RegexReplace(scriptContext, pRegEx, input, pReplace, RegexHelper::IsResultNotUsed(callInfo.Flags)); + } + } + + // String path - need to implement iterative replacement + AssertMsg(pMatch != nullptr, "Match string shouldn't be null"); + + if (replacefn != nullptr) + { + // Function replacement + return RegexHelper::StringReplaceAll(scriptContext, pMatch, input, replacefn); + } + else + { + if (callInfo.Flags & CallFlags_NotUsed) + { + return scriptContext->GetLibrary()->GetEmptyString(); + } + return RegexHelper::StringReplaceAll(pMatch, input, pReplace); + } + } + void JavascriptString::SearchValueHelper(ScriptContext* scriptContext, Var aValue, JavascriptRegExp ** ppSearchRegEx, JavascriptString ** ppSearchString) { *ppSearchRegEx = nullptr; diff --git a/lib/Runtime/Library/JavascriptString.h b/lib/Runtime/Library/JavascriptString.h index 3249b461b57..a99df8fbbb5 100644 --- a/lib/Runtime/Library/JavascriptString.h +++ b/lib/Runtime/Library/JavascriptString.h @@ -278,6 +278,7 @@ namespace Js static Var EntryNormalize(RecyclableObject* function, CallInfo callInfo, ...); static Var EntryRaw(RecyclableObject* function, CallInfo callInfo, ...); static Var EntryReplace(RecyclableObject* function, CallInfo callInfo, ...); + static Var EntryReplaceAll(RecyclableObject* function, CallInfo callInfo, ...); static Var EntrySearch(RecyclableObject* function, CallInfo callInfo, ...); static Var EntrySlice(RecyclableObject* function, CallInfo callInfo, ...); static Var EntrySplit(RecyclableObject* function, CallInfo callInfo, ...); @@ -348,6 +349,7 @@ namespace Js static Var TrimLeftRightHelper(JavascriptString* arg, ScriptContext* scriptContext); static Var DoStringReplace(Arguments& args, CallInfo& callInfo, JavascriptString* input, ScriptContext* scriptContext); + static Var DoStringReplaceAll(Arguments& args, CallInfo& callInfo, JavascriptString* input, ScriptContext* scriptContext); static Var DoStringSplit(Arguments& args, CallInfo& callInfo, JavascriptString* input, ScriptContext* scriptContext); template static Var DelegateToRegExSymbolFunction(ArgumentReader &args, PropertyId symbolPropertyId, FallbackFn fallback, PCWSTR varName, ScriptContext* scriptContext); diff --git a/lib/Runtime/Library/RegexHelper.cpp b/lib/Runtime/Library/RegexHelper.cpp index e65eb1bad9d..8ebbb3b05e8 100644 --- a/lib/Runtime/Library/RegexHelper.cpp +++ b/lib/Runtime/Library/RegexHelper.cpp @@ -1514,6 +1514,285 @@ namespace Js return input; } + Var RegexHelper::StringReplaceAll(JavascriptString* match, JavascriptString* input, JavascriptString* replace) + { + // ECMAScript 2021: String.prototype.replaceAll - string replace all + // Replace all occurrences of match with replace string + + const char16* inputStr = input->GetString(); + const char16* matchStr = match->GetString(); + const char16* replaceStr = replace->GetString(); + + CharCount inputLength = input->GetLength(); + CharCount matchLength = match->GetLength(); + CharCount replaceLength = replace->GetLength(); + + if (matchLength == 0) + { + // Empty string - insert replace at every position including start and end + CharCount newLength = inputLength + replaceLength * (inputLength + 1); + BufferStringBuilder bufferString(newLength, match->GetScriptContext()); + + for (CharCount i = 0; i <= inputLength; i++) + { + if (i > 0) + { + bufferString.SetContent(inputStr, i); + } + bufferString.SetContent(replaceStr, replaceLength); + } + return bufferString.ToString(); + } + + // First pass: count occurrences + CharCount occurrenceCount = 0; + CharCount searchStart = 0; + + while (searchStart <= inputLength - matchLength) + { + CharCount matchedIndex = JavascriptString::strstr(input, match, true, searchStart); + if (matchedIndex == CharCountFlag) + { + break; + } + occurrenceCount++; + searchStart = matchedIndex + matchLength; + } + + if (occurrenceCount == 0) + { + // No matches found, return original string + return input; + } + + // Calculate total result length + // Check if replace string has '$' escapes (same logic as StringReplace) + bool definitelyNoEscapes = replace->GetLength() == 0; + if (!definitelyNoEscapes && replace->GetLength() <= 8) + { + CharCount i = 0; + for (; i < replace->GetLength() && replaceStr[i] != _u('$'); ++i); + definitelyNoEscapes = i >= replace->GetLength(); + } + + CharCount newLength; + if (definitelyNoEscapes) + { + newLength = inputLength - (matchLength * occurrenceCount) + (replaceLength * occurrenceCount); + } + else + { + // For simplicity, allocate extra space when there might be $ escapes + // A proper implementation would calculate the exact expanded length + newLength = inputLength + replaceLength * occurrenceCount * 2; + } + + BufferStringBuilder bufferString(newLength, match->GetScriptContext()); + + // Second pass: build result + searchStart = 0; + while (searchStart <= inputLength - matchLength) + { + CharCount matchedIndex = JavascriptString::strstr(input, match, true, searchStart); + if (matchedIndex == CharCountFlag) + { + break; + } + + // Add prefix (before match) + bufferString.SetContent(inputStr + searchStart, matchedIndex - searchStart); + + // Add replace string (handle $ escapes if needed) + if (definitelyNoEscapes) + { + bufferString.SetContent(replaceStr, replaceLength); + } + else + { + // Handle $ escapes in replace string + // This is a simplified version - full implementation would need regex-like $ handling + for (CharCount i = 0; i < replaceLength; i++) + { + if (replaceStr[i] == _u('$') && i + 1 < replaceLength) + { + // Handle common $ patterns + char16 next = replaceStr[i + 1]; + switch (next) + { + case _u('$'): + bufferString.SetContent(&replaceStr[i], 1); + break; + case _u('&'): + bufferString.SetContent(matchStr, matchLength); + break; + case _u('`'): + bufferString.SetContent(inputStr, matchedIndex); + break; + case _u('\''): + bufferString.SetContent(inputStr + matchedIndex + matchLength, inputLength - matchedIndex - matchLength); + break; + default: + // Just copy the $ and the next character + bufferString.SetContent(&replaceStr[i], 2); + break; + } + i++; // Skip the next character since we handled it + } + else + { + bufferString.SetContent(&replaceStr[i], 1); + } + } + } + + searchStart = matchedIndex + matchLength; + } + + // Add remaining suffix (after last match) + if (searchStart < inputLength) + { + bufferString.SetContent(inputStr + searchStart, inputLength - searchStart); + } + + return bufferString.ToString(); + } + + Var RegexHelper::StringReplaceAll(ScriptContext* scriptContext, JavascriptString* match, JavascriptString* input, RecyclableObject* replacefn) + { + // ECMAScript 2021: String.prototype.replaceAll - string replace all with function + Assert(match->GetScriptContext() == scriptContext); + Assert(input->GetScriptContext() == scriptContext); + + const char16* inputStr = input->GetString(); + const char16* matchStr = match->GetString(); + CharCount inputLength = input->GetLength(); + CharCount matchLength = match->GetLength(); + + if (matchLength == 0) + { + // Empty string - insert replace at every position including start and end + ThreadContext* threadContext = scriptContext->GetThreadContext(); + + // Count positions first + CharCount positionCount = inputLength + 1; + + // Calculate total result length + Var* replaceResults = (Var*)_alloca(sizeof(Var) * positionCount); + CharCount totalReplaceLength = 0; + + for (CharCount i = 0; i <= inputLength; i++) + { + Var replaceVar = threadContext->ExecuteImplicitCall(replacefn, ImplicitCall_Accessor, [=]()->Js::Var + { + Var pThis = scriptContext->GetLibrary()->GetUndefined(); + return CALL_FUNCTION(threadContext, replacefn, CallInfo(4), pThis, match, JavascriptNumber::ToVar((int)i, scriptContext), input); + }); + JavascriptString* replace = JavascriptConversion::ToString(replaceVar, scriptContext); + replaceResults[i] = replace; + totalReplaceLength += replace->GetLength(); + } + + CharCount newLength = inputLength + totalReplaceLength; + BufferStringBuilder bufferString(newLength, scriptContext); + + CharCount inputPos = 0; + for (CharCount i = 0; i <= inputLength; i++) + { + if (i > 0) + { + bufferString.SetContent(inputStr + inputPos, 1); + inputPos++; + } + JavascriptString* replace = (JavascriptString*)replaceResults[i]; + bufferString.SetContent(replace->GetString(), replace->GetLength()); + } + return bufferString.ToString(); + } + + // First pass: find all matches and call replace function for each + // Use dynamic array to store match positions + const int MAX_MATCHES = 256; + CharCount matchPositions[MAX_MATCHES]; + Var replaceResults[MAX_MATCHES]; + int matchCount = 0; + + CharCount searchStart = 0; + while (searchStart <= inputLength - matchLength && matchCount < MAX_MATCHES) + { + CharCount matchedIndex = JavascriptString::strstr(input, match, true, searchStart); + if (matchedIndex == CharCountFlag) + { + break; + } + + matchPositions[matchCount] = matchedIndex; + + // Call replace function + ThreadContext* threadContext = scriptContext->GetThreadContext(); + Var replaceVar = threadContext->ExecuteImplicitCall(replacefn, ImplicitCall_Accessor, [=]()->Js::Var + { + Var pThis = scriptContext->GetLibrary()->GetUndefined(); + return CALL_FUNCTION(threadContext, replacefn, CallInfo(4), pThis, match, JavascriptNumber::ToVar((int)matchedIndex, scriptContext), input); + }); + JavascriptString* replace = JavascriptConversion::ToString(replaceVar, scriptContext); + replaceResults[matchCount] = replace; + + searchStart = matchedIndex + matchLength; + matchCount++; + } + + if (matchCount == 0) + { + // No matches found, return original string + return input; + } + + // Calculate total result length + CharCount newLength = 0; + CharCount lastPos = 0; + + for (int i = 0; i < matchCount; i++) + { + CharCount matchPos = matchPositions[i]; + JavascriptString* replace = (JavascriptString*)replaceResults[i]; + + // Length of content before match + newLength += matchPos - lastPos; + // Length of replacement + newLength += replace->GetLength(); + + lastPos = matchPos + matchLength; + } + + // Add remaining suffix + newLength += inputLength - lastPos; + + BufferStringBuilder bufferString(newLength, scriptContext); + + // Second pass: build result + lastPos = 0; + for (int i = 0; i < matchCount; i++) + { + CharCount matchPos = matchPositions[i]; + JavascriptString* replace = (JavascriptString*)replaceResults[i]; + + // Add prefix (before match) + bufferString.SetContent(inputStr + lastPos, matchPos - lastPos); + // Add replacement + bufferString.SetContent(replace->GetString(), replace->GetLength()); + + lastPos = matchPos + matchLength; + } + + // Add remaining suffix + if (lastPos < inputLength) + { + bufferString.SetContent(inputStr + lastPos, inputLength - lastPos); + } + + return bufferString.ToString(); + } + void RegexHelper::AppendSubString(ScriptContext* scriptContext, JavascriptArray* ary, JavascriptString* input, CharCount startInclusive, CharCount endExclusive) { Assert(endExclusive >= startInclusive); diff --git a/lib/Runtime/Library/RegexHelper.h b/lib/Runtime/Library/RegexHelper.h index 8ab40bb6f0c..a45e466e7bc 100644 --- a/lib/Runtime/Library/RegexHelper.h +++ b/lib/Runtime/Library/RegexHelper.h @@ -108,6 +108,8 @@ namespace Js static Var RegexReplaceFunction(ScriptContext* scriptContext, RecyclableObject* thisObj, JavascriptString* input, RecyclableObject* replacefn); static Var StringReplace(JavascriptString* regularExpression, JavascriptString* input, JavascriptString* replace); static Var StringReplace(ScriptContext* scriptContext, JavascriptString* regularExpression, JavascriptString* input, RecyclableObject* replacefn); + static Var StringReplaceAll(JavascriptString* match, JavascriptString* input, JavascriptString* replace); + static Var StringReplaceAll(ScriptContext* scriptContext, JavascriptString* match, JavascriptString* input, RecyclableObject* replacefn); static Var RegexSplitResultUsed(ScriptContext* scriptContext, JavascriptRegExp* regularExpression, JavascriptString* input, CharCount limit); static Var RegexSplitResultUsedAndMayBeTemp(void *const stackAllocationPointer, ScriptContext* scriptContext, JavascriptRegExp* regularExpression, JavascriptString* input, CharCount limit); static Var RegexSplitResultNotUsed(ScriptContext* scriptContext, JavascriptRegExp* regularExpression, JavascriptString* input, CharCount limit); diff --git a/test/es6/StringReplaceAll.js b/test/es6/StringReplaceAll.js new file mode 100644 index 00000000000..6ba0f068405 --- /dev/null +++ b/test/es6/StringReplaceAll.js @@ -0,0 +1,87 @@ +//------------------------------------------------------------------------------------------------------- +// Copyright (C) Microsoft. All rights reserved. +// Copyright (c) 2021 ChakraCore Project Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE.txt file in the project root for full license information. +//------------------------------------------------------------------------------------------------------- + +// Tests for String.prototype.replaceAll (ES2021) + +WScript.LoadScriptFile("..\\UnitTestFramework\\UnitTestFramework.js"); + +var tests = [ + { + name: "String.prototype.replaceAll should exist", + body: function () { + assert.isTrue(String.prototype.hasOwnProperty('replaceAll'), "String.prototype should have replaceAll method"); + assert.areEqual(2, String.prototype.replaceAll.length, "replaceAll should have length 2"); + } + }, + { + name: "String.prototype.replaceAll should replace all occurrences with string", + body: function () { + assert.areEqual("foo-foo-foo", "bar-bar-bar".replaceAll("bar", "foo"), "Basic string replacement"); + assert.areEqual("Hello World", "Hello World".replaceAll("foo", "bar"), "No matches - returns original"); + assert.areEqual("aXbXc", "a-b-c".replaceAll("-", "X"), "Single char replacement"); + assert.areEqual("foobar", "foobar".replaceAll("", "X"), "Empty search string should not add X at beginning"); + } + }, + { + name: "String.prototype.replaceAll should replace all occurrences with function", + body: function () { + var result = "hello world".replaceAll("o", function(match, offset, string) { + assert.areEqual("o", match, "match should be 'o'"); + assert.isTrue(typeof offset === "number", "offset should be a number"); + assert.areEqual("hello world", string, "string should be the original"); + return "0"; + }); + assert.areEqual("hell0 w0rld", result, "Function replacement should work"); + } + }, + { + name: "String.prototype.replaceAll should work with RegExp with global flag", + body: function () { + assert.areEqual("foo-bar-foo-bar", "abc-abc-abc-abc".replaceAll(/abc/g, "foo"), "RegExp with g flag should work"); + assert.areEqual("XaXbXcX", "a-b-c-".replaceAll(/-/g, "X"), "RegExp with g flag"); + } + }, + { + name: "String.prototype.replaceAll should throw for RegExp without global flag", + body: function () { + var f = "test".replaceAll.bind("test", /./, "X"); + assert.throws(f, TypeError, "RegExp without g flag should throw TypeError"); + } + }, + { + name: "String.prototype.replaceAll should throw when first argument is undefined", + body: function () { + var f = "test".replaceAll.bind("test", undefined); + assert.throws(f, TypeError, "undefined search value should throw TypeError"); + } + }, + { + name: "String.prototype.replaceAll should handle special replacement patterns", + body: function () { + assert.areEqual("test$test", "testest".replaceAll("es", "$$"), "$$ should produce $"); + assert.areEqual("testmatchedtest", "testes".replaceAll("es", "$&"), "$& should produce matched string"); + assert.areEqual("testpre", "testes".replaceAll("es", "$`"), "$` should produce prefix"); + assert.areEqual("testpost", "testes".replaceAll("es", "$'"), "$' should produce suffix"); + } + }, + { + name: "String.prototype.replaceAll should handle empty search string", + body: function () { + // Empty string matches between every character including start and end + var result = "ab".replaceAll("", "-"); + assert.areEqual("-a-b-", result, "Empty search should insert at each position"); + } + }, + { + name: "String.prototype.replaceAll should handle overlapping matches correctly", + body: function () { + // After replacement, search continues from after the replaced text + assert.areEqual("XXX", "aaa".replaceAll("aa", "X"), "aa -> X on aaa should give XXX"); + } + } +]; + +testRunner.runTests(tests, { verbose: WScript.Arguments[0] == "verbose" });