From 03394646dcd1218ce27d1c6f3c165b5d364525a6 Mon Sep 17 00:00:00 2001 From: seantan22 Date: Fri, 22 Jan 2021 11:35:10 -0500 Subject: [PATCH] Add Whitespace Query Support to Quick File Open What it Does Fixes [#8747](https://github.com/eclipse-theia/theia/issues/8747) - Allows whitespaces to be included in a `quick file open` search query. - Adds support for whitespaces in the scoring of file search results - Adds tests for whitespace queries (considers fuzzy matching and search term order). How to Test 1. `ctrl + p` to `quick file open` 2. Search for a file with a query that includes whitespaces (eg. `readme core`) 3. Observe that whitespaces do not affect the search results Alternatively, run `@theia/file-search` tests. Signed-off-by: seantan22 --- .../src/browser/quick-file-open.ts | 32 +++++++++++++++++-- .../src/node/file-search-service-impl.spec.ts | 30 +++++++++++++++++ .../src/node/file-search-service-impl.ts | 22 +++++++++++-- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/file-search/src/browser/quick-file-open.ts b/packages/file-search/src/browser/quick-file-open.ts index d7ef1421f971e..dfd6beda11638 100644 --- a/packages/file-search/src/browser/quick-file-open.ts +++ b/packages/file-search/src/browser/quick-file-open.ts @@ -74,6 +74,13 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { */ protected currentLookFor: string = ''; + /** + * Default matching score values. + */ + static MAX_SCORE: number = 1000; + static FULL_MATCH: number = 500; + static PART_MATCH: number = 250; + readonly prefix: string = '...'; get description(): string { @@ -254,9 +261,28 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { * @returns the score. */ function score(str: string): number { - const match = fuzzy.match(query, str); - // eslint-disable-next-line no-null/no-null - return (match === null) ? 0 : match.score; + // Adjust for whitespaces in the query. + const querySplit = query.split(' '); + const queryJoin = querySplit.join(''); + + // Check full exact matches. + const fullMatch = querySplit.every(part => str.indexOf(part) !== -1); + + // Check partial exact matches. + const partMatch = querySplit.some(part => str.indexOf(part) !== -1); + + // Check fuzzy matches. + const fuzzyMatch = fuzzy.match(queryJoin, query); + const matchScore = (fuzzyMatch.score === Infinity) ? QuickFileOpenService.MAX_SCORE : fuzzyMatch.score; + + // Prioritize full exact matches, then partial exact matches, then fuzzy matches. + if (fullMatch) { + return matchScore + QuickFileOpenService.FULL_MATCH; + } else if (partMatch) { + return matchScore + QuickFileOpenService.PART_MATCH; + } else { + return matchScore; + } } // Get the item's member values for comparison. diff --git a/packages/file-search/src/node/file-search-service-impl.spec.ts b/packages/file-search/src/node/file-search-service-impl.spec.ts index cc2f34ccb7477..2700329289906 100644 --- a/packages/file-search/src/node/file-search-service-impl.spec.ts +++ b/packages/file-search/src/node/file-search-service-impl.spec.ts @@ -195,4 +195,34 @@ describe('search-service', function (): void { }); }); + describe('search with whitespaces', () => { + const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources')).toString(); + + it('should support file searches with whitespaces', async () => { + const matches = await service.find('foo sub', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 }); + + expect(matches).to.be.length(2); + expect(matches[0].endsWith('subdir1/sub-bar/foo.txt')); + expect(matches[1].endsWith('subdir1/sub2/foo.txt')); + }); + + it('should support fuzzy file searches with whitespaces', async () => { + const matchesExact = await service.find('foo sbd2', { rootUris: [rootUri], fuzzyMatch: false, useGitIgnore: true, limit: 200 }); + const matchesFuzzy = await service.find('foo sbd2', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 }); + + expect(matchesExact).to.be.length(0); + expect(matchesFuzzy).to.be.length(1); + expect(matchesFuzzy[0].endsWith('subdir1/sub2/foo.txt')); + }); + + it('should support file searches with whitespaces regardless of order', async () => { + const matchesA = await service.find('foo sub', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 }); + const matchesB = await service.find('sub foo', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 }); + + expect(matchesA).to.not.be.empty; + expect(matchesB).to.not.be.empty; + expect(matchesA).to.deep.eq(matchesB); + }); + }); + }); diff --git a/packages/file-search/src/node/file-search-service-impl.ts b/packages/file-search/src/node/file-search-service-impl.ts index da28c10890c96..566b9ad005fb4 100644 --- a/packages/file-search/src/node/file-search-service-impl.ts +++ b/packages/file-search/src/node/file-search-service-impl.ts @@ -80,24 +80,39 @@ export class FileSearchServiceImpl implements FileSearchService { searchPattern = searchPattern.replace(/\//g, '\\'); } + const WHITESPACE_QUERY_SEPARATOR = ' '; const stringPattern = searchPattern.toLocaleLowerCase(); + const stringPatterns = stringPattern.split(WHITESPACE_QUERY_SEPARATOR); + await Promise.all(Object.keys(roots).map(async root => { try { const rootUri = new URI(root); const rootPath = FileUri.fsPath(rootUri); const rootOptions = roots[root]; + await this.doFind(rootUri, rootOptions, candidate => { + // Convert OS-native candidate path to a file URI string const fileUri = FileUri.create(path.resolve(rootPath, candidate)).toString(); + // Skip results that have already been matched. if (exactMatches.has(fileUri) || fuzzyMatches.has(fileUri)) { return; } - if (!searchPattern || searchPattern === '*' || candidate.toLocaleLowerCase().indexOf(stringPattern) !== -1) { + + // Determine if the candidate matches any of the patterns exactly or fuzzy + const patternExists = stringPatterns.every(pattern => candidate.toLocaleLowerCase().indexOf(pattern) !== -1); + if (patternExists) { + exactMatches.add(fileUri); + } else if (!searchPattern || searchPattern === '*') { exactMatches.add(fileUri); - } else if (opts.fuzzyMatch && fuzzy.test(searchPattern, candidate)) { - fuzzyMatches.add(fileUri); + } else { + const fuzzyPatternExists = stringPatterns.every(pattern => fuzzy.test(pattern, candidate)); + if (opts.fuzzyMatch && fuzzyPatternExists) { + fuzzyMatches.add(fileUri); + } } + // Preemptively terminate the search when the list of exact matches reaches the limit. if (exactMatches.size === opts.limit) { cancellationSource.cancel(); @@ -107,6 +122,7 @@ export class FileSearchServiceImpl implements FileSearchService { console.error('Failed to search:', root, e); } })); + if (clientToken && clientToken.isCancellationRequested) { return []; }