diff --git a/src/vs/workbench/api/node/extHostSearch.ts b/src/vs/workbench/api/node/extHostSearch.ts index 6684a6c88d5..616387de355 100644 --- a/src/vs/workbench/api/node/extHostSearch.ts +++ b/src/vs/workbench/api/node/extHostSearch.ts @@ -5,6 +5,7 @@ 'use strict'; import * as pfs from 'vs/base/node/pfs'; +import * as extfs from 'vs/base/node/extfs'; import * as path from 'path'; import * as arrays from 'vs/base/common/arrays'; import { asWinJsPromise } from 'vs/base/common/async'; @@ -33,7 +34,7 @@ export class ExtHostSearch implements ExtHostSearchShape { private _fileSearchManager: FileSearchManager; - constructor(mainContext: IMainContext, private _schemeTransformer: ISchemeTransformer, private _pfs = pfs) { + constructor(mainContext: IMainContext, private _schemeTransformer: ISchemeTransformer, private _extfs = extfs, private _pfs = pfs) { this._proxy = mainContext.getProxy(MainContext.MainThreadSearch); this._fileSearchManager = new FileSearchManager(this._pfs); } @@ -78,7 +79,8 @@ export class ExtHostSearch implements ExtHostSearchShape { }); } - $provideTextSearchResults(handle: number, session: number, pattern: IPatternInfo, query: IRawSearchQuery): TPromise { + $provideTextSearchResults(handle: number, session: number, pattern: IPatternInfo, rawQuery: IRawSearchQuery): TPromise { + const query = reviveQuery(rawQuery); return TPromise.join( query.folderQueries.map(fq => this.provideTextSearchResultsForFolder(handle, session, pattern, query, fq)) ).then( @@ -88,7 +90,7 @@ export class ExtHostSearch implements ExtHostSearchShape { }); } - private provideTextSearchResultsForFolder(handle: number, session: number, pattern: IPatternInfo, query: IRawSearchQuery, folderQuery: IFolderQuery): TPromise { + private provideTextSearchResultsForFolder(handle: number, session: number, pattern: IPatternInfo, query: ISearchQuery, folderQuery: IFolderQuery): TPromise { const provider = this._searchProvider.get(handle); if (!provider.provideTextSearchResults) { return TPromise.as(undefined); @@ -106,15 +108,35 @@ export class ExtHostSearch implements ExtHostSearchShape { encoding: query.fileEncoding }; + const queryTester = new QueryGlobTester(query, folderQuery); const collector = new TextSearchResultsCollector(handle, session, this._proxy); + const folderStr = folderQuery.folder.fsPath; + const testingPs = []; const progress = { - report: (data: vscode.TextSearchResult) => { - collector.add(data); + report: (result: vscode.TextSearchResult) => { + const relativePath = path.relative(folderStr, result.uri.fsPath); + + testingPs.push( + queryTester.includedInQuery(relativePath, path.basename(relativePath), () => this.readdir(path.dirname(result.uri.fsPath))).then( + included => included && collector.add(result))); } }; return asWinJsPromise(token => provider.provideTextSearchResults(pattern, searchOptions, progress, token)) + .then(() => TPromise.join(testingPs)) .then(() => collector.flush()); } + + private readdir(dirname: string): TPromise { + return new TPromise((resolve, reject) => { + this._extfs.readdir(dirname, (err, files) => { + if (err) { + return reject(err); + } + + resolve(files); + }); + }); + } } /** @@ -306,6 +328,83 @@ interface IInternalFileMatch { size?: number; } +class QueryGlobTester { + + private _excludeExpression: glob.IExpression; + private _parsedExcludeExpression: glob.ParsedExpression; + + private _parsedIncludeExpression: glob.ParsedExpression; + + constructor(config: ISearchQuery, folderQuery: IFolderQuery) { + this._excludeExpression = { + ...(config.excludePattern || {}), + ...(folderQuery.excludePattern || {}) + }; + this._parsedExcludeExpression = glob.parse(this._excludeExpression); + + // Empty includeExpression means include nothing, so no {} shortcuts + let includeExpression: glob.IExpression = config.includePattern; + if (folderQuery.includePattern) { + if (includeExpression) { + includeExpression = { + ...includeExpression, + ...folderQuery.includePattern + }; + } else { + includeExpression = folderQuery.includePattern; + } + } + + if (includeExpression) { + this._parsedIncludeExpression = glob.parse(includeExpression); + } + } + + /** + * Guaranteed sync - siblingsFn should not return a promise. + */ + public includedInQuerySync(testPath: string, basename?: string, siblingsFn?: () => string[]): boolean { + if (this._parsedExcludeExpression && this._parsedExcludeExpression(testPath, basename, siblingsFn)) { + return false; + } + + if (this._parsedIncludeExpression && !this._parsedIncludeExpression(testPath, basename, siblingsFn)) { + return false; + } + + return true; + } + + /** + * Guaranteed async. + */ + public async includedInQuery(testPath: string, basename?: string, siblingsFn?: () => string[] | TPromise): TPromise { + if (this._parsedExcludeExpression && await this._parsedExcludeExpression(testPath, basename, siblingsFn)) { + return false; + } + + if (this._parsedIncludeExpression && !await this._parsedIncludeExpression(testPath, basename, siblingsFn)) { + return false; + } + + return true; + } + + public hasSiblingExcludeClauses(): boolean { + return hasSiblingClauses(this._excludeExpression); + } +} + +function hasSiblingClauses(pattern: glob.IExpression): boolean { + for (let key in pattern) { + if (typeof pattern[key] !== 'boolean') { + return true; + } + } + + return false; +} + class FileSearchEngine { private filePattern: string; private normalizedFilePatternLowercase: string; @@ -418,12 +517,8 @@ class FileSearchEngine { let filePatternSeen = false; const tree = this.initDirectoryTree(); - const folderExcludeExpression: glob.IExpression = { - ...(this.config.excludePattern || {}), - ...(fq.excludePattern || {}) - }; - const parsedFolderExcludeExpression = glob.parse(folderExcludeExpression); - const noSiblingsClauses = !hasSiblingClauses(folderExcludeExpression); + const queryTester = new QueryGlobTester(this.config, fq); + const noSiblingsClauses = !queryTester.hasSiblingExcludeClauses(); const onProviderResult = (result: URI) => { if (this.isCanceled) { @@ -475,7 +570,7 @@ class FileSearchEngine { } } - this.matchDirectoryTree(tree, folderStr, parsedFolderExcludeExpression, onResult); + this.matchDirectoryTree(tree, folderStr, queryTester, onResult); return null; }).then( () => { @@ -536,7 +631,7 @@ class FileSearchEngine { add(relativeFile); } - private matchDirectoryTree({ rootEntries, pathToEntries }: IDirectoryTree, rootFolder: string, excludePattern: glob.ParsedExpression, onResult: (result: IInternalFileMatch) => void) { + private matchDirectoryTree({ rootEntries, pathToEntries }: IDirectoryTree, rootFolder: string, queryTester: QueryGlobTester, onResult: (result: IInternalFileMatch) => void) { const self = this; const filePattern = this.filePattern; function matchDirectory(entries: IDirectoryEntry[]) { @@ -549,7 +644,7 @@ class FileSearchEngine { // If the user searches for the exact file name, we adjust the glob matching // to ignore filtering by siblings because the user seems to know what she // is searching for and we want to include the result in that case anyway - if (excludePattern(relativePath, basename, () => filePattern !== basename ? entries.map(entry => entry.basename) : [])) { + if (!queryTester.includedInQuerySync(relativePath, basename, () => filePattern !== basename ? entries.map(entry => entry.basename) : [])) { continue; } @@ -657,16 +752,6 @@ class FileSearchEngine { } } -function hasSiblingClauses(pattern: glob.IExpression): boolean { - for (let key in pattern) { - if (typeof pattern[key] !== 'boolean') { - return true; - } - } - - return false; -} - interface ISearchComplete { limitHit: boolean; stats?: any; @@ -678,8 +763,7 @@ class FileSearchManager { private caches: { [cacheKey: string]: Cache; } = Object.create(null); - constructor(private _pfs: typeof pfs) { - } + constructor(private _pfs: typeof pfs) { } public fileSearch(config: ISearchQuery, provider: vscode.SearchProvider): PPromise> { if (config.sortByScore) { diff --git a/src/vs/workbench/test/electron-browser/api/extHostSearch.test.ts b/src/vs/workbench/test/electron-browser/api/extHostSearch.test.ts index 8b2f7c5f992..3b6ccb8701e 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostSearch.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostSearch.test.ts @@ -6,6 +6,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as extfs from 'vs/base/node/extfs'; import URI, { UriComponents } from 'vs/base/common/uri'; import { TPromise } from 'vs/base/common/winjs.base'; import { IRawFileMatch2, IRawSearchQuery, QueryType, ISearchQuery, IPatternInfo, IFileMatch } from 'vs/platform/search/common/search'; @@ -46,6 +47,8 @@ class MockMainThreadSearch implements MainThreadSearchShape { } } +let mockExtfs: Partial; + suite('ExtHostSearch', () => { async function registerTestSearchProvider(provider: vscode.SearchProvider): TPromise { disposables.push(extHostSearch.registerSearchProvider('file', provider)); @@ -103,7 +106,9 @@ suite('ExtHostSearch', () => { mockMainThreadSearch = new MockMainThreadSearch(); rpcProtocol.set(MainContext.MainThreadSearch, mockMainThreadSearch); - extHostSearch = new ExtHostSearch(rpcProtocol, null); + + mockExtfs = {}; + extHostSearch = new ExtHostSearch(rpcProtocol, null, mockExtfs as typeof extfs); }); teardown(() => { @@ -579,6 +584,14 @@ suite('ExtHostSearch', () => { }; } + function makeTextResult(uri: URI): vscode.TextSearchResult { + return { + preview: makePreview('foo'), + range: new Range(0, 0, 0, 3), + uri + }; + } + function getSimpleQuery(): ISearchQuery { return { type: QueryType.Text, @@ -643,16 +656,8 @@ suite('ExtHostSearch', () => { test('basic results', async () => { const providedResults: vscode.TextSearchResult[] = [ - { - preview: makePreview('foo'), - range: new Range(0, 0, 0, 3), - uri: makeAbsoluteURI(rootFolderA, 'file1.ts') - }, - { - preview: makePreview('bar'), - range: new Range(1, 0, 1, 3), - uri: makeAbsoluteURI(rootFolderA, 'file2.ts') - } + makeTextResult(makeAbsoluteURI(rootFolderA, 'file1.ts')), + makeTextResult(makeAbsoluteURI(rootFolderA, 'file2.ts')) ]; await registerTestSearchProvider({ @@ -665,5 +670,246 @@ suite('ExtHostSearch', () => { const results = await runTextSearch(getPattern('foo'), getSimpleQuery()); assertResults(results, providedResults); }); + + test('all provider calls get global include/excludes', async () => { + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + assert.equal(options.includes.length, 1); + assert.equal(options.excludes.length, 1); + return TPromise.wrap(null); + } + }); + + const query: IRawSearchQuery = { + type: QueryType.Text, + + includePattern: { + '*.ts': true + }, + + excludePattern: { + '*.js': true + }, + + folderQueries: [ + { folder: rootFolderA }, + { folder: rootFolderB } + ] + }; + + await runTextSearch(getPattern('foo'), query); + }); + + test('global/local include/excludes combined', async () => { + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + if (options.folder.toString() === rootFolderA.toString()) { + assert.deepEqual(options.includes.sort(), ['*.ts', 'foo']); + assert.deepEqual(options.excludes.sort(), ['*.js', 'bar']); + } else { + assert.deepEqual(options.includes.sort(), ['*.ts']); + assert.deepEqual(options.excludes.sort(), ['*.js']); + } + + return TPromise.wrap(null); + } + }); + + const query: IRawSearchQuery = { + type: QueryType.Text, + + includePattern: { + '*.ts': true + }, + excludePattern: { + '*.js': true + }, + folderQueries: [ + { + folder: rootFolderA, + includePattern: { + 'foo': true + }, + excludePattern: { + 'bar': true + } + }, + { folder: rootFolderB } + ] + }; + + await runTextSearch(getPattern('foo'), query); + }); + + test('include/excludes resolved correctly', async () => { + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + assert.deepEqual(options.includes.sort(), ['*.jsx', '*.ts']); + assert.deepEqual(options.excludes.sort(), []); + + return TPromise.wrap(null); + } + }); + + const query: ISearchQuery = { + type: QueryType.Text, + + includePattern: { + '*.ts': true, + '*.jsx': false + }, + excludePattern: { + '*.js': true, + '*.tsx': false + }, + folderQueries: [ + { + folder: rootFolderA, + includePattern: { + '*.jsx': true + }, + excludePattern: { + '*.js': false + } + } + ] + }; + + await runTextSearch(getPattern('foo'), query); + }); + + test('provider fail', async () => { + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + throw new Error('Provider fail'); + } + }); + + try { + await runTextSearch(getPattern('foo'), getSimpleQuery()); + assert(false, 'Expected to fail'); + } catch { + // expected to fail + } + }); + + test('basic sibling clause', async () => { + mockExtfs.readdir = (_path: string, callback: (error: Error, files: string[]) => void) => { + if (_path === rootFolderA.fsPath) { + callback(null, [ + 'file1.js', + 'file1.ts' + ]); + } else { + callback(new Error('Wrong path'), null); + } + }; + + const providedResults: vscode.TextSearchResult[] = [ + makeTextResult(makeAbsoluteURI(rootFolderA, 'file1.js')), + makeTextResult(makeAbsoluteURI(rootFolderA, 'file1.ts')) + ]; + + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + providedResults.forEach(r => progress.report(r)); + return TPromise.wrap(null); + } + }); + + const query: ISearchQuery = { + type: QueryType.Text, + + excludePattern: { + '*.js': { + when: '$(basename).ts' + } + }, + + folderQueries: [ + { folder: rootFolderA } + ] + }; + + const results = await runTextSearch(getPattern('foo'), query); + assertResults(results, providedResults.slice(1)); + }); + + test('multiroot sibling clause', async () => { + mockExtfs.readdir = (_path: string, callback: (error: Error, files: string[]) => void) => { + if (_path === makeAbsoluteURI(rootFolderA, 'folder').fsPath) { + callback(null, [ + 'fileA.scss', + 'fileA.css', + 'file2.css' + ]); + } else if (_path === rootFolderB.fsPath) { + callback(null, [ + 'fileB.ts', + 'fileB.js', + 'file3.js' + ]); + } else { + callback(new Error('Wrong path'), null); + } + }; + + await registerTestSearchProvider({ + provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Thenable { + let reportedResults; + if (options.folder.fsPath === rootFolderA.fsPath) { + reportedResults = [ + makeTextResult(makeAbsoluteURI(rootFolderA, 'folder/fileA.scss')), + makeTextResult(makeAbsoluteURI(rootFolderA, 'folder/fileA.css')), + makeTextResult(makeAbsoluteURI(rootFolderA, 'folder/file2.css')) + ]; + } else { + reportedResults = [ + makeTextResult(makeAbsoluteURI(rootFolderB, 'fileB.ts')), + makeTextResult(makeAbsoluteURI(rootFolderB, 'fileB.js')), + makeTextResult(makeAbsoluteURI(rootFolderB, 'file3.js')) + ]; + } + + reportedResults.forEach(r => progress.report(r)); + return TPromise.wrap(null); + } + }); + + const query: ISearchQuery = { + type: QueryType.Text, + + excludePattern: { + '*.js': { + when: '$(basename).ts' + }, + '*.css': true + }, + folderQueries: [ + { + folder: rootFolderA, + excludePattern: { + 'folder/*.css': { + when: '$(basename).scss' + } + } + }, + { + folder: rootFolderB, + excludePattern: { + '*.js': false + } + } + ] + }; + + const results = await runTextSearch(getPattern('foo'), query); + assertResults(results, [ + makeTextResult(makeAbsoluteURI(rootFolderA, 'folder/fileA.scss')), + makeTextResult(makeAbsoluteURI(rootFolderA, 'folder/file2.css')), + makeTextResult(makeAbsoluteURI(rootFolderB, 'fileB.ts')), + makeTextResult(makeAbsoluteURI(rootFolderB, 'fileB.js')), + makeTextResult(makeAbsoluteURI(rootFolderB, 'file3.js'))]); + }); }); });