Skip to content
This repository has been archived by the owner on Nov 6, 2019. It is now read-only.

Feature improve fuzzymatch #264

Merged
merged 8 commits into from
May 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions examples/example-dockpanel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,34 @@ function main(): void {
}
});

commands.addCommand('example:clear-cell', {
label: 'Clear Cell',
execute: () => {
console.log('Clear Cell');
}
});

commands.addCommand('example:cut-cells', {
label: 'Cut Cell(s)',
execute: () => {
console.log('Cut Cell(s)');
}
});

commands.addCommand('example:run-cell', {
label: 'Run Cell',
execute: () => {
console.log('Run Cell');
}
});

commands.addCommand('example:cell-test', {
label: 'Cell Test',
execute: () => {
console.log('Cell Test');
}
});

commands.addCommand('notebook:new', {
label: 'New Notebook',
execute: () => {
Expand Down Expand Up @@ -272,6 +300,10 @@ function main(): void {
palette.addItem({ command: 'example:save-on-exit', category: 'File' });
palette.addItem({ command: 'example:open-task-manager', category: 'File' });
palette.addItem({ command: 'example:close', category: 'File' });
palette.addItem({ command: 'example:clear-cell', category: 'Notebook Cell Operations' });
palette.addItem({ command: 'example:cut-cells', category: 'Notebook Cell Operations' });
palette.addItem({ command: 'example:run-cell', category: 'Notebook Cell Operations' });
palette.addItem({ command: 'example:cell-test', category: 'Console' });
palette.addItem({ command: 'notebook:new', category: 'Notebook' });
palette.id = 'palette';

Expand Down
22 changes: 14 additions & 8 deletions packages/algorithm/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace StringExt {
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The matched indices, or `null` if there is no match.
*
* #### Complexity
Expand All @@ -31,9 +33,9 @@ namespace StringExt {
* Characters are matched using strict `===` equality.
*/
export
function findIndices(source: string, query: string): number[] | null {
function findIndices(source: string, query: string, start = 0): number[] | null {
let indices = new Array<number>(query.length);
for (let i = 0, j = 0, n = query.length; i < n; ++i, ++j) {
for (let i = 0, j = start, n = query.length; i < n; ++i, ++j) {
j = source.indexOf(query[i], j);
if (j === -1) {
return null;
Expand Down Expand Up @@ -71,6 +73,8 @@ namespace StringExt {
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The match result, or `null` if there is no match.
* A lower `score` represents a stronger match.
*
Expand All @@ -86,14 +90,14 @@ namespace StringExt {
* late matches are heavily penalized.
*/
export
function matchSumOfSquares(source: string, query: string): IMatchResult | null {
let indices = findIndices(source, query);
function matchSumOfSquares(source: string, query: string, start = 0): IMatchResult | null {
let indices = findIndices(source, query, start);
if (!indices) {
return null;
}
let score = 0;
for (let i = 0, n = indices.length; i < n; ++i) {
let j = indices[i];
let j = indices[i] - start;
score += j * j;
}
return { score, indices };
Expand All @@ -106,6 +110,8 @@ namespace StringExt {
*
* @param query - The characters to locate in the source text.
*
* @param start - The index to start the search.
*
* @returns The match result, or `null` if there is no match.
* A lower `score` represents a stronger match.
*
Expand All @@ -121,13 +127,13 @@ namespace StringExt {
* penalized.
*/
export
function matchSumOfDeltas(source: string, query: string): IMatchResult | null {
let indices = findIndices(source, query);
function matchSumOfDeltas(source: string, query: string, start = 0): IMatchResult | null {
let indices = findIndices(source, query, start);
if (!indices) {
return null;
}
let score = 0;
let last = -1;
let last = start - 1;
for (let i = 0, n = indices.length; i < n; ++i) {
let j = indices[i];
score += j - last - 1;
Expand Down
153 changes: 107 additions & 46 deletions packages/widgets/src/commandpalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,10 +1045,20 @@ namespace Private {
return text.replace(/\s+/g, '').toLowerCase();
}

/**
* An enum of the supported match types.
*/
const enum MatchType { Label, Category, Split, Default }

/**
* A text match score with associated command item.
*/
interface IScore {
/**
* The numerical type for the text match.
*/
matchType: MatchType;

/**
* The numerical score for the text match.
*/
Expand Down Expand Up @@ -1091,7 +1101,10 @@ namespace Private {
// If the query is empty, all items are matched by default.
if (!query) {
scores.push({
score: 0, categoryIndices: null, labelIndices: null, item
matchType: MatchType.Default,
categoryIndices: null,
labelIndices: null,
score: 0, item
});
continue;
}
Expand All @@ -1105,6 +1118,7 @@ namespace Private {
}

// Penalize disabled items.
// TODO - push disabled items all the way down in sort cmp?
if (!item.isEnabled) {
score.score += 1000;
}
Expand All @@ -1121,79 +1135,126 @@ namespace Private {
* Perform a fuzzy search on a single command item.
*/
function fuzzySearch(item: CommandPalette.IItem, query: string): IScore | null {
// Normalize the case of the category and label.
// Create the source text to be searched.
let category = item.category.toLowerCase();
let label = item.label.toLowerCase();
let source = `${category} ${label}`;

// Set up the result variables.
let categoryIndices: number[] | null = null;
let labelIndices: number[] | null = null;
// Set up the match score and indices array.
let score = Infinity;
let indices: number[] | null = null;

// Test for a full match in the category.
let cMatch = StringExt.matchSumOfDeltas(category, query);
if (cMatch && cMatch.score < score) {
score = cMatch.score;
categoryIndices = cMatch.indices;
labelIndices = null;
}
// The regex for search word boundaries
let rgx = /\b\w/g;

// Test for a better full match in the label.
let lMatch = StringExt.matchSumOfDeltas(label, query);
if (lMatch && lMatch.score < score) {
score = lMatch.score;
labelIndices = lMatch.indices;
categoryIndices = null;
}
// Search the source by word boundary.
while (true) {
// Find the next word boundary in the source.
let rgxMatch = rgx.exec(source);

// Test for a better split match.
for (let i = 0, n = query.length - 1; i < n; ++i) {
let cMatch = StringExt.matchSumOfDeltas(category, query.slice(0, i + 1));
if (!cMatch) {
continue;
// Break if there is no more source context.
if (!rgxMatch) {
break;
}
let lMatch = StringExt.matchSumOfDeltas(label, query.slice(i + 1));
if (!lMatch) {
continue;

// Run the string match on the relevant substring.
let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index);

// Break if there is no match.
if (!match) {
break;
}
if (cMatch.score + lMatch.score < score) {
score = cMatch.score + lMatch.score;
categoryIndices = cMatch.indices;
labelIndices = lMatch.indices;

// Update the match if the score is better.
if (match && match.score <= score) {
score = match.score;
indices = match.indices;
}
}

// Bail if there is no match.
if (score === Infinity) {
// Bail if there was no match.
if (!indices || score === Infinity) {
return null;
}

// Return the final score and matched indices.
return { score, categoryIndices, labelIndices, item };
// Compute the pivot index between category and label text.
let pivot = category.length + 1;

// Find the slice index to separate matched indices.
let j = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b);

// Extract the matched category and label indices.
let categoryIndices = indices.slice(0, j);
let labelIndices = indices.slice(j);

// Adjust the label indices for the pivot offset.
for (let i = 0, n = labelIndices.length; i < n; ++i) {
labelIndices[i] -= pivot;
}

// Handle a pure label match.
if (categoryIndices.length === 0) {
return {
matchType: MatchType.Label,
categoryIndices: null,
labelIndices,
score, item
};
}

// Handle a pure category match.
if (labelIndices.length === 0) {
return {
matchType: MatchType.Category,
categoryIndices,
labelIndices: null,
score, item
};
}

// Handle a split match.
return {
matchType: MatchType.Split,
categoryIndices,
labelIndices,
score, item
};
}

/**
* A sort comparison function for a match score.
*/
function scoreCmp(a: IScore, b: IScore): number {
// First compare based on the match score.
// First compare based on the match type
let m1 = a.matchType - b.matchType;
if (m1 !== 0) {
return m1;
}

// Otherwise, compare based on the match score.
let d1 = a.score - b.score;
if (d1 !== 0) {
return d1;
}

// Otherwise, prefer a pure category match.
let c1 = !!a.categoryIndices && !a.labelIndices;
let c2 = !!b.categoryIndices && !b.labelIndices;
if (c1 !== c2) {
return c1 ? -1 : 1;
// Find the match index based on the match type.
let i1 = 0;
let i2 = 0;
switch (a.matchType) {
case MatchType.Label:
i1 = a.labelIndices![0];
i2 = b.labelIndices![0];
break;
case MatchType.Category:
case MatchType.Split:
i1 = a.categoryIndices![0];
i2 = b.categoryIndices![0];
break;
}

// Otherwise, prefer a pure label match.
let l1 = !!a.labelIndices && !a.categoryIndices;
let l2 = !!b.labelIndices && !b.categoryIndices;
if (l1 !== l2) {
return l1 ? -1 : 1;
// Compare based on the match index.
if (i1 !== i2) {
return i1 - i2;
}

// Otherwise, compare by category.
Expand Down
2 changes: 2 additions & 0 deletions tests/test-widgets/src/commandpalette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ describe('@phosphor/widgets', () => {
expect(items()).to.have.length(10);
input(`${categories[1]}`); // Category match
expect(items()).to.have.length(5);
input(`${names[1][0]}`); // Label match
expect(items()).to.have.length(1);
input(`${categories[1]} B`); // No match
expect(items()).to.have.length(0);
input(`${categories[1]} I`); // Category and text match
Expand Down