Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fuzzyScore #22884

Merged
merged 17 commits into from Mar 20, 2017
107 changes: 107 additions & 0 deletions src/vs/base/common/filters.ts
Expand Up @@ -357,3 +357,110 @@ export function matchesFuzzy(word: string, wordToMatchAgainst: string, enableSep
// Default Filter
return enableSeparateSubstringMatching ? fuzzySeparateFilter(word, wordToMatchAgainst) : fuzzyContiguousFilter(word, wordToMatchAgainst);
}

export function matchesFuzzy2(pattern: string, word: string): number[] {

pattern = pattern.toLowerCase();
word = word.toLowerCase();

let matches: number[] = [];
let patternPos = 0;
let wordPos = 0;
while (patternPos < pattern.length && wordPos < word.length) {
if (pattern[patternPos] === word[wordPos]) {
patternPos += 1;
matches.push(wordPos);
}
wordPos += 1;
}

if (patternPos !== pattern.length) {
return undefined;
}

return matches;
}

export function createMatches(position: number[]): IMatch[] {
let ret: IMatch[] = [];
let last: IMatch;
for (const pos of position) {
if (last && last.end === pos) {
last.end += 1;
} else {
last = { start: pos, end: pos + 1 };
ret.push(last);
}
}
return ret;
}

export function fuzzyMatchAndScore(pattern: string, word: string): [number, number[]] {

if (!pattern) {
return [-1, []];
}

let matches: number[] = [];
let score = _matchRecursive(
pattern, pattern.toLowerCase(), pattern.toUpperCase(), 0,
word, word.toLowerCase(), 0,
matches
);

if (score <= 0) {
return undefined;
}

score -= Math.min(matches[0], 3) * 3; // penalty for first matching character
score -= (1 + matches[matches.length - 1]) - (pattern.length); // penalty for all non matching characters between first and last

return [score, matches];
}

export function _matchRecursive(
pattern: string, lowPattern: string, upPattern: string, patternPos: number,
word: string, lowWord: string, wordPos: number,
matches: number[]
): number {

if (patternPos >= lowPattern.length) {
return 0;
}

const lowPatternChar = lowPattern[patternPos];
let idx = -1;
let value = 0;

if ((patternPos === wordPos
&& lowPatternChar === lowWord[wordPos])
&& ((value = _matchRecursive(pattern, lowPattern, upPattern, patternPos + 1, word, lowWord, wordPos + 1, matches)) >= 0)
) {
matches.unshift(wordPos);
return (pattern[patternPos] === word[wordPos] ? 17 : 11) + value;
}

if ((idx = lowWord.indexOf(`_${lowPatternChar}`, wordPos)) >= 0
&& ((value = _matchRecursive(pattern, lowPattern, upPattern, patternPos + 1, word, lowWord, idx + 2, matches)) >= 0)
) {
matches.unshift(idx + 1);
return (pattern[patternPos] === word[idx + 1] ? 17 : 11) + value;
}

if ((idx = word.indexOf(upPattern[patternPos], wordPos)) >= 0
&& ((value = _matchRecursive(pattern, lowPattern, upPattern, patternPos + 1, word, lowWord, idx + 1, matches)) >= 0)
) {
matches.unshift(idx);
return (pattern[patternPos] === word[idx] ? 17 : 11) + value;
}

if (patternPos > 0
&& (idx = lowWord.indexOf(lowPatternChar, wordPos)) >= 0
&& ((value = _matchRecursive(pattern, lowPattern, upPattern, patternPos + 1, word, lowWord, idx + 1, matches)) >= 0)
) {
matches.unshift(idx);
return 1 + value;
}

return -1;
}
1 change: 1 addition & 0 deletions src/vs/base/test/common/filters.perf.data.json

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions src/vs/base/test/common/filters.perf.test.ts
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';

// import * as assert from 'assert';
import * as filters from 'vs/base/common/filters';

const data = <string[]>require.__$__nodeRequire(require.toUrl('./filters.perf.data.json'));
const patterns = ['cci', 'ida', 'pos', 'CCI', 'enbled', 'callback', 'gGame', 'cons'];

const _enablePerf = false;

function perfSuite(name: string, callback: (this: Mocha.ISuiteCallbackContext) => void) {
if (_enablePerf) {
suite(name, callback);
}
}

perfSuite('Performance - fuzzyMatch', function () {

console.log(`Matching ${data.length} items against ${patterns.length} patterns...`);

function perfTest(name: string, match: (pattern: string, word: string) => any) {
test(name, function () {

const t1 = Date.now();
let count = 0;
for (const pattern of patterns) {
for (const item of data) {
count += 1;
match(pattern, item);
}
}
console.log(name, Date.now() - t1, `${(count / (Date.now() - t1)).toPrecision(6)}/ms`);
});
}

perfTest('matchesFuzzy', filters.matchesFuzzy);
perfTest('fuzzyContiguousFilter', filters.fuzzyContiguousFilter);
perfTest('matchesFuzzy2', filters.matchesFuzzy2);
perfTest('fuzzyMatchAndScore', filters.fuzzyMatchAndScore);

});

97 changes: 96 additions & 1 deletion src/vs/base/test/common/filters.test.ts
Expand Up @@ -5,7 +5,7 @@
'use strict';

import * as assert from 'assert';
import { IFilter, or, matchesPrefix, matchesStrictPrefix, matchesCamelCase, matchesSubString, matchesContiguousSubString, matchesWords } from 'vs/base/common/filters';
import { IFilter, or, matchesPrefix, matchesStrictPrefix, matchesCamelCase, matchesSubString, matchesContiguousSubString, matchesWords, fuzzyMatchAndScore } from 'vs/base/common/filters';

function filterOk(filter: IFilter, word: string, wordToMatchAgainst: string, highlights?: { start: number; end: number; }[]) {
let r = filter(word, wordToMatchAgainst);
Expand Down Expand Up @@ -192,4 +192,99 @@ suite('Filters', () => {
assert.ok(matchesWords('gipu', 'Category: Git: Pull', true) === null);
assert.deepEqual(matchesWords('pu', 'Category: Git: Pull', true), [{ start: 15, end: 17 }]);
});

test('fuzzyMatchAndScore', function () {

function assertMatches(pattern: string, word: string, decoratedWord: string, filter: typeof fuzzyMatchAndScore) {
let r = filter(pattern, word);
assert.ok(Boolean(r) === Boolean(decoratedWord));
if (r) {
const [, matches] = r;
let pos = 0;
for (let i = 0; i < matches.length; i++) {
let actual = matches[i];
let expected = decoratedWord.indexOf('^', pos) - i;
assert.equal(actual, expected);
pos = expected + 1 + i;
}
}
}

assertMatches('no', 'match', undefined, fuzzyMatchAndScore);
assertMatches('no', '', undefined, fuzzyMatchAndScore);
assertMatches('BK', 'the_black_knight', 'the_^black_^knight', fuzzyMatchAndScore);
assertMatches('bkn', 'the_black_knight', 'the_^black_^k^night', fuzzyMatchAndScore);
assertMatches('bt', 'the_black_knight', 'the_^black_knigh^t', fuzzyMatchAndScore);
assertMatches('bti', 'the_black_knight', undefined, fuzzyMatchAndScore);
assertMatches('LLL', 'SVisualLoggerLogsList', 'SVisual^Logger^Logs^List', fuzzyMatchAndScore);
assertMatches('LLLL', 'SVisualLoggerLogsList', undefined, fuzzyMatchAndScore);
assertMatches('sllll', 'SVisualLoggerLogsList', '^SVisua^l^Logger^Logs^List', fuzzyMatchAndScore);
assertMatches('sl', 'SVisualLoggerLogsList', '^SVisual^LoggerLogsList', fuzzyMatchAndScore);
assertMatches('foobar', 'foobar', '^f^o^o^b^a^r', fuzzyMatchAndScore);
assertMatches('fob', 'foobar', '^f^oo^bar', fuzzyMatchAndScore);
assertMatches('ob', 'foobar', undefined, fuzzyMatchAndScore);
assertMatches('gp', 'Git: Pull', '^Git: ^Pull', fuzzyMatchAndScore);
assertMatches('gp', 'Git_Git_Pull', '^Git_Git_^Pull', fuzzyMatchAndScore);
assertMatches('g p', 'Git: Pull', '^Git:^ ^Pull', fuzzyMatchAndScore);
assertMatches('gip', 'Git: Pull', '^G^it: ^Pull', fuzzyMatchAndScore);
assertMatches('is', 'isValid', '^i^sValid', fuzzyMatchAndScore);
assertMatches('is', 'ImportStatement', '^Import^Statement', fuzzyMatchAndScore);
assertMatches('lowrd', 'lowWord', '^l^o^wWo^r^d', fuzzyMatchAndScore);
assertMatches('ccm', 'cacmelCase', '^ca^c^melCase', fuzzyMatchAndScore);
assertMatches('ccm', 'camelCase', undefined, fuzzyMatchAndScore);
assertMatches('ccm', 'camelCasecm', '^camel^Casec^m', fuzzyMatchAndScore);
assertMatches('myvable', 'myvariable', '^m^y^v^aria^b^l^e', fuzzyMatchAndScore);
assertMatches('fdm', 'findModel', '^fin^d^Model', fuzzyMatchAndScore);
});

test('topScore', function () {

function assertTopScore(pattern: string, expected: number, ...words: string[]) {
let topScore = Number.MIN_VALUE;
let topIdx = 0;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const m = fuzzyMatchAndScore(pattern, word);
if (m) {
const [score] = m;
if (score > topScore) {
topScore = score;
topIdx = i;
}
}
}
assert.equal(topIdx, expected);
}

assertTopScore('cons', 2, 'ArrayBufferConstructor', 'Console', 'console');
assertTopScore('Foo', 1, 'foo', 'Foo', 'foo');

assertTopScore('CC', 1, 'camelCase', 'CamelCase');
assertTopScore('cC', 0, 'camelCase', 'CamelCase');
assertTopScore('cC', 1, 'ccfoo', 'camelCase');
assertTopScore('cC', 1, 'ccfoo', 'camelCase', 'foo-cC-bar');

// issue #17836
assertTopScore('p', 0, 'parse', 'posix', 'pafdsa', 'path', 'p');
assertTopScore('pa', 0, 'parse', 'pafdsa', 'path');

// issue #14583
assertTopScore('log', 3, 'HTMLOptGroupElement', 'ScrollLogicalPosition', 'SVGFEMorphologyElement', 'log');
assertTopScore('e', 2, 'AbstractWorker', 'ActiveXObject', 'else');

// issue #14446
assertTopScore('workbench.sideb', 1, 'workbench.editor.defaultSideBySideLayout', 'workbench.sideBar.location');

// issue #11423
assertTopScore('editor.r', 2, 'diffEditor.renderSideBySide', 'editor.overviewRulerlanes', 'editor.renderControlCharacter', 'editor.renderWhitespace');
// assertTopScore('editor.R', 1, 'diffEditor.renderSideBySide', 'editor.overviewRulerlanes', 'editor.renderControlCharacter', 'editor.renderWhitespace');
// assertTopScore('Editor.r', 0, 'diffEditor.renderSideBySide', 'editor.overviewRulerlanes', 'editor.renderControlCharacter', 'editor.renderWhitespace');

assertTopScore('-mo', 1, '-ms-ime-mode', '-moz-columns');
// // dupe, issue #14861
assertTopScore('convertModelPosition', 0, 'convertModelPositionToViewPosition', 'convertViewToModelPosition');
// // dupe, issue #14942
assertTopScore('is', 0, 'isValidViewletId', 'import statement');

});
});
5 changes: 3 additions & 2 deletions src/vs/editor/contrib/suggest/browser/suggestWidget.ts
Expand Up @@ -7,6 +7,7 @@

import 'vs/css!./suggest';
import * as nls from 'vs/nls';
import { createMatches } from 'vs/base/common/filters';
import * as strings from 'vs/base/common/strings';
import Event, { Emitter, chain } from 'vs/base/common/event';
import { TPromise } from 'vs/base/common/winjs.base';
Expand Down Expand Up @@ -136,7 +137,7 @@ class Renderer implements IRenderer<ICompletionItem, ISuggestionTemplateData> {
}
}

data.highlightedLabel.set(suggestion.label, element.highlights);
data.highlightedLabel.set(suggestion.label, createMatches(element.matches));
data.typeLabel.textContent = (suggestion.detail || '').replace(/\n.*$/m, '');

data.documentation.textContent = suggestion.documentation || '';
Expand Down Expand Up @@ -234,7 +235,7 @@ class SuggestionDetails {
return;
}

this.titleLabel.set(item.suggestion.label, item.highlights);
this.titleLabel.set(item.suggestion.label, createMatches(item.matches));
this.type.innerText = item.suggestion.detail || '';
this.docs.textContent = item.suggestion.documentation;
this.back.onmousedown = e => {
Expand Down