From 89423c067cc75fce51fb380902a2dc15285d6336 Mon Sep 17 00:00:00 2001 From: Hans Halverson Date: Tue, 11 Aug 2026 21:17:31 -0700 Subject: [PATCH] [tests] Speed up slow RegExp tests --- .../built-ins/RegExp/flags_only_copy.js | 74 ++++- .../RegExp/required_literal_filter.js | 306 ++++++++++-------- 2 files changed, 235 insertions(+), 145 deletions(-) diff --git a/tests/integration/built-ins/RegExp/flags_only_copy.js b/tests/integration/built-ins/RegExp/flags_only_copy.js index d92482c5..b9f86c15 100644 --- a/tests/integration/built-ins/RegExp/flags_only_copy.js +++ b/tests/integration/built-ins/RegExp/flags_only_copy.js @@ -10,26 +10,37 @@ var runtimeOnlyFlags = ["", "d", "g", "y", "dg", "dy", "gy", "dgy"]; // Flags that do affect compilation, so the pattern must be reparsed and recompiled. var compilationFlags = ["i", "m", "s", "u", "v", "im", "gis", "dgimsy"]; +var matchStrings = ["", "abc", "ABC", "AzB", "abcabc", "a\nb", "x\nazb", "aa/bb", "a\u{1F600}b"]; + +// The canonical flags string does not depend on the pattern. +var canonicalFlagsCache = {}; +function canonicalFlags(flags) { + if (!(flags in canonicalFlagsCache)) { + canonicalFlagsCache[flags] = new RegExp("a", flags).flags; + } + return canonicalFlagsCache[flags]; +} + // The source is always taken from the source regexp, and the flags always from the argument, -// however the compiled regexp is obtained. -function assertCopy(source, sourceFlags, newFlags) { - var original = new RegExp(source, sourceFlags); +// however the compiled regexp is obtained. The original and direct regexps are reused across +// calls, so reset lastIndex before each use to make them behave like freshly constructed ones. +function assertCopy(original, direct, source, sourceFlags, newFlags) { var copy = new RegExp(original, newFlags); var message = "/" + source + "/" + sourceFlags + " with " + JSON.stringify(newFlags); assert.sameValue(copy.source, original.source, message + " source"); - assert.sameValue(copy.flags, new RegExp(source, newFlags).flags, message + " flags"); + assert.sameValue(copy.flags, canonicalFlags(newFlags), message + " flags"); assert.sameValue(copy.lastIndex, 0, message + " last index"); assert.notSameValue(copy, original, message + " is a new object"); // Matching must agree with a regexp compiled from the same source and flags directly. - var direct = new RegExp(original.source, newFlags); - var strings = ["", "abc", "ABC", "abcabc", "a\nB", "aa/bb", "a\u{1F600}b"]; - for (var i = 0; i < strings.length; i++) { + for (var i = 0; i < matchStrings.length; i++) { + copy.lastIndex = 0; + direct.lastIndex = 0; assert.sameValue( - JSON.stringify(new RegExp(original, newFlags).exec(strings[i])), - JSON.stringify(new RegExp(direct.source, newFlags).exec(strings[i])), - message + " exec on " + JSON.stringify(strings[i]) + JSON.stringify(copy.exec(matchStrings[i])), + JSON.stringify(direct.exec(matchStrings[i])), + message + " exec on " + JSON.stringify(matchStrings[i]) ); } } @@ -37,11 +48,46 @@ function assertCopy(source, sourceFlags, newFlags) { var sources = ["abc", "(a)(b)", "(?a)b", "a|b", "[a-c]+", "a\\/b", "\\d{2,3}", "^a.b$"]; var allFlags = runtimeOnlyFlags.concat(compilationFlags); -for (var i = 0; i < sources.length; i++) { +// Whether the copy reuses the compiled regexp as is, clones its bytecode with new flags, or +// recompiles from scratch depends only on the pair of flags, never on the pattern. Run the full +// matrix of flag pairs against a single pattern whose matching is sensitive to each compilation +// flag, so a copy that wrongly shares bytecode shows up in the exec results. +var matrixSource = "^(?a).b$"; + +var matrixOriginals = []; +for (var j = 0; j < allFlags.length; j++) { + matrixOriginals.push(new RegExp(matrixSource, allFlags[j])); +} + +for (var k = 0; k < allFlags.length; k++) { + var direct = new RegExp(matrixOriginals[0].source, allFlags[k]); for (var j = 0; j < allFlags.length; j++) { - for (var k = 0; k < allFlags.length; k++) { - assertCopy(sources[i], allFlags[j], allFlags[k]); - } + assertCopy(matrixOriginals[j], direct, matrixSource, allFlags[j], allFlags[k]); + } +} + +// Run every pattern against one pair of flags from each reuse class: identical flags, a runtime +// only difference, a runtime only difference alongside equal compilation flags, and gaining or +// losing a compilation flag. +// +// Every compilation flag must appear in some pair so that each pattern is parsed under it, since +// the matrix pattern above cannot stand in for the parse. `v` in particular parses character +// classes through a separate path and the matrix pattern has no character class, so the last pair +// recompiles every pattern in unicode sets mode. +var flagPairs = [ + ["dgy", "dgy"], + ["g", "dy"], + ["gis", "dgis"], + ["", "im"], + ["iu", "g"], + ["g", "v"], +]; + +for (var i = 0; i < sources.length; i++) { + for (var p = 0; p < flagPairs.length; p++) { + var original = new RegExp(sources[i], flagPairs[p][0]); + var direct = new RegExp(original.source, flagPairs[p][1]); + assertCopy(original, direct, sources[i], flagPairs[p][0], flagPairs[p][1]); } } diff --git a/tests/integration/built-ins/RegExp/required_literal_filter.js b/tests/integration/built-ins/RegExp/required_literal_filter.js index 40b7b761..9bc16de5 100644 --- a/tests/integration/built-ins/RegExp/required_literal_filter.js +++ b/tests/integration/built-ins/RegExp/required_literal_filter.js @@ -2,7 +2,6 @@ description: > Test the RegExp's required literal filter in a variety of situations. Should be transparent but return correct results. -includes: [compareArray.js] ---*/ // Wrapping a pattern in a disjunction with an empty class, which never matches, leaves the language @@ -11,143 +10,109 @@ function withoutRequiredLiteral(source) { return "(?:" + source + "|[])"; } -// Every operation that runs the matcher, reporting the result it produced. -function runAll(source, flags, string) { - return { - exec: JSON.stringify(new RegExp(source, flags).exec(string)), - test: new RegExp(source, flags).test(string), - search: string.search(new RegExp(source, flags)), - match: JSON.stringify(string.match(new RegExp(source, flags))), - replace: string.replace(new RegExp(source, flags), "<$&>"), - split: string.split(new RegExp(source, flags)), - }; +// Compare two exec results: both null, or the same captures and match position. Named groups are +// derived from the captures, so equal captures imply equal groups. +function sameMatch(a, b) { + if (a === null || b === null) return a === b; + if (a.length !== b.length || a.index !== b.index) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; } -function assertSameResults(source, flags, string) { - var message = "/" + source + "/" + flags + " on " + JSON.stringify(string); - - var filtered = runAll(source, flags, string); - var unfiltered = runAll(withoutRequiredLiteral(source), flags, string); +function sameArray(a, b) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} - assert.sameValue(filtered.exec, unfiltered.exec, message + " exec"); - assert.sameValue(filtered.test, unfiltered.test, message + " test"); - assert.sameValue(filtered.search, unfiltered.search, message + " search"); - assert.sameValue(filtered.match, unfiltered.match, message + " match"); - assert.sameValue(filtered.replace, unfiltered.replace, message + " replace"); - assert.compareArray(filtered.split, unfiltered.split, message + " split"); +// The failure message is only built on an actual mismatch, off the hot path of passing combos. +function fail(source, flags, string, op, filtered, unfiltered) { + var message = "/" + source + "/" + flags + " on " + JSON.stringify(string) + " " + op; + assert.sameValue(JSON.stringify(filtered), JSON.stringify(unfiltered), message); + throw new Error(message + ": results differ outside their JSON form"); } -var sources = [ - // A single run of literal code points - "foobar", - // Runs broken up by terms that require nothing, so only the longest run is required - "a(?:bcdef)g", - "[xy]abcde[xy]", - // A run inside a term that always matches at least once - "(abcd)+xy", - // A run that is longer than the stored literal, so only a prefix of it is searched for - "abcdefghijklmnopqrstuvwxyz", - // Non-Latin1 code points interrupt a literal and a new literal starts after the interruption, - // with the longest literal searched for. A full literal ignores later interruptions. - "abcdefĀghijkl", - "abcĀdefgh", - "aĀĀbbbĀcccc", - "x{2,5}abĀvwxyz", - "Āabcdefgh.*xyz", - "abcdefghijklmnopqrstĀuvwxyz", - // Astral code points count as one code point in unicode mode and two surrogates otherwise - "😀😀foobar", - // Case insensitive mode searches only for stretches of code points without case variants - "123456", - "foo_123_bar", - "é12345", - "(?i:error404)xyz", - // Runs that are only reachable on some paths, or not at the start of the match - "(?:abcdef)?xy", - "(?=abcdef)abc", - "foobar|bazqux", - // The shape of the pattern this filter was written for: a leading group that consumes at most one - // code point, followed by a long required run. - "(^|[^\\\\])\"\\\\/Date\\((-?[0-9]+)\\)", - // Literals at a fixed distance into the match, which pins a match start to one position per - // occurrence of the literal - "x{3}foobar", - "(?=xx)x{2}foobar", - // Literals at a bounded range of distances, so several positions must be tried per occurrence - "x{2,5}foobar", - "(?:ab|cde)foobar", - // Literals that may appear arbitrarily far into the match, which can only rule out an input - "(\\w)\\1foobar", - ".*foobar", - // A line anchored pattern scans line starts while still using the literal to rule out inputs - "^x*foobar", - // Literals containing NUL code points, whose widened two byte form can overlap itself at a - // misaligned offset in a two byte input - "\\u0000\\u0000\\u0000", - "\\u0000bc", - "a\\u0000\\u0000\\u0000", -]; +// Every operation that runs the matcher must report the same result through the filtered and the +// unfiltered regexp. The regexps are reused across operations and strings, so reset lastIndex +// before each operation to make them behave like freshly constructed ones. +function assertSameResults(filteredRe, unfilteredRe, source, flags, string) { + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredExec = filteredRe.exec(string); + var unfilteredExec = unfilteredRe.exec(string); + if (!sameMatch(filteredExec, unfilteredExec)) { + fail(source, flags, string, "exec", filteredExec, unfilteredExec); + } + + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredTest = filteredRe.test(string); + var unfilteredTest = unfilteredRe.test(string); + if (filteredTest !== unfilteredTest) { + fail(source, flags, string, "test", filteredTest, unfilteredTest); + } + + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredSearch = string.search(filteredRe); + var unfilteredSearch = string.search(unfilteredRe); + if (filteredSearch !== unfilteredSearch) { + fail(source, flags, string, "search", filteredSearch, unfilteredSearch); + } + + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredMatch = string.match(filteredRe); + var unfilteredMatch = string.match(unfilteredRe); + if (!sameMatch(filteredMatch, unfilteredMatch)) { + fail(source, flags, string, "match", filteredMatch, unfilteredMatch); + } + + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredReplace = string.replace(filteredRe, "<$&>"); + var unfilteredReplace = string.replace(unfilteredRe, "<$&>"); + if (filteredReplace !== unfilteredReplace) { + fail(source, flags, string, "replace", filteredReplace, unfilteredReplace); + } + + filteredRe.lastIndex = 0; + unfilteredRe.lastIndex = 0; + var filteredSplit = string.split(filteredRe); + var unfilteredSplit = string.split(unfilteredRe); + if (!sameArray(filteredSplit, unfilteredSplit)) { + fail(source, flags, string, "split", filteredSplit, unfilteredSplit); + } +} var flagCombinations = ["", "g", "i", "gi", "y", "gy", "u", "gu", "dgimsy"]; -var strings = [ +// Strings run against every pattern under every flag combination: the literal absent from an +// empty, one byte, two byte, and lone surrogate input, plus inputs holding the literal that most +// of the patterns share. The trailing ‰ is what forces a string to be stored as two bytes. +// +// Half of these hold the literal rather than merely being rejected, and a two byte buffer is +// searched for the literal by a different path than a one byte buffer, so these must not be +// narrowed to a subset of the flag combinations. They are what guarantees that every pattern +// exercises the two byte search under each flag combination, since most of the per case strings +// below are one byte only. +var commonStrings = [ "", - // The literal present, absent, repeated, and in case variants - "foobar", - "xxfoobarxx", - "foobarfoobar", "xxxxxxxxxx", - "xxFooBarxx", - "xx123456xx", - // Inputs for the patterns that are not plain runs - "xabcdex", - "abcdabcdxy", - "abcdefghijklmnopqrstuvwxyz", - "abcdefghijklmnop", - "abc", - "xyz", - "bazqux", - // Inputs with line terminators for the line anchored pattern - "zz\nxfoobar", - "foobar\nzz", - '{"d":"\\/Date(12345)\\/"}', - // Inputs holding interrupted literals exactly and at surrounding offsets - "abcdefĀghijkl", - "abcĀdefgh", - "xxabcĀdefghxx", - "defgh", - "aĀĀbbbĀcccc", - "ccccxxxx", - "xabĀvwxyz", - "xxabĀvwxyz", - "xxxxxabĀvwxyz", - "xxxxxxabĀvwxyz", - "abcdefghijklmnopqrstĀuvwxyz", - "Āabcdefghxxxxyz", - "\u0100abcdefghxyz", - // Astral inputs around the required literal - "😀😀foobar", - "x😀😀foobar", - "😀foobar", - // Case variants of the stretch patterns - "FOO_123_BAR", - "foo_123_barxx", - "foo_123_bar‰", - "ERROR404xyz", - "error404XYZ", - "É12345", - "é12345", - // A two byte input holds the same code points, but is searched for the literal differently. The - // trailing code point is what forces the string to be stored as two bytes. "xxfoobarxx‰", "xxxxxxxxxx‰", - "xx123456xx‰", - '{"d":"\\/Date(12345)\\/"}‰', - // A lone surrogate cannot be part of the literal, but must not be matched into either "xxfoo\uD83Dbarxx", "xxfoobarxx\uD83D", - // The literal at each distance around the bounds of the patterns above, so that a match starting - // at exactly the earliest and latest viable position is covered, as well as just outside both. +]; + +// The literal at each distance around the bounds of the fixed and bounded distance patterns below, +// so that a match starting at exactly the earliest and latest viable position is covered, as well +// as just outside both. +var literalDistanceStrings = [ "xfoobar", "xxfoobar", "xxxfoobar", @@ -155,25 +120,104 @@ var strings = [ "xxxxxfoobar", "xxxxxxfoobar", "zzfoobarxxxfoobar", - "abfoobar", - "cdefoobar", "xxxfoobar‰", "xxxxxfoobar‰", - // Two byte inputs where a misaligned occurrence of a NUL literal's widened form overlaps and - // precedes the real aligned occurrence +]; + +// Two byte inputs where a misaligned occurrence of a NUL literal's widened form overlaps and +// precedes the real aligned occurrence, plus one byte inputs holding the same NUL literals +var nulStrings = [ "a\u0000\u0000\u0000\u1234", "\u00ff\u0000\u0000\u0000\u1234", "\u1234a\u0000\u0000\u0000", "A\u6200\u6300\u0000bc", - // One byte inputs holding the same NUL literals "a\u0000\u0000\u0000", "\u0000\u0000\u0000bc", ]; -for (var i = 0; i < sources.length; i++) { +// Each pattern is paired with the inputs written for it: the literal present, absent, repeated, in +// case variants, and at surrounding offsets. Every pattern also runs against the common strings +// above. Running the full cross product of patterns and strings instead only added rejections and +// matches already covered by each pattern's own inputs, such as the same literal found at a +// further offset in a longer run of filler. +var cases = [ + // A single run of literal code points + { source: "foobar", strings: ["foobar", "xxfoobarxx", "foobarfoobar", "xxFooBarxx", "foobar\nzz"] }, + // Runs broken up by terms that require nothing, so only the longest run is required + { + source: "a(?:bcdef)g", + // The last input is two byte, since none of the common strings match this pattern. + strings: ["abcdefg", "abcdefghijklmnop", "abc", "xabcdex", "abcdefghijklmnopqrstĀuvwxyz"], + }, + { source: "[xy]abcde[xy]", strings: ["xabcdex", "abc", "xyz"] }, + // A run inside a term that always matches at least once + { source: "(abcd)+xy", strings: ["abcdabcdxy", "abc", "xyz"] }, + // A run that is longer than the stored literal, so only a prefix of it is searched for + { source: "abcdefghijklmnopqrstuvwxyz", strings: ["abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnop"] }, + // Non-Latin1 code points interrupt a literal and a new literal starts after the interruption, + // with the longest literal searched for. A full literal ignores later interruptions. + { source: "abcdefĀghijkl", strings: ["abcdefĀghijkl", "abcdefghijklmnop", "defgh"] }, + { source: "abcĀdefgh", strings: ["abcĀdefgh", "xxabcĀdefghxx", "defgh", "abc"] }, + { source: "aĀĀbbbĀcccc", strings: ["aĀĀbbbĀcccc", "ccccxxxx"] }, + { + source: "x{2,5}abĀvwxyz", + strings: ["xabĀvwxyz", "xxabĀvwxyz", "xxxxxabĀvwxyz", "xxxxxxabĀvwxyz"], + }, + { source: "Āabcdefgh.*xyz", strings: ["Āabcdefghxxxxyz", "Āabcdefghxyz"] }, + { source: "abcdefghijklmnopqrstĀuvwxyz", strings: ["abcdefghijklmnopqrstĀuvwxyz"] }, + // Astral code points count as one code point in unicode mode and two surrogates otherwise + { source: "😀😀foobar", strings: ["😀😀foobar", "x😀😀foobar", "😀foobar"] }, + // Case insensitive mode searches only for stretches of code points without case variants + { source: "123456", strings: ["xx123456xx", "xx123456xx‰"] }, + { source: "foo_123_bar", strings: ["FOO_123_BAR", "foo_123_barxx", "foo_123_bar‰"] }, + { source: "é12345", strings: ["É12345", "é12345"] }, + { source: "(?i:error404)xyz", strings: ["ERROR404xyz", "error404XYZ"] }, + // Runs that are only reachable on some paths, or not at the start of the match + // The last input of each is two byte, since none of the common strings match these patterns. + { + source: "(?:abcdef)?xy", + strings: ["abcdabcdxy", "xyz", "abcdefghijklmnop", "Āabcdefghxxxxyz"], + }, + { source: "(?=abcdef)abc", strings: ["abcdefghijklmnop", "abc", "abcdefĀghijkl"] }, + { source: "foobar|bazqux", strings: ["bazqux", "xxfoobarxx", "xyz"] }, + // The shape of the pattern this filter was written for: a leading group that consumes at most one + // code point, followed by a long required run. + { + source: "(^|[^\\\\])\"\\\\/Date\\((-?[0-9]+)\\)", + strings: ['{"d":"\\/Date(12345)\\/"}', '{"d":"\\/Date(12345)\\/"}‰'], + }, + // Literals at a fixed distance into the match, which pins a match start to one position per + // occurrence of the literal + { source: "x{3}foobar", strings: literalDistanceStrings }, + { source: "(?=xx)x{2}foobar", strings: literalDistanceStrings }, + // Literals at a bounded range of distances, so several positions must be tried per occurrence + { source: "x{2,5}foobar", strings: literalDistanceStrings }, + { + source: "(?:ab|cde)foobar", + strings: ["abfoobar", "cdefoobar", "xfoobar", "xxfoobar", "xxxfoobar"], + }, + // Literals that may appear arbitrarily far into the match, which can only rule out an input + { source: "(\\w)\\1foobar", strings: ["xxfoobarxx", "zzfoobarxxxfoobar", "xfoobar"] }, + { source: ".*foobar", strings: ["xxfoobarxx", "zz\nxfoobar", "foobar\nzz"] }, + // A line anchored pattern scans line starts while still using the literal to rule out inputs + { source: "^x*foobar", strings: ["zz\nxfoobar", "foobar\nzz", "xxxfoobar"] }, + // Literals containing NUL code points, whose widened two byte form can overlap itself at a + // misaligned offset in a two byte input + { source: "\\u0000\\u0000\\u0000", strings: nulStrings }, + { source: "\\u0000bc", strings: nulStrings }, + { source: "a\\u0000\\u0000\\u0000", strings: nulStrings }, +]; + + +for (var i = 0; i < cases.length; i++) { + var source = cases[i].source; + var strings = commonStrings.concat(cases[i].strings); for (var j = 0; j < flagCombinations.length; j++) { + var flags = flagCombinations[j]; + var filteredRe = new RegExp(source, flags); + var unfilteredRe = new RegExp(withoutRequiredLiteral(source), flags); for (var k = 0; k < strings.length; k++) { - assertSameResults(sources[i], flagCombinations[j], strings[k]); + assertSameResults(filteredRe, unfilteredRe, source, flags, strings[k]); } } }