From 0b924b4abde92c6c0c7692f2a6dc162b921289f9 Mon Sep 17 00:00:00 2001 From: Eugene Tang Date: Thu, 11 May 2017 15:34:09 -0400 Subject: [PATCH 1/8] add exact matching and matching priority to fuzzy matching --- examples/example-dockpanel/src/index.ts | 32 ++++++++ packages/algorithm/src/string.ts | 25 ++++++ packages/widgets/src/commandpalette.ts | 103 +++++++++++++++--------- 3 files changed, 123 insertions(+), 37 deletions(-) diff --git a/examples/example-dockpanel/src/index.ts b/examples/example-dockpanel/src/index.ts index bd9a50540..47e97712d 100644 --- a/examples/example-dockpanel/src/index.ts +++ b/examples/example-dockpanel/src/index.ts @@ -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: () => { @@ -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'; diff --git a/packages/algorithm/src/string.ts b/packages/algorithm/src/string.ts index 695ec230a..b45a6a437 100644 --- a/packages/algorithm/src/string.ts +++ b/packages/algorithm/src/string.ts @@ -64,6 +64,31 @@ namespace StringExt { indices: number[]; } + /** + * A string matcher that looks for an exact match. + * + * @param source - The source text which should be searched. + * + * @param query - The characters to locate in the source text. + * + * @returns The match result, or `null` if there is no match. + * A lower `score` represents a stronger match. + * + * #### Complexity + * Linear on `sourceText`. + */ + export + function matchExact(source: string, query: string): IMatchResult | null { + let matchIndex = source.indexOf(query); + if (matchIndex === -1) { return null; } + let score = matchIndex; + let indices = []; + for (var i=0; i < query.length; i++) { + indices.push(matchIndex+i); + } + return { score, indices }; + } + /** * A string matcher which uses a sum-of-squares algorithm. * diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index c02e428bb..2ee0ef2db 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -1049,6 +1049,11 @@ namespace Private { * A text match score with associated command item. */ interface IScore { + /** + * The numerical type for the text match. + */ + matchType: number; + /** * The numerical score for the text match. */ @@ -1073,6 +1078,10 @@ namespace Private { /** * Perform a fuzzy match on an array of command items. */ + let LABEL_EXACT_MATCH_TYPE = 0; + let LABEL_FUZZY_MATCH_TYPE = 1; + let CATEGORY_EXACT_MATCH_TYPE = 10; + let CATEGORY_FUZZY_MATCH_TYPE = 11; function matchItems(items: CommandPalette.IItem[], query: string): IScore[] { // Normalize the query text to lower case with no whitespace. query = normalizeQuery(query); @@ -1091,7 +1100,7 @@ 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: -1, score: 0, categoryIndices: null, labelIndices: null, item }); continue; } @@ -1128,67 +1137,80 @@ namespace Private { // Set up the result variables. let categoryIndices: number[] | null = null; let labelIndices: number[] | null = null; + let matchType = Infinity; let score = Infinity; - // 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; + // First, test for an exact match in the label + if (LABEL_EXACT_MATCH_TYPE <= matchType) { + let lExactMatch = StringExt.matchExact(label, query); + if (lExactMatch) { + matchType = LABEL_EXACT_MATCH_TYPE; + score = lExactMatch.score; + labelIndices = lExactMatch.indices; + categoryIndices = null; + } } - // 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; + // Otherwise, test for a fuzzy match in the label. + if (LABEL_FUZZY_MATCH_TYPE <= matchType) { + let lFuzzyMatch = StringExt.matchSumOfDeltas(label, query); + if (lFuzzyMatch) { + matchType = LABEL_FUZZY_MATCH_TYPE; + score = lFuzzyMatch.score; + labelIndices = lFuzzyMatch.indices; + categoryIndices = null; + return { matchType, score, categoryIndices, labelIndices, item }; + } } - // 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; + // Otherwise, test for an exact match in the category + if (CATEGORY_EXACT_MATCH_TYPE <= matchType) { + let cExactMatch = StringExt.matchExact(category, query); + if (cExactMatch) { + matchType = CATEGORY_EXACT_MATCH_TYPE; + score = cExactMatch.score; + categoryIndices = cExactMatch.indices; + labelIndices = null; + return { matchType, score, categoryIndices, labelIndices, item }; } - let lMatch = StringExt.matchSumOfDeltas(label, query.slice(i + 1)); - if (!lMatch) { - continue; - } - if (cMatch.score + lMatch.score < score) { - score = cMatch.score + lMatch.score; - categoryIndices = cMatch.indices; - labelIndices = lMatch.indices; + } + + // Otherwise, test for a fuzzy match in the category + if (CATEGORY_FUZZY_MATCH_TYPE <= matchType) { + let cFuzzyMatch = StringExt.matchSumOfDeltas(category, query); + if (cFuzzyMatch) { + matchType = CATEGORY_FUZZY_MATCH_TYPE; + score = cFuzzyMatch.score; + categoryIndices = cFuzzyMatch.indices; + labelIndices = null; + return { matchType, score, categoryIndices, labelIndices, item }; } } // Bail if there is no match. - if (score === Infinity) { + if (matchType === Infinity || score === Infinity) { return null; } - // Return the final score and matched indices. - return { score, categoryIndices, labelIndices, item }; + return { matchType, score, categoryIndices, labelIndices, 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; - } - // Otherwise, prefer a pure label match. let l1 = !!a.labelIndices && !a.categoryIndices; let l2 = !!b.labelIndices && !b.categoryIndices; @@ -1196,6 +1218,13 @@ namespace Private { return l1 ? -1 : 1; } + // 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; + } + // Otherwise, compare by category. let d2 = a.item.category.localeCompare(b.item.category); if (d2 !== 0) { From 7ccf9fd23ecedf6674623a883194a64c862840e6 Mon Sep 17 00:00:00 2001 From: Eugene Tang Date: Thu, 11 May 2017 15:41:56 -0400 Subject: [PATCH 2/8] remove redundant returns --- packages/widgets/src/commandpalette.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index 2ee0ef2db..272231a9a 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -1159,7 +1159,6 @@ namespace Private { score = lFuzzyMatch.score; labelIndices = lFuzzyMatch.indices; categoryIndices = null; - return { matchType, score, categoryIndices, labelIndices, item }; } } @@ -1171,7 +1170,6 @@ namespace Private { score = cExactMatch.score; categoryIndices = cExactMatch.indices; labelIndices = null; - return { matchType, score, categoryIndices, labelIndices, item }; } } @@ -1183,7 +1181,6 @@ namespace Private { score = cFuzzyMatch.score; categoryIndices = cFuzzyMatch.indices; labelIndices = null; - return { matchType, score, categoryIndices, labelIndices, item }; } } From 99e74a2ffcd24997229fbb157f348163b7cdd37a Mon Sep 17 00:00:00 2001 From: Eugene Tang Date: Thu, 11 May 2017 18:27:59 -0400 Subject: [PATCH 3/8] edit / add tests --- tests/test-algorithm/src/string.spec.ts | 25 +++++++++++++++++++ tests/test-widgets/src/commandpalette.spec.ts | 6 ++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test-algorithm/src/string.spec.ts b/tests/test-algorithm/src/string.spec.ts index d997912b6..66bc9e1eb 100644 --- a/tests/test-algorithm/src/string.spec.ts +++ b/tests/test-algorithm/src/string.spec.ts @@ -40,6 +40,31 @@ describe('@phosphor/algorithm', () => { }); + describe('matchExact()', () => { + + it('should find and score exact substring matches by starting index', () => { + let r1 = StringExt.matchExact('Foo Bar Baz', 'Fo')!; + let r2 = StringExt.matchExact('Foo Bar Baz', 'Baz')!; + let r3 = StringExt.matchExact('Foo Bar Baz', 'B')!; + expect(r1.score).to.equal(0); + expect(r1.indices).to.deep.equal([0, 1]); + expect(r2.score).to.equal(8); + expect(r2.indices).to.deep.equal([8, 9, 10]); + expect(r3.score).to.equal(4); + expect(r3.indices).to.deep.equal([4]); + }); + + it('should return `null` if no match is found', () => { + let r1 = StringExt.findIndices('Foo Bar Baz', 'faa'); + let r2 = StringExt.findIndices('Foo Bar Baz', 'obz'); + let r3 = StringExt.findIndices('Foo Bar Baz', 'raB'); + expect(r1).to.equal(null); + expect(r2).to.equal(null); + expect(r3).to.equal(null); + }); + + }); + describe('matchSumOfSquares()', () => { it('should score the match using the sum of squared distances', () => { diff --git a/tests/test-widgets/src/commandpalette.spec.ts b/tests/test-widgets/src/commandpalette.spec.ts index bf4a952d2..be2cf872f 100644 --- a/tests/test-widgets/src/commandpalette.spec.ts +++ b/tests/test-widgets/src/commandpalette.spec.ts @@ -529,12 +529,12 @@ 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 - expect(items()).to.have.length(1); - input('1'); // Multi-category match + input('1'); // Multi-category match expect(headers()).to.have.length(2); expect(items()).to.have.length(2); }); From 0a06374fdbabca1b676bfd4a6cd71b0d578f4cb4 Mon Sep 17 00:00:00 2001 From: "S. Chris Colbert" Date: Mon, 15 May 2017 13:39:36 -0500 Subject: [PATCH 4/8] cleanup exactMatch function and tests --- packages/algorithm/src/string.ts | 13 +++++++------ tests/test-algorithm/src/string.spec.ts | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/algorithm/src/string.ts b/packages/algorithm/src/string.ts index b45a6a437..163a4966f 100644 --- a/packages/algorithm/src/string.ts +++ b/packages/algorithm/src/string.ts @@ -79,12 +79,13 @@ namespace StringExt { */ export function matchExact(source: string, query: string): IMatchResult | null { - let matchIndex = source.indexOf(query); - if (matchIndex === -1) { return null; } - let score = matchIndex; - let indices = []; - for (var i=0; i < query.length; i++) { - indices.push(matchIndex+i); + let score = source.indexOf(query); + if (score === -1) { + return null; + } + let indices = new Array(query.length); + for (let i = 0, n = query.length; i < n; i++) { + indices[i] = score + i; } return { score, indices }; } diff --git a/tests/test-algorithm/src/string.spec.ts b/tests/test-algorithm/src/string.spec.ts index 66bc9e1eb..a3fc6c8c4 100644 --- a/tests/test-algorithm/src/string.spec.ts +++ b/tests/test-algorithm/src/string.spec.ts @@ -55,9 +55,9 @@ describe('@phosphor/algorithm', () => { }); it('should return `null` if no match is found', () => { - let r1 = StringExt.findIndices('Foo Bar Baz', 'faa'); - let r2 = StringExt.findIndices('Foo Bar Baz', 'obz'); - let r3 = StringExt.findIndices('Foo Bar Baz', 'raB'); + let r1 = StringExt.matchExact('Foo Bar Baz', 'faa'); + let r2 = StringExt.matchExact('Foo Bar Baz', 'obz'); + let r3 = StringExt.matchExact('Foo Bar Baz', 'raB'); expect(r1).to.equal(null); expect(r2).to.equal(null); expect(r3).to.equal(null); From 620ffe9203047dd35d6b7fc2da24f985d7191666 Mon Sep 17 00:00:00 2001 From: "S. Chris Colbert" Date: Mon, 15 May 2017 15:50:44 -0500 Subject: [PATCH 5/8] tweak fuzzy search wip --- packages/algorithm/src/string.ts | 72 +++---- packages/widgets/src/commandpalette.ts | 255 +++++++++++++++++++------ 2 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/algorithm/src/string.ts b/packages/algorithm/src/string.ts index 163a4966f..ff284a1a1 100644 --- a/packages/algorithm/src/string.ts +++ b/packages/algorithm/src/string.ts @@ -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 @@ -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(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; @@ -64,31 +66,31 @@ namespace StringExt { indices: number[]; } - /** - * A string matcher that looks for an exact match. - * - * @param source - The source text which should be searched. - * - * @param query - The characters to locate in the source text. - * - * @returns The match result, or `null` if there is no match. - * A lower `score` represents a stronger match. - * - * #### Complexity - * Linear on `sourceText`. - */ - export - function matchExact(source: string, query: string): IMatchResult | null { - let score = source.indexOf(query); - if (score === -1) { - return null; - } - let indices = new Array(query.length); - for (let i = 0, n = query.length; i < n; i++) { - indices[i] = score + i; - } - return { score, indices }; - } + // * + // * A string matcher that looks for an exact match. + // * + // * @param source - The source text which should be searched. + // * + // * @param query - The characters to locate in the source text. + // * + // * @returns The match result, or `null` if there is no match. + // * A lower `score` represents a stronger match. + // * + // * #### Complexity + // * Linear on `sourceText`. + + // export + // function matchExact(source: string, query: string): IMatchResult | null { + // let score = source.indexOf(query); + // if (score === -1) { + // return null; + // } + // let indices = new Array(query.length); + // for (let i = 0, n = query.length; i < n; i++) { + // indices[i] = score + i; + // } + // return { score, indices }; + // } /** * A string matcher which uses a sum-of-squares algorithm. @@ -97,6 +99,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. * @@ -112,14 +116,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 }; @@ -132,6 +136,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. * @@ -147,13 +153,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; diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index 272231a9a..1032687f2 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -1045,6 +1045,11 @@ namespace Private { return text.replace(/\s+/g, '').toLowerCase(); } + /** + * + */ + const enum MatchType { Label, Category, Split, Default } + /** * A text match score with associated command item. */ @@ -1078,10 +1083,6 @@ namespace Private { /** * Perform a fuzzy match on an array of command items. */ - let LABEL_EXACT_MATCH_TYPE = 0; - let LABEL_FUZZY_MATCH_TYPE = 1; - let CATEGORY_EXACT_MATCH_TYPE = 10; - let CATEGORY_FUZZY_MATCH_TYPE = 11; function matchItems(items: CommandPalette.IItem[], query: string): IScore[] { // Normalize the query text to lower case with no whitespace. query = normalizeQuery(query); @@ -1100,13 +1101,17 @@ namespace Private { // If the query is empty, all items are matched by default. if (!query) { scores.push({ - matchType: -1, score: 0, categoryIndices: null, labelIndices: null, item + matchType: MatchType.Default, + score: 0, + categoryIndices: null, + labelIndices: null, + item }); continue; } // Run the fuzzy search for the item and query. - let score = fuzzySearch(item, query); + let score = fuzzySearch2(item, query); // Ignore the item if it is not a match. if (!score) { @@ -1129,67 +1134,195 @@ 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. + // function fuzzySearch(item: CommandPalette.IItem, query: string): IScore | null { + // // Normalize the case of the category and label. + // let category = item.category.toLowerCase(); + // let label = item.label.toLowerCase(); + + // // First, test for an exact match in the label + // let lExactMatch = StringExt.matchExact(label, query); + // if (lExactMatch) { + // return { + // matchType: MatchType.LabelExact, + // score: lExactMatch.score, + // labelIndices: lExactMatch.indices, + // categoryIndices: null, + // item + // }; + // } + + // // Otherwise, test for a fuzzy match in the label. + // let lFuzzyMatch = StringExt.matchSumOfDeltas(label, query); + // if (lFuzzyMatch) { + // return { + // matchType: MatchType.LabelFuzzy, + // score: lFuzzyMatch.score, + // labelIndices: lFuzzyMatch.indices, + // categoryIndices: null, + // item + // }; + // } + + // // Otherwise, test for an exact match in the category + // let cExactMatch = StringExt.matchExact(category, query); + // if (cExactMatch) { + // return { + // matchType: MatchType.CategoryExact, + // score: cExactMatch.score, + // labelIndices: null, + // categoryIndices: cExactMatch.indices, + // item + // }; + // } + + // // Otherwise, test for a fuzzy match in the category + // let cFuzzyMatch = StringExt.matchSumOfDeltas(category, query); + // if (cFuzzyMatch) { + // return { + // matchType: MatchType.CategoryFuzzy, + // score: cFuzzyMatch.score, + // labelIndices: null, + // categoryIndices: cFuzzyMatch.indices, + // item + // }; + // } + + // // Otherwise, test for a fuzzy match across label and category. + // let score = Infinity; + // let labelIndices: number[] | null = null; + // let categoryIndices: number[] | null = null; + // for (let i = 0, n = query.length - 1; i < n; ++i) { + // // + // let cq = query.slice(0, i + 1); + + // // + // let cMatch = StringExt.matchExact(category, cq); + + // // + // if (!cMatch) { + // // + // cMatch = StringExt.matchSumOfDeltas(category, cq); + + // // + // if (!cMatch) { + // continue; + // } + // } + + // // + // let lq = query.slice(i + 1); + + // // + // let lMatch = StringExt.matchExact(label, lq); + + // // + // if (!lMatch) { + // // + // lMatch = StringExt.matchSumOfDeltas(label, lq); + + // // + // if (!lMatch) { + // continue; + // } + // } + + // // + // if (cMatch.score + lMatch.score < score) { + // score = cMatch.score + lMatch.score; + // categoryIndices = cMatch.indices; + // labelIndices = lMatch.indices; + // } + // } + + // // Bail if there is no match. + // if (score === Infinity) { + // return null; + // } + + // // Return the final score and matched indices. + // return { + // matchType: MatchType.SplitFuzzy, score, categoryIndices, labelIndices, item + // }; + // } + + /** + * Perform a fuzzy search on a single command item. + */ + function fuzzySearch2(item: CommandPalette.IItem, query: string): IScore | null { + // + let rgx = /\b\w/g; + + // 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; - let matchType = Infinity; + // let score = Infinity; + let indices: number[] | null = null; + + // + while (true) { + // + let rgxMatch = rgx.exec(source); + if (!rgxMatch) { + break; + } + + // + let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index); - // First, test for an exact match in the label - if (LABEL_EXACT_MATCH_TYPE <= matchType) { - let lExactMatch = StringExt.matchExact(label, query); - if (lExactMatch) { - matchType = LABEL_EXACT_MATCH_TYPE; - score = lExactMatch.score; - labelIndices = lExactMatch.indices; - categoryIndices = null; + // + if (match && match.score <= score) { + score = match.score; + indices = match.indices; } } - // Otherwise, test for a fuzzy match in the label. - if (LABEL_FUZZY_MATCH_TYPE <= matchType) { - let lFuzzyMatch = StringExt.matchSumOfDeltas(label, query); - if (lFuzzyMatch) { - matchType = LABEL_FUZZY_MATCH_TYPE; - score = lFuzzyMatch.score; - labelIndices = lFuzzyMatch.indices; - categoryIndices = null; - } + // + if (!indices || score === Infinity) { + return null; } - // Otherwise, test for an exact match in the category - if (CATEGORY_EXACT_MATCH_TYPE <= matchType) { - let cExactMatch = StringExt.matchExact(category, query); - if (cExactMatch) { - matchType = CATEGORY_EXACT_MATCH_TYPE; - score = cExactMatch.score; - categoryIndices = cExactMatch.indices; - labelIndices = null; - } + // + let pivot = category.length + 1; + + // + let i = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b); + + // + let categoryIndices = indices.slice(0, i); + let labelIndices = indices.slice(i); + + // + for (let i = 0, n = labelIndices.length; i < n; ++i) { + labelIndices[i] -= pivot; } - // Otherwise, test for a fuzzy match in the category - if (CATEGORY_FUZZY_MATCH_TYPE <= matchType) { - let cFuzzyMatch = StringExt.matchSumOfDeltas(category, query); - if (cFuzzyMatch) { - matchType = CATEGORY_FUZZY_MATCH_TYPE; - score = cFuzzyMatch.score; - categoryIndices = cFuzzyMatch.indices; - labelIndices = null; - } + if (i === 0) { + return { + matchType: MatchType.Label, + categoryIndices: null, + labelIndices, + score, item + }; } - // Bail if there is no match. - if (matchType === Infinity || score === Infinity) { - return null; + if (i === indices.length) { + return { + matchType: MatchType.Category, + categoryIndices, + labelIndices: null, + score, item + }; } - return { matchType, score, categoryIndices, labelIndices, item }; + return { + matchType: MatchType.Split, + categoryIndices, + labelIndices, + score, item + }; } /** @@ -1209,18 +1342,18 @@ namespace Private { } // 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; - } + // let l1 = !!a.labelIndices && !a.categoryIndices; + // let l2 = !!b.labelIndices && !b.categoryIndices; + // if (l1 !== l2) { + // return l1 ? -1 : 1; + // } // 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; - } + // let c1 = !!a.categoryIndices && !a.labelIndices; + // let c2 = !!b.categoryIndices && !b.labelIndices; + // if (c1 !== c2) { + // return c1 ? -1 : 1; + // } // Otherwise, compare by category. let d2 = a.item.category.localeCompare(b.item.category); From 4d6a54ed74be587e34cab0652e68ebcf6206194e Mon Sep 17 00:00:00 2001 From: "S. Chris Colbert" Date: Mon, 15 May 2017 16:05:17 -0500 Subject: [PATCH 6/8] update sort cmp function --- packages/widgets/src/commandpalette.ts | 36 +++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index 1032687f2..787cafa97 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -1299,7 +1299,7 @@ namespace Private { labelIndices[i] -= pivot; } - if (i === 0) { + if (categoryIndices.length === 0) { return { matchType: MatchType.Label, categoryIndices: null, @@ -1308,7 +1308,7 @@ namespace Private { }; } - if (i === indices.length) { + if (labelIndices.length === 0) { return { matchType: MatchType.Category, categoryIndices, @@ -1341,19 +1341,25 @@ namespace Private { return d1; } - // 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; - // } - - // 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; + } + + // Compare based on the match index. + if (i1 !== i2) { + return i1 - i2; + } // Otherwise, compare by category. let d2 = a.item.category.localeCompare(b.item.category); From 618bf601b4fe782e7f63047dce6713b6965268bf Mon Sep 17 00:00:00 2001 From: "S. Chris Colbert" Date: Mon, 15 May 2017 16:57:30 -0500 Subject: [PATCH 7/8] update tests --- tests/test-algorithm/src/string.spec.ts | 25 ------------------- tests/test-widgets/src/commandpalette.spec.ts | 6 +++-- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/tests/test-algorithm/src/string.spec.ts b/tests/test-algorithm/src/string.spec.ts index a3fc6c8c4..d997912b6 100644 --- a/tests/test-algorithm/src/string.spec.ts +++ b/tests/test-algorithm/src/string.spec.ts @@ -40,31 +40,6 @@ describe('@phosphor/algorithm', () => { }); - describe('matchExact()', () => { - - it('should find and score exact substring matches by starting index', () => { - let r1 = StringExt.matchExact('Foo Bar Baz', 'Fo')!; - let r2 = StringExt.matchExact('Foo Bar Baz', 'Baz')!; - let r3 = StringExt.matchExact('Foo Bar Baz', 'B')!; - expect(r1.score).to.equal(0); - expect(r1.indices).to.deep.equal([0, 1]); - expect(r2.score).to.equal(8); - expect(r2.indices).to.deep.equal([8, 9, 10]); - expect(r3.score).to.equal(4); - expect(r3.indices).to.deep.equal([4]); - }); - - it('should return `null` if no match is found', () => { - let r1 = StringExt.matchExact('Foo Bar Baz', 'faa'); - let r2 = StringExt.matchExact('Foo Bar Baz', 'obz'); - let r3 = StringExt.matchExact('Foo Bar Baz', 'raB'); - expect(r1).to.equal(null); - expect(r2).to.equal(null); - expect(r3).to.equal(null); - }); - - }); - describe('matchSumOfSquares()', () => { it('should score the match using the sum of squared distances', () => { diff --git a/tests/test-widgets/src/commandpalette.spec.ts b/tests/test-widgets/src/commandpalette.spec.ts index be2cf872f..0bb9b38d1 100644 --- a/tests/test-widgets/src/commandpalette.spec.ts +++ b/tests/test-widgets/src/commandpalette.spec.ts @@ -529,12 +529,14 @@ 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 + 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 + expect(items()).to.have.length(1); - input('1'); // Multi-category match + input('1'); // Multi-category match expect(headers()).to.have.length(2); expect(items()).to.have.length(2); }); From f634e8283a07184e37d15596a1312e5eccc5f097 Mon Sep 17 00:00:00 2001 From: "S. Chris Colbert" Date: Mon, 15 May 2017 16:58:22 -0500 Subject: [PATCH 8/8] cleanup --- packages/algorithm/src/string.ts | 26 ---- packages/widgets/src/commandpalette.ts | 170 +++++-------------------- 2 files changed, 33 insertions(+), 163 deletions(-) diff --git a/packages/algorithm/src/string.ts b/packages/algorithm/src/string.ts index ff284a1a1..ff1a0df29 100644 --- a/packages/algorithm/src/string.ts +++ b/packages/algorithm/src/string.ts @@ -66,32 +66,6 @@ namespace StringExt { indices: number[]; } - // * - // * A string matcher that looks for an exact match. - // * - // * @param source - The source text which should be searched. - // * - // * @param query - The characters to locate in the source text. - // * - // * @returns The match result, or `null` if there is no match. - // * A lower `score` represents a stronger match. - // * - // * #### Complexity - // * Linear on `sourceText`. - - // export - // function matchExact(source: string, query: string): IMatchResult | null { - // let score = source.indexOf(query); - // if (score === -1) { - // return null; - // } - // let indices = new Array(query.length); - // for (let i = 0, n = query.length; i < n; i++) { - // indices[i] = score + i; - // } - // return { score, indices }; - // } - /** * A string matcher which uses a sum-of-squares algorithm. * diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index 787cafa97..d245ac6fc 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -1046,7 +1046,7 @@ namespace Private { } /** - * + * An enum of the supported match types. */ const enum MatchType { Label, Category, Split, Default } @@ -1057,7 +1057,7 @@ namespace Private { /** * The numerical type for the text match. */ - matchType: number; + matchType: MatchType; /** * The numerical score for the text match. @@ -1102,16 +1102,15 @@ namespace Private { if (!query) { scores.push({ matchType: MatchType.Default, - score: 0, categoryIndices: null, labelIndices: null, - item + score: 0, item }); continue; } // Run the fuzzy search for the item and query. - let score = fuzzySearch2(item, query); + let score = fuzzySearch(item, query); // Ignore the item if it is not a match. if (!score) { @@ -1119,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; } @@ -1134,171 +1134,65 @@ 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. - // let category = item.category.toLowerCase(); - // let label = item.label.toLowerCase(); - - // // First, test for an exact match in the label - // let lExactMatch = StringExt.matchExact(label, query); - // if (lExactMatch) { - // return { - // matchType: MatchType.LabelExact, - // score: lExactMatch.score, - // labelIndices: lExactMatch.indices, - // categoryIndices: null, - // item - // }; - // } - - // // Otherwise, test for a fuzzy match in the label. - // let lFuzzyMatch = StringExt.matchSumOfDeltas(label, query); - // if (lFuzzyMatch) { - // return { - // matchType: MatchType.LabelFuzzy, - // score: lFuzzyMatch.score, - // labelIndices: lFuzzyMatch.indices, - // categoryIndices: null, - // item - // }; - // } - - // // Otherwise, test for an exact match in the category - // let cExactMatch = StringExt.matchExact(category, query); - // if (cExactMatch) { - // return { - // matchType: MatchType.CategoryExact, - // score: cExactMatch.score, - // labelIndices: null, - // categoryIndices: cExactMatch.indices, - // item - // }; - // } - - // // Otherwise, test for a fuzzy match in the category - // let cFuzzyMatch = StringExt.matchSumOfDeltas(category, query); - // if (cFuzzyMatch) { - // return { - // matchType: MatchType.CategoryFuzzy, - // score: cFuzzyMatch.score, - // labelIndices: null, - // categoryIndices: cFuzzyMatch.indices, - // item - // }; - // } - - // // Otherwise, test for a fuzzy match across label and category. - // let score = Infinity; - // let labelIndices: number[] | null = null; - // let categoryIndices: number[] | null = null; - // for (let i = 0, n = query.length - 1; i < n; ++i) { - // // - // let cq = query.slice(0, i + 1); - - // // - // let cMatch = StringExt.matchExact(category, cq); - - // // - // if (!cMatch) { - // // - // cMatch = StringExt.matchSumOfDeltas(category, cq); - - // // - // if (!cMatch) { - // continue; - // } - // } - - // // - // let lq = query.slice(i + 1); - - // // - // let lMatch = StringExt.matchExact(label, lq); - - // // - // if (!lMatch) { - // // - // lMatch = StringExt.matchSumOfDeltas(label, lq); - - // // - // if (!lMatch) { - // continue; - // } - // } - - // // - // if (cMatch.score + lMatch.score < score) { - // score = cMatch.score + lMatch.score; - // categoryIndices = cMatch.indices; - // labelIndices = lMatch.indices; - // } - // } - - // // Bail if there is no match. - // if (score === Infinity) { - // return null; - // } - - // // Return the final score and matched indices. - // return { - // matchType: MatchType.SplitFuzzy, score, categoryIndices, labelIndices, item - // }; - // } - - /** - * Perform a fuzzy search on a single command item. - */ - function fuzzySearch2(item: CommandPalette.IItem, query: string): IScore | null { - // - let rgx = /\b\w/g; - - // + function fuzzySearch(item: CommandPalette.IItem, query: string): IScore | null { + // Create the source text to be searched. let category = item.category.toLowerCase(); let label = item.label.toLowerCase(); let source = `${category} ${label}`; - // + // Set up the match score and indices array. let score = Infinity; let indices: number[] | null = null; - // + // The regex for search word boundaries + let rgx = /\b\w/g; + + // Search the source by word boundary. while (true) { - // + // Find the next word boundary in the source. let rgxMatch = rgx.exec(source); + + // Break if there is no more source context. if (!rgxMatch) { break; } - // + // 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; + } + + // Update the match if the score is better. if (match && match.score <= score) { score = match.score; indices = match.indices; } } - // + // Bail if there was no match. if (!indices || score === Infinity) { return null; } - // + // Compute the pivot index between category and label text. let pivot = category.length + 1; - // - let i = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b); + // Find the slice index to separate matched indices. + let j = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b); - // - let categoryIndices = indices.slice(0, i); - let labelIndices = indices.slice(i); + // 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, @@ -1308,6 +1202,7 @@ namespace Private { }; } + // Handle a pure category match. if (labelIndices.length === 0) { return { matchType: MatchType.Category, @@ -1317,6 +1212,7 @@ namespace Private { }; } + // Handle a split match. return { matchType: MatchType.Split, categoryIndices,