Skip to content

Commit

Permalink
Add Whitespace Query Support to Quick File Open
Browse files Browse the repository at this point in the history
What it Does

Fixes [eclipse-theia#8747](eclipse-theia#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 <sean.a.tan@ericsson.com>
  • Loading branch information
seantan22 committed Jan 27, 2021
1 parent 47be972 commit e651b82
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 6 deletions.
27 changes: 24 additions & 3 deletions packages/file-search/src/browser/quick-file-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,30 @@ 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;
const MAX_SCORE = 1000;
const FULL_MATCH = 500;
const PART_MATCH = 250;

// Adjust for whitespaces in the query
const querySplit = query.split(' ');
const queryJoin = querySplit.join('');

// Check full exact matches and partial exact matches
const fullMatch = querySplit.every(part => str.indexOf(part) !== -1);
const partMatch = querySplit.some(part => str.indexOf(part) !== -1);

// Check fuzzy matches
const fuzzyMatch = fuzzy.match(queryJoin, query);
const matchScore = (fuzzyMatch.score === Infinity) ? MAX_SCORE : fuzzyMatch.score;

// Prioritize full exact matches, then partial exact matches, then fuzzy matches
if (fullMatch) {
return matchScore + FULL_MATCH;
} else if (partMatch) {
return matchScore + PART_MATCH;
} else {
return matchScore;
}
}

// Get the item's member values for comparison.
Expand Down
30 changes: 30 additions & 0 deletions packages/file-search/src/node/file-search-service-impl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

});
22 changes: 19 additions & 3 deletions packages/file-search/src/node/file-search-service-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -107,6 +122,7 @@ export class FileSearchServiceImpl implements FileSearchService {
console.error('Failed to search:', root, e);
}
}));

if (clientToken && clientToken.isCancellationRequested) {
return [];
}
Expand Down

0 comments on commit e651b82

Please sign in to comment.