From e3b72e17ec206c960e81a1d376cd47e9c39cd42e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:12:58 -0700 Subject: [PATCH] perf: skip nonmatching spans in quick open fuzzy scoring (#20349) --- src/shared/quick-open-fuzzy-scan.test.ts | 118 +++++++++++++++++++++++ src/shared/quick-open-path-search.ts | 9 +- 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/shared/quick-open-fuzzy-scan.test.ts diff --git a/src/shared/quick-open-fuzzy-scan.test.ts b/src/shared/quick-open-fuzzy-scan.test.ts new file mode 100644 index 00000000000..db209bfe471 --- /dev/null +++ b/src/shared/quick-open-fuzzy-scan.test.ts @@ -0,0 +1,118 @@ +import { expect, it } from 'vitest' +import { compareFileNames } from './file-name-sort' +import { + prepareQuickOpenFiles, + QuickOpenPathRanker, + rankQuickOpenFiles, + type QuickOpenIndexedFile +} from './quick-open-path-search' + +function referenceScore(query: string, file: QuickOpenIndexedFile): number { + let qi = 0 + let score = 0 + let lastMatch = -1 + for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { + if (file.lowerPath[ti] !== query[qi]) { + continue + } + score += lastMatch === -1 ? 0 : ti - lastMatch - 1 + if (ti > 0 && '/.-'.includes(file.lowerPath[ti - 1])) { + score -= 5 + } + lastMatch = ti + qi++ + } + if (qi < query.length) { + return -1 + } + return score - (file.lowerFilename.includes(query) ? 100 : 0) +} + +it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => { + const [prepared] = prepareQuickOpenFiles([`a/${'x'.repeat(4000)}/z.ts`]) + let pathReads = 0 + const measured = { + ...prepared, + get lowerPath() { + pathReads++ + return prepared.lowerPath + } + } + expect(rankQuickOpenFiles(query, [measured])).toEqual(rankQuickOpenFiles(query, [prepared])) + expect(pathReads).toBeLessThanOrEqual(20) +}) + +it('preserves code-unit scores and ordering for generated Unicode paths and queries', () => { + const alphabet = [ + 'a', + 'b', + 'c', + '/', + '\\', + '.', + '-', + '2', + '0', + 'é', + 'e\u0301', + 'İ', + '😀', + '\ud800', + '\udc00' + ] + let seed = 43 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + const paths = ['a.../b', 'a/x/z', 'a/z', '😀.ts', 'a'.repeat(110), 'aa', 'same', 'same', ''] + for (let index = 0; index < 1000; index++) { + const length = next() % 80 + let path = '' + for (let offset = 0; offset < length; offset++) { + path += alphabet[next() % alphabet.length] + } + paths.push(path) + } + const files = prepareQuickOpenFiles(paths) + const queries = [ + '', + ' ', + 'az', + 'ab', + 'a', + 'aa', + 'q', + '😀', + '\ud800', + '\udc00', + 'é', + 'e\u0301', + 'İ', + 'a\\b', + ' A ' + ] + for (let index = 0; index < 100; index++) { + queries.push(`${alphabet[next() % alphabet.length]}${alphabet[next() % alphabet.length]}`) + } + for (const query of queries) { + const normalized = query.trim().replace(/\\/g, '/').toLowerCase() + const expected = files + .map((file) => ({ ...file, score: normalized ? referenceScore(normalized, file) : 0 })) + .filter((file) => file.score !== -1) + .sort( + (a, b) => + a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex + ) + for (const limit of [1, 16, 50]) { + const selected = expected.slice(0, limit).map(({ path, score }) => ({ path, score })) + expect(rankQuickOpenFiles(query, files, limit)).toEqual(selected) + const streamed = new QuickOpenPathRanker(query, limit) + paths.forEach((path) => streamed.consider(path)) + expect(streamed.result()).toEqual({ + paths: selected.map((entry) => entry.path), + totalCount: expected.length + }) + } + } +}) diff --git a/src/shared/quick-open-path-search.ts b/src/shared/quick-open-path-search.ts index 5680abf8286..2e2dbff4d84 100644 --- a/src/shared/quick-open-path-search.ts +++ b/src/shared/quick-open-path-search.ts @@ -126,9 +126,12 @@ function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): numbe let score = 0 let lastMatchIdx = -1 - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] !== query[qi]) { - continue + while (qi < query.length) { + const next = lastMatchIdx + 1 + const ti = + file.lowerPath[next] === query[qi] ? next : file.lowerPath.indexOf(query[qi], next + 1) + if (ti === -1) { + return -1 } const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 score += gap