Skip to content

Commit

Permalink
quick access - allow to match on multiple inputs (fix #30404)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpasero committed Mar 27, 2020
1 parent fb11f14 commit c1c90f8
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 64 deletions.
94 changes: 83 additions & 11 deletions src/vs/base/common/fuzzyScorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { sep } from 'vs/base/common/path';
import { isWindows, isLinux } from 'vs/base/common/platform';
import { stripWildcards, equalsIgnoreCase } from 'vs/base/common/strings';
import { CharCode } from 'vs/base/common/charCode';
import { distinctES6 } from 'vs/base/common/arrays';

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

export function score(target: string, query: string, queryLower: string, fuzzy: boolean): Score {
export function score(target: string, query: IPreparedQuery, fuzzy: boolean): Score {
if (query.values && query.values.length > 1) {
return scoreMultiple(target, query.values, fuzzy);
}

return scoreSingle(target, query.value, query.valueLowercase, fuzzy);
}

function scoreMultiple(target: string, query: IPreparedQueryPiece[], fuzzy: boolean): Score {
let totalScore = NO_MATCH;
const totalPositions: number[] = [];

for (const { value, valueLowercase } of query) {
const [scoreValue, positions] = scoreSingle(target, value, valueLowercase, fuzzy);
if (scoreValue === NO_MATCH) {
// if a single query value does not match, return with
// no score entirely, we require all queries to match
return NO_SCORE;
}

totalScore += scoreValue;
totalPositions.push(...positions);
}

if (totalScore === NO_MATCH) {
return NO_SCORE;
}

// if we have a score, ensure that the positions are
// sorted in ascending order and distinct
return [totalScore, distinctES6(totalPositions).sort((a, b) => a - b)];
}

function scoreSingle(target: string, query: string, queryLower: string, fuzzy: boolean): Score {
if (!target || !query) {
return NO_SCORE; // return early if target or query are undefined
}
Expand Down Expand Up @@ -303,32 +337,70 @@ const LABEL_PREFIX_SCORE = 1 << 17;
const LABEL_CAMELCASE_SCORE = 1 << 16;
const LABEL_SCORE_THRESHOLD = 1 << 15;

export interface IPreparedQuery {
export interface IPreparedQueryPiece {
original: string;
originalLowercase: string;

value: string;
lowercase: string;
valueLowercase: string;
}

export interface IPreparedQuery extends IPreparedQueryPiece {

// Split by spaces
values: IPreparedQueryPiece[] | undefined;

containsPathSeparator: boolean;
}

/**
* Helper function to prepare a search value for scoring by removing unwanted characters.
* Helper function to prepare a search value for scoring by removing unwanted characters
* and allowing to score on multiple pieces separated by whitespace character.
*/
const MULTIPL_QUERY_VALUES_SEPARATOR = ' ';
export function prepareQuery(original: string): IPreparedQuery {
if (!original) {
if (typeof original !== 'string') {
original = '';
}

const originalLowercase = original.toLowerCase();
const value = prepareQueryValue(original);
const valueLowercase = value.toLowerCase();
const containsPathSeparator = value.indexOf(sep) >= 0;

let values: IPreparedQueryPiece[] | undefined = undefined;

const originalSplit = original.split(MULTIPL_QUERY_VALUES_SEPARATOR);
if (originalSplit.length > 1) {
for (const originalPiece of originalSplit) {
const valuePiece = prepareQueryValue(originalPiece);
if (valuePiece) {
if (!values) {
values = [];
}

values.push({
original: originalPiece,
originalLowercase: originalPiece.toLowerCase(),
value: valuePiece,
valueLowercase: valuePiece.toLowerCase()
});
}
}
}

return { original, originalLowercase, value, valueLowercase, values, containsPathSeparator };
}

function prepareQueryValue(original: string): string {
let value = stripWildcards(original).replace(/\s/g, ''); // get rid of all wildcards and whitespace
if (isWindows) {
value = value.replace(/\//g, sep); // Help Windows users to search for paths when using slash
} else {
value = value.replace(/\\/g, sep); // Help macOS/Linux users to search for paths when using backslash
}

const lowercase = value.toLowerCase();
const containsPathSeparator = value.indexOf(sep) >= 0;

return { original, value, lowercase, containsPathSeparator };
return value;
}

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

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

const [labelDescriptionScore, labelDescriptionPositions] = score(descriptionAndLabel, query.value, query.lowercase, fuzzy);
const [labelDescriptionScore, labelDescriptionPositions] = score(descriptionAndLabel, query, fuzzy);
if (labelDescriptionScore) {
const labelDescriptionMatches = createMatches(labelDescriptionPositions);
const labelMatch: IMatch[] = [];
Expand Down
73 changes: 70 additions & 3 deletions src/vs/base/test/common/fuzzyScorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class NullAccessorClass implements scorer.IItemAccessor<URI> {
}

function _doScore(target: string, query: string, fuzzy: boolean): scorer.Score {
return scorer.score(target, query, query.toLowerCase(), fuzzy);
return scorer.score(target, scorer.prepareQuery(query), fuzzy);
}

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

test('score (fuzzy, multiple)', function () {
const target = 'HeLlo-World';

const [firstSingleScore, firstSinglePositions] = _doScore(target, 'HelLo', true);
const [secondSingleScore, secondSinglePositions] = _doScore(target, 'World', true);
const firstAndSecondSinglePositions = [...firstSinglePositions, ...secondSinglePositions];

let [multiScore, multiPositions] = _doScore(target, 'HelLo World', true);

function assertScore() {
assert.ok(multiScore >= firstSingleScore + secondSingleScore);
for (let i = 0; i < multiPositions.length; i++) {
assert.equal(multiPositions[i], firstAndSecondSinglePositions[i]);
}
}

function assertNoScore() {
assert.equal(multiScore, 0);
assert.equal(multiPositions.length, 0);
}

assertScore();

[multiScore, multiPositions] = _doScore(target, 'World HelLo', true);
assertScore();

[multiScore, multiPositions] = _doScore(target, 'World HelLo World', true);
assertScore();

[multiScore, multiPositions] = _doScore(target, 'World HelLo Nothing', true);
assertNoScore();

[multiScore, multiPositions] = _doScore(target, 'More Nothing', true);
assertNoScore();
});

test('scoreItem - matches are proper', function () {
let res = scoreItem(null, 'something', true, ResourceAccessor, cache);
assert.ok(!res.score);
Expand Down Expand Up @@ -820,11 +856,42 @@ suite('Fuzzy Scorer', () => {
assert.equal(res[0], resourceB);
});

test('prepareSearchForScoring', () => {
test('prepareQuery', () => {
assert.equal(scorer.prepareQuery(' f*a ').value, 'fa');
assert.equal(scorer.prepareQuery('model Tester.ts').original, 'model Tester.ts');
assert.equal(scorer.prepareQuery('model Tester.ts').originalLowercase, 'model Tester.ts'.toLowerCase());
assert.equal(scorer.prepareQuery('model Tester.ts').value, 'modelTester.ts');
assert.equal(scorer.prepareQuery('Model Tester.ts').lowercase, 'modeltester.ts');
assert.equal(scorer.prepareQuery('Model Tester.ts').valueLowercase, 'modeltester.ts');
assert.equal(scorer.prepareQuery('ModelTester.ts').containsPathSeparator, false);
assert.equal(scorer.prepareQuery('Model' + sep + 'Tester.ts').containsPathSeparator, true);

// with spaces
let query = scorer.prepareQuery('He*llo World');
assert.equal(query.original, 'He*llo World');
assert.equal(query.value, 'HelloWorld');
assert.equal(query.valueLowercase, 'HelloWorld'.toLowerCase());
assert.equal(query.values?.length, 2);
assert.equal(query.values?.[0].original, 'He*llo');
assert.equal(query.values?.[0].value, 'Hello');
assert.equal(query.values?.[0].valueLowercase, 'Hello'.toLowerCase());
assert.equal(query.values?.[1].original, 'World');
assert.equal(query.values?.[1].value, 'World');
assert.equal(query.values?.[1].valueLowercase, 'World'.toLowerCase());

// with spaces that are empty
query = scorer.prepareQuery(' Hello World ');
assert.equal(query.original, ' Hello World ');
assert.equal(query.originalLowercase, ' Hello World '.toLowerCase());
assert.equal(query.value, 'HelloWorld');
assert.equal(query.valueLowercase, 'HelloWorld'.toLowerCase());
assert.equal(query.values?.length, 2);
assert.equal(query.values?.[0].original, 'Hello');
assert.equal(query.values?.[0].originalLowercase, 'Hello'.toLowerCase());
assert.equal(query.values?.[0].value, 'Hello');
assert.equal(query.values?.[0].valueLowercase, 'Hello'.toLowerCase());
assert.equal(query.values?.[1].original, 'World');
assert.equal(query.values?.[1].originalLowercase, 'World'.toLowerCase());
assert.equal(query.values?.[1].value, 'World');
assert.equal(query.values?.[1].valueLowercase, 'World'.toLowerCase());
});
});
29 changes: 18 additions & 11 deletions src/vs/editor/contrib/quickAccess/gotoSymbolQuickAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { values } from 'vs/base/common/collections';
import { trim, format } from 'vs/base/common/strings';
import { fuzzyScore, FuzzyScore, createMatches } from 'vs/base/common/filters';
import { assign } from 'vs/base/common/objects';
import { prepareQuery, IPreparedQuery } from 'vs/base/common/fuzzyScorer';

export interface IGotoSymbolQuickPickItem extends IQuickPickItem {
kind: SymbolKind,
Expand Down Expand Up @@ -155,7 +156,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit
// Collect symbol picks
picker.busy = true;
try {
const items = await this.doGetSymbolPicks(symbolsPromise, picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim(), picksCts.token);
const items = await this.doGetSymbolPicks(symbolsPromise, prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim()), picksCts.token);
if (token.isCancellationRequested) {
return;
}
Expand Down Expand Up @@ -194,18 +195,24 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit
return disposables;
}

protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, filter: string, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, query: IPreparedQuery, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
const symbols = await symbolsPromise;
if (token.isCancellationRequested) {
return [];
}

// Normalize filter
const filterBySymbolKind = filter.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
const filterBySymbolKind = query.original.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
const filterPos = filterBySymbolKind ? 1 : 0;
const [symbolFilter, containerFilter] = filter.split(' ') as [string, string | undefined];
const symbolFilterLow = symbolFilter.toLowerCase();
const containerFilterLow = containerFilter?.toLowerCase();

// Split between symbol and container query if separated by space
let symbolQuery: IPreparedQuery;
let containerQuery: IPreparedQuery | undefined;
if (query.values && query.values.length > 1) {
symbolQuery = prepareQuery(query.values[0].original);
containerQuery = prepareQuery(query.values[1].original);
} else {
symbolQuery = query;
}

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

let includeSymbol = true;
if (filter.length > filterPos) {
if (query.original.length > filterPos) {

// Score by symbol
symbolScore = fuzzyScore(symbolFilter, symbolFilterLow, filterPos, symbolLabel, symbolLabel.toLowerCase(), 0, true);
symbolScore = fuzzyScore(symbolQuery.original, symbolQuery.originalLowercase, filterPos, symbolLabel, symbolLabel.toLowerCase(), 0, true);
includeSymbol = !!symbolScore;

// Score by container if specified
if (includeSymbol && containerFilter && containerFilterLow) {
if (includeSymbol && containerQuery) {
if (containerLabel) {
containerScore = fuzzyScore(containerFilter, containerFilterLow, filterPos, containerLabel, containerLabel.toLowerCase(), 0, true);
containerScore = fuzzyScore(containerQuery.original, containerQuery.originalLowercase, filterPos, containerLabel, containerLabel.toLowerCase(), 0, true);
}

includeSymbol = !!containerScore;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Action } from 'vs/base/common/actions';
import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/workbench/common/actions';
import { SyncActionDescriptor } from 'vs/platform/actions/common/actions';
import { KeyMod, KeyCode } from 'vs/base/common/keyCodes';
import { prepareQuery } from 'vs/base/common/fuzzyScorer';

export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccessProvider {

Expand Down Expand Up @@ -85,7 +86,7 @@ export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccess
return [];
}

return this.doGetSymbolPicks(this.getDocumentSymbols(model, true, token), filter, token);
return this.doGetSymbolPicks(this.getDocumentSymbols(model, true, token), prepareQuery(filter), token);
}

addDecorations(editor: IEditor, range: IRange): void {
Expand Down
Loading

0 comments on commit c1c90f8

Please sign in to comment.