diff --git a/config/scripts/source-string-blanking-benchmark.mjs b/config/scripts/source-string-blanking-benchmark.mjs new file mode 100644 index 00000000000..de677b9baa9 --- /dev/null +++ b/config/scripts/source-string-blanking-benchmark.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' +import { blankStringContents as after } from '../../src/shared/source-scan/source-tree-scan.ts' + +const ref = process.argv[2] +if (!ref) { + throw new Error('Usage: node config/scripts/source-string-blanking-benchmark.mjs ') +} +const source = execFileSync('git', ['show', `${ref}:src/shared/source-scan/source-tree-scan.ts`], { + encoding: 'utf8' +}) +const { blankStringContents: before } = await import( + `data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}` +) +const tokens = [ + 'a', + '/', + '*', + ' ', + '\n', + '\r', + '\t', + '\u00a0', + '\u2028', + '"', + "'", + '`', + '${', + '}', + '{', + '\\', + '(', + ')', + '[', + ']', + '=', + '+', + '-', + ';' +] +let seed = 173 +for (let sample = 0; sample < 3000; sample++) { + let input = '' + for (let token = 0; token < 40; token++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + input += tokens[seed % tokens.length] + } + assert.equal(after(input), before(input), JSON.stringify(input)) + assert.equal(after(input, true), before(input, true), JSON.stringify(input)) +} +function measure(fn, input) { + const samples = [] + for (let run = 0; run < 3; run++) { + const start = performance.now() + fn(input) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[1] +} +const results = [] +for (const lines of [100, 1000, 5000, 10000]) { + const input = 'const x = value / 2;\n'.repeat(lines) + assert.equal(after(input), before(input)) + results.push({ + lines, + bytes: Buffer.byteLength(input), + beforeMs: measure(before, input), + afterMs: measure(after, input) + }) +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: 3000, results }, + null, + 2 + ) +) diff --git a/src/shared/source-scan/source-tree-scan.test.ts b/src/shared/source-scan/source-tree-scan.test.ts index be4b72e4ce9..07ddb52a845 100644 --- a/src/shared/source-scan/source-tree-scan.test.ts +++ b/src/shared/source-scan/source-tree-scan.test.ts @@ -47,6 +47,13 @@ describe('stripComments', () => { }) describe('blankStringContents', () => { + it('does not rescan the accumulated source for each division operator', () => { + const source = 'const x = value / 2;\n'.repeat(10000) + const started = performance.now() + expect(blankStringContents(source)).toBe(source) + expect(performance.now() - started).toBeLessThan(200) + }) + it('neutralises parentheses inside a string so a call is matched whole', () => { // A shell script embedded as a string closed the call early, so the options // object fell outside the match and its flags read as absent. diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index dce7ae1a274..937a2fc39db 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -179,8 +179,7 @@ export function blankStringContentsDesynced(source: string): boolean { * each also has a prefix reading: `!` (non-null assertion vs `!/re/.test(x)`), * `+` `-` `*` `%` `^` `~` (postfix `--`/`++`), and `>` `}` (JSX close). */ -function startsRegexLiteral(emitted: string): boolean { - const prev = emitted.replace(/\s+$/, '').at(-1) +function startsRegexLiteral(prev: string | undefined): boolean { return prev === undefined || '(,=:[&|?;'.includes(prev) } @@ -209,6 +208,7 @@ function findRegexLiteralEnd(source: string, start: number): number { export function blankStringContents(source: string, reportDesync = false): string { let out = '' + let lastSignificantChar: string | undefined let index = 0 let quote: string | null = null // Brace depth per interpolation, so a `}` inside `${ { a: 1 } }` does not @@ -220,6 +220,7 @@ export function blankStringContents(source: string, reportDesync = false): strin templates.push(0) quote = null out += '${' + lastSignificantChar = '{' index += 2 continue } @@ -232,6 +233,7 @@ export function blankStringContents(source: string, reportDesync = false): strin templates.pop() quote = '`' out += char + lastSignificantChar = char index += 1 continue } @@ -256,6 +258,7 @@ export function blankStringContents(source: string, reportDesync = false): strin if (char === quote) { quote = null out += char + lastSignificantChar = char } else { out += char === '\n' ? char : ' ' } @@ -270,10 +273,11 @@ export function blankStringContents(source: string, reportDesync = false): strin // comments first, but this runs standalone too, and at index 0 a file // starting with a banner comment read as one giant regex. const next = source[index + 1] - if (char === '/' && next !== '/' && next !== '*' && startsRegexLiteral(out)) { + if (char === '/' && next !== '/' && next !== '*' && startsRegexLiteral(lastSignificantChar)) { const end = findRegexLiteralEnd(source, index) if (end !== -1) { out += `/${' '.repeat(end - index - 1)}` + lastSignificantChar = '/' index = end continue } @@ -282,6 +286,9 @@ export function blankStringContents(source: string, reportDesync = false): strin quote = char } out += char + if (/\S/.test(char)) { + lastSignificantChar = char + } index += 1 } if (reportDesync) {