Skip to content

Commit c1c90f8

Browse files
author
Benjamin Pasero
committed
quick access - allow to match on multiple inputs (fix microsoft#30404)
1 parent fb11f14 commit c1c90f8

8 files changed

Lines changed: 248 additions & 64 deletions

File tree

src/vs/base/common/fuzzyScorer.ts

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { sep } from 'vs/base/common/path';
99
import { isWindows, isLinux } from 'vs/base/common/platform';
1010
import { stripWildcards, equalsIgnoreCase } from 'vs/base/common/strings';
1111
import { CharCode } from 'vs/base/common/charCode';
12+
import { distinctES6 } from 'vs/base/common/arrays';
1213

1314
export type Score = [number /* score */, number[] /* match positions */];
1415
export type ScorerCache = { [key: string]: IItemScore };
@@ -19,7 +20,40 @@ const NO_SCORE: Score = [NO_MATCH, []];
1920
// const DEBUG = false;
2021
// const DEBUG_MATRIX = false;
2122

22-
export function score(target: string, query: string, queryLower: string, fuzzy: boolean): Score {
23+
export function score(target: string, query: IPreparedQuery, fuzzy: boolean): Score {
24+
if (query.values && query.values.length > 1) {
25+
return scoreMultiple(target, query.values, fuzzy);
26+
}
27+
28+
return scoreSingle(target, query.value, query.valueLowercase, fuzzy);
29+
}
30+
31+
function scoreMultiple(target: string, query: IPreparedQueryPiece[], fuzzy: boolean): Score {
32+
let totalScore = NO_MATCH;
33+
const totalPositions: number[] = [];
34+
35+
for (const { value, valueLowercase } of query) {
36+
const [scoreValue, positions] = scoreSingle(target, value, valueLowercase, fuzzy);
37+
if (scoreValue === NO_MATCH) {
38+
// if a single query value does not match, return with
39+
// no score entirely, we require all queries to match
40+
return NO_SCORE;
41+
}
42+
43+
totalScore += scoreValue;
44+
totalPositions.push(...positions);
45+
}
46+
47+
if (totalScore === NO_MATCH) {
48+
return NO_SCORE;
49+
}
50+
51+
// if we have a score, ensure that the positions are
52+
// sorted in ascending order and distinct
53+
return [totalScore, distinctES6(totalPositions).sort((a, b) => a - b)];
54+
}
55+
56+
function scoreSingle(target: string, query: string, queryLower: string, fuzzy: boolean): Score {
2357
if (!target || !query) {
2458
return NO_SCORE; // return early if target or query are undefined
2559
}
@@ -303,32 +337,70 @@ const LABEL_PREFIX_SCORE = 1 << 17;
303337
const LABEL_CAMELCASE_SCORE = 1 << 16;
304338
const LABEL_SCORE_THRESHOLD = 1 << 15;
305339

306-
export interface IPreparedQuery {
340+
export interface IPreparedQueryPiece {
307341
original: string;
342+
originalLowercase: string;
343+
308344
value: string;
309-
lowercase: string;
345+
valueLowercase: string;
346+
}
347+
348+
export interface IPreparedQuery extends IPreparedQueryPiece {
349+
350+
// Split by spaces
351+
values: IPreparedQueryPiece[] | undefined;
352+
310353
containsPathSeparator: boolean;
311354
}
312355

313356
/**
314-
* Helper function to prepare a search value for scoring by removing unwanted characters.
357+
* Helper function to prepare a search value for scoring by removing unwanted characters
358+
* and allowing to score on multiple pieces separated by whitespace character.
315359
*/
360+
const MULTIPL_QUERY_VALUES_SEPARATOR = ' ';
316361
export function prepareQuery(original: string): IPreparedQuery {
317-
if (!original) {
362+
if (typeof original !== 'string') {
318363
original = '';
319364
}
320365

366+
const originalLowercase = original.toLowerCase();
367+
const value = prepareQueryValue(original);
368+
const valueLowercase = value.toLowerCase();
369+
const containsPathSeparator = value.indexOf(sep) >= 0;
370+
371+
let values: IPreparedQueryPiece[] | undefined = undefined;
372+
373+
const originalSplit = original.split(MULTIPL_QUERY_VALUES_SEPARATOR);
374+
if (originalSplit.length > 1) {
375+
for (const originalPiece of originalSplit) {
376+
const valuePiece = prepareQueryValue(originalPiece);
377+
if (valuePiece) {
378+
if (!values) {
379+
values = [];
380+
}
381+
382+
values.push({
383+
original: originalPiece,
384+
originalLowercase: originalPiece.toLowerCase(),
385+
value: valuePiece,
386+
valueLowercase: valuePiece.toLowerCase()
387+
});
388+
}
389+
}
390+
}
391+
392+
return { original, originalLowercase, value, valueLowercase, values, containsPathSeparator };
393+
}
394+
395+
function prepareQueryValue(original: string): string {
321396
let value = stripWildcards(original).replace(/\s/g, ''); // get rid of all wildcards and whitespace
322397
if (isWindows) {
323398
value = value.replace(/\//g, sep); // Help Windows users to search for paths when using slash
324399
} else {
325400
value = value.replace(/\\/g, sep); // Help macOS/Linux users to search for paths when using backslash
326401
}
327402

328-
const lowercase = value.toLowerCase();
329-
const containsPathSeparator = value.indexOf(sep) >= 0;
330-
331-
return { original, value, lowercase, containsPathSeparator };
403+
return value;
332404
}
333405

334406
export function scoreItem<T>(item: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor<T>, cache: ScorerCache): IItemScore {
@@ -404,7 +476,7 @@ function doScoreItem(label: string, description: string | undefined, path: strin
404476
}
405477

406478
// 4.) prefer scores on the label if any
407-
const [labelScore, labelPositions] = score(label, query.value, query.lowercase, fuzzy);
479+
const [labelScore, labelPositions] = score(label, query, fuzzy);
408480
if (labelScore) {
409481
return { score: labelScore + LABEL_SCORE_THRESHOLD, labelMatch: createMatches(labelPositions) };
410482
}
@@ -420,7 +492,7 @@ function doScoreItem(label: string, description: string | undefined, path: strin
420492
const descriptionPrefixLength = descriptionPrefix.length;
421493
const descriptionAndLabel = `${descriptionPrefix}${label}`;
422494

423-
const [labelDescriptionScore, labelDescriptionPositions] = score(descriptionAndLabel, query.value, query.lowercase, fuzzy);
495+
const [labelDescriptionScore, labelDescriptionPositions] = score(descriptionAndLabel, query, fuzzy);
424496
if (labelDescriptionScore) {
425497
const labelDescriptionMatches = createMatches(labelDescriptionPositions);
426498
const labelMatch: IMatch[] = [];

src/vs/base/test/common/fuzzyScorer.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class NullAccessorClass implements scorer.IItemAccessor<URI> {
4343
}
4444

4545
function _doScore(target: string, query: string, fuzzy: boolean): scorer.Score {
46-
return scorer.score(target, query, query.toLowerCase(), fuzzy);
46+
return scorer.score(target, scorer.prepareQuery(query), fuzzy);
4747
}
4848

4949
function scoreItem<T>(item: T, query: string, fuzzy: boolean, accessor: scorer.IItemAccessor<T>, cache: scorer.ScorerCache): scorer.IItemScore {
@@ -109,6 +109,42 @@ suite('Fuzzy Scorer', () => {
109109
assert.equal(_doScore(target, 'eo', false)[0], 0);
110110
});
111111

112+
test('score (fuzzy, multiple)', function () {
113+
const target = 'HeLlo-World';
114+
115+
const [firstSingleScore, firstSinglePositions] = _doScore(target, 'HelLo', true);
116+
const [secondSingleScore, secondSinglePositions] = _doScore(target, 'World', true);
117+
const firstAndSecondSinglePositions = [...firstSinglePositions, ...secondSinglePositions];
118+
119+
let [multiScore, multiPositions] = _doScore(target, 'HelLo World', true);
120+
121+
function assertScore() {
122+
assert.ok(multiScore >= firstSingleScore + secondSingleScore);
123+
for (let i = 0; i < multiPositions.length; i++) {
124+
assert.equal(multiPositions[i], firstAndSecondSinglePositions[i]);
125+
}
126+
}
127+
128+
function assertNoScore() {
129+
assert.equal(multiScore, 0);
130+
assert.equal(multiPositions.length, 0);
131+
}
132+
133+
assertScore();
134+
135+
[multiScore, multiPositions] = _doScore(target, 'World HelLo', true);
136+
assertScore();
137+
138+
[multiScore, multiPositions] = _doScore(target, 'World HelLo World', true);
139+
assertScore();
140+
141+
[multiScore, multiPositions] = _doScore(target, 'World HelLo Nothing', true);
142+
assertNoScore();
143+
144+
[multiScore, multiPositions] = _doScore(target, 'More Nothing', true);
145+
assertNoScore();
146+
});
147+
112148
test('scoreItem - matches are proper', function () {
113149
let res = scoreItem(null, 'something', true, ResourceAccessor, cache);
114150
assert.ok(!res.score);
@@ -820,11 +856,42 @@ suite('Fuzzy Scorer', () => {
820856
assert.equal(res[0], resourceB);
821857
});
822858

823-
test('prepareSearchForScoring', () => {
859+
test('prepareQuery', () => {
824860
assert.equal(scorer.prepareQuery(' f*a ').value, 'fa');
861+
assert.equal(scorer.prepareQuery('model Tester.ts').original, 'model Tester.ts');
862+
assert.equal(scorer.prepareQuery('model Tester.ts').originalLowercase, 'model Tester.ts'.toLowerCase());
825863
assert.equal(scorer.prepareQuery('model Tester.ts').value, 'modelTester.ts');
826-
assert.equal(scorer.prepareQuery('Model Tester.ts').lowercase, 'modeltester.ts');
864+
assert.equal(scorer.prepareQuery('Model Tester.ts').valueLowercase, 'modeltester.ts');
827865
assert.equal(scorer.prepareQuery('ModelTester.ts').containsPathSeparator, false);
828866
assert.equal(scorer.prepareQuery('Model' + sep + 'Tester.ts').containsPathSeparator, true);
867+
868+
// with spaces
869+
let query = scorer.prepareQuery('He*llo World');
870+
assert.equal(query.original, 'He*llo World');
871+
assert.equal(query.value, 'HelloWorld');
872+
assert.equal(query.valueLowercase, 'HelloWorld'.toLowerCase());
873+
assert.equal(query.values?.length, 2);
874+
assert.equal(query.values?.[0].original, 'He*llo');
875+
assert.equal(query.values?.[0].value, 'Hello');
876+
assert.equal(query.values?.[0].valueLowercase, 'Hello'.toLowerCase());
877+
assert.equal(query.values?.[1].original, 'World');
878+
assert.equal(query.values?.[1].value, 'World');
879+
assert.equal(query.values?.[1].valueLowercase, 'World'.toLowerCase());
880+
881+
// with spaces that are empty
882+
query = scorer.prepareQuery(' Hello World ');
883+
assert.equal(query.original, ' Hello World ');
884+
assert.equal(query.originalLowercase, ' Hello World '.toLowerCase());
885+
assert.equal(query.value, 'HelloWorld');
886+
assert.equal(query.valueLowercase, 'HelloWorld'.toLowerCase());
887+
assert.equal(query.values?.length, 2);
888+
assert.equal(query.values?.[0].original, 'Hello');
889+
assert.equal(query.values?.[0].originalLowercase, 'Hello'.toLowerCase());
890+
assert.equal(query.values?.[0].value, 'Hello');
891+
assert.equal(query.values?.[0].valueLowercase, 'Hello'.toLowerCase());
892+
assert.equal(query.values?.[1].original, 'World');
893+
assert.equal(query.values?.[1].originalLowercase, 'World'.toLowerCase());
894+
assert.equal(query.values?.[1].value, 'World');
895+
assert.equal(query.values?.[1].valueLowercase, 'World'.toLowerCase());
829896
});
830897
});

src/vs/editor/contrib/quickAccess/gotoSymbolQuickAccess.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { values } from 'vs/base/common/collections';
1717
import { trim, format } from 'vs/base/common/strings';
1818
import { fuzzyScore, FuzzyScore, createMatches } from 'vs/base/common/filters';
1919
import { assign } from 'vs/base/common/objects';
20+
import { prepareQuery, IPreparedQuery } from 'vs/base/common/fuzzyScorer';
2021

2122
export interface IGotoSymbolQuickPickItem extends IQuickPickItem {
2223
kind: SymbolKind,
@@ -155,7 +156,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit
155156
// Collect symbol picks
156157
picker.busy = true;
157158
try {
158-
const items = await this.doGetSymbolPicks(symbolsPromise, picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim(), picksCts.token);
159+
const items = await this.doGetSymbolPicks(symbolsPromise, prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim()), picksCts.token);
159160
if (token.isCancellationRequested) {
160161
return;
161162
}
@@ -194,18 +195,24 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit
194195
return disposables;
195196
}
196197

197-
protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, filter: string, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
198+
protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, query: IPreparedQuery, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
198199
const symbols = await symbolsPromise;
199200
if (token.isCancellationRequested) {
200201
return [];
201202
}
202203

203-
// Normalize filter
204-
const filterBySymbolKind = filter.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
204+
const filterBySymbolKind = query.original.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
205205
const filterPos = filterBySymbolKind ? 1 : 0;
206-
const [symbolFilter, containerFilter] = filter.split(' ') as [string, string | undefined];
207-
const symbolFilterLow = symbolFilter.toLowerCase();
208-
const containerFilterLow = containerFilter?.toLowerCase();
206+
207+
// Split between symbol and container query if separated by space
208+
let symbolQuery: IPreparedQuery;
209+
let containerQuery: IPreparedQuery | undefined;
210+
if (query.values && query.values.length > 1) {
211+
symbolQuery = prepareQuery(query.values[0].original);
212+
containerQuery = prepareQuery(query.values[1].original);
213+
} else {
214+
symbolQuery = query;
215+
}
209216

210217
// Convert to symbol picks and apply filtering
211218
const filteredSymbolPicks: IGotoSymbolQuickPickItem[] = [];
@@ -219,16 +226,16 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit
219226
let containerScore: FuzzyScore | undefined = undefined;
220227

221228
let includeSymbol = true;
222-
if (filter.length > filterPos) {
229+
if (query.original.length > filterPos) {
223230

224231
// Score by symbol
225-
symbolScore = fuzzyScore(symbolFilter, symbolFilterLow, filterPos, symbolLabel, symbolLabel.toLowerCase(), 0, true);
232+
symbolScore = fuzzyScore(symbolQuery.original, symbolQuery.originalLowercase, filterPos, symbolLabel, symbolLabel.toLowerCase(), 0, true);
226233
includeSymbol = !!symbolScore;
227234

228235
// Score by container if specified
229-
if (includeSymbol && containerFilter && containerFilterLow) {
236+
if (includeSymbol && containerQuery) {
230237
if (containerLabel) {
231-
containerScore = fuzzyScore(containerFilter, containerFilterLow, filterPos, containerLabel, containerLabel.toLowerCase(), 0, true);
238+
containerScore = fuzzyScore(containerQuery.original, containerQuery.originalLowercase, filterPos, containerLabel, containerLabel.toLowerCase(), 0, true);
232239
}
233240

234241
includeSymbol = !!containerScore;

src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Action } from 'vs/base/common/actions';
2121
import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/workbench/common/actions';
2222
import { SyncActionDescriptor } from 'vs/platform/actions/common/actions';
2323
import { KeyMod, KeyCode } from 'vs/base/common/keyCodes';
24+
import { prepareQuery } from 'vs/base/common/fuzzyScorer';
2425

2526
export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccessProvider {
2627

@@ -85,7 +86,7 @@ export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccess
8586
return [];
8687
}
8788

88-
return this.doGetSymbolPicks(this.getDocumentSymbols(model, true, token), filter, token);
89+
return this.doGetSymbolPicks(this.getDocumentSymbols(model, true, token), prepareQuery(filter), token);
8990
}
9091

9192
addDecorations(editor: IEditor, range: IRange): void {

0 commit comments

Comments
 (0)