提交 c26d6e6c 编写于 作者: R Rob Lourens

Refactor text seach

To allow better code sharing between EH and search proc
上级 ee40fa3e
......@@ -46,6 +46,11 @@ declare module 'vscode' {
*/
pattern: string;
/**
* Whether or not `pattern` should match multiple lines of text.
*/
isMultiline?: boolean;
/**
* Whether or not `pattern` should be interpreted as a regular expression.
*/
......
......@@ -18,6 +18,7 @@ import { TPromise } from 'vs/base/common/winjs.base';
import { compareItemsByScore, IItemAccessor, prepareQuery, ScorerCache } from 'vs/base/parts/quickopen/common/quickOpenScorer';
import { ICachedSearchStats, IFileIndexProviderStats, IFileMatch, IFileSearchStats, IFolderQuery, IRawSearchQuery, ISearchCompleteStats, ISearchQuery } from 'vs/platform/search/common/search';
import * as vscode from 'vscode';
import { resolvePatternsForProvider, QueryGlobTester } from 'vs/workbench/services/search/node/search';
export interface IInternalFileMatch {
base: URI;
......@@ -27,105 +28,6 @@ export interface IInternalFileMatch {
size?: number;
}
/**
* Computes the patterns that the provider handles. Discards sibling clauses and 'false' patterns
*/
export function resolvePatternsForProvider(globalPattern: glob.IExpression, folderPattern: glob.IExpression): string[] {
const merged = {
...(globalPattern || {}),
...(folderPattern || {})
};
return Object.keys(merged)
.filter(key => {
const value = merged[key];
return typeof value === 'boolean' && value;
});
}
export 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, hasSibling?: (name: string) => boolean): boolean {
if (this._parsedExcludeExpression && this._parsedExcludeExpression(testPath, basename, hasSibling)) {
return false;
}
if (this._parsedIncludeExpression && !this._parsedIncludeExpression(testPath, basename, hasSibling)) {
return false;
}
return true;
}
/**
* Guaranteed async.
*/
public includedInQuery(testPath: string, basename?: string, hasSibling?: (name: string) => boolean | TPromise<boolean>): TPromise<boolean> {
const excludeP = this._parsedExcludeExpression ?
TPromise.as(this._parsedExcludeExpression(testPath, basename, hasSibling)).then(result => !!result) :
TPromise.wrap(false);
return excludeP.then(excluded => {
if (excluded) {
return false;
}
return this._parsedIncludeExpression ?
TPromise.as(this._parsedIncludeExpression(testPath, basename, hasSibling)).then(result => !!result) :
TPromise.wrap(true);
}).then(included => {
return included;
});
}
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;
}
export interface IDirectoryEntry {
base: URI;
relativePath: string;
......
......@@ -7,20 +7,22 @@ import * as path from 'path';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import * as glob from 'vs/base/common/glob';
import { toDisposable } from 'vs/base/common/lifecycle';
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import * as resources from 'vs/base/common/resources';
import { StopWatch } from 'vs/base/common/stopwatch';
import { URI, UriComponents } from 'vs/base/common/uri';
import { TPromise } from 'vs/base/common/winjs.base';
import * as extfs from 'vs/base/node/extfs';
import { ILogService } from 'vs/platform/log/common/log';
import { IFileMatch, IFileSearchProviderStats, IFolderQuery, IPatternInfo, IRawSearchQuery, ISearchCompleteStats, ISearchQuery, ITextSearchResult } from 'vs/platform/search/common/search';
import { IFileMatch, IFileSearchProviderStats, IFolderQuery, IPatternInfo, IRawSearchQuery, ISearchCompleteStats, ISearchQuery } from 'vs/platform/search/common/search';
import { ExtHostConfiguration } from 'vs/workbench/api/node/extHostConfiguration';
import { FileIndexSearchManager, IDirectoryEntry, IDirectoryTree, IInternalFileMatch, QueryGlobTester, resolvePatternsForProvider } from 'vs/workbench/api/node/extHostSearch.fileIndex';
import { FileIndexSearchManager, IDirectoryEntry, IDirectoryTree, IInternalFileMatch } from 'vs/workbench/api/node/extHostSearch.fileIndex';
import { RipgrepSearchProvider } from 'vs/workbench/services/search/node/ripgrepSearchEH';
import { OutputChannel } from 'vs/workbench/services/search/node/ripgrepSearchUtils';
import { QueryGlobTester, resolvePatternsForProvider } from 'vs/workbench/services/search/node/search';
import { TextSearchEngine } from 'vs/workbench/services/search/node/textSearchEngine';
import * as vscode from 'vscode';
import { ExtHostSearchShape, IMainContext, MainContext, MainThreadSearchShape } from './extHost.protocol';
import { RipgrepSearchProvider } from 'vs/workbench/services/search/node/ripgrepSearchEH';
export interface ISchemeTransformer {
transformOutgoing(scheme: string): string;
......@@ -29,8 +31,9 @@ export interface ISchemeTransformer {
export class ExtHostSearch implements ExtHostSearchShape {
private readonly _proxy: MainThreadSearchShape;
private readonly _fileSearchProvider = new Map<number, vscode.FileSearchProvider>();
private readonly _textSearchProvider = new Map<number, vscode.TextSearchProvider>();
private readonly _fileSearchProvider = new Map<number, vscode.FileSearchProvider>();
private _internalFileSearchProvider;
private readonly _fileIndexProvider = new Map<number, vscode.FileIndexProvider>();
private _handlePool: number = 0;
......@@ -52,7 +55,17 @@ export class ExtHostSearch implements ExtHostSearchShape {
return scheme;
}
registerFileSearchProvider(scheme: string, provider: vscode.FileSearchProvider) {
registerTextSearchProvider(scheme: string, provider: vscode.TextSearchProvider): IDisposable {
const handle = this._handlePool++;
this._textSearchProvider.set(handle, provider);
this._proxy.$registerTextSearchProvider(handle, this._transformScheme(scheme));
return toDisposable(() => {
this._textSearchProvider.delete(handle);
this._proxy.$unregisterProvider(handle);
});
}
registerFileSearchProvider(scheme: string, provider: vscode.FileSearchProvider): IDisposable {
const handle = this._handlePool++;
this._fileSearchProvider.set(handle, provider);
this._proxy.$registerFileSearchProvider(handle, this._transformScheme(scheme));
......@@ -62,17 +75,17 @@ export class ExtHostSearch implements ExtHostSearchShape {
});
}
registerTextSearchProvider(scheme: string, provider: vscode.TextSearchProvider) {
registerInternalFileSearchProvider(scheme: string, provider): IDisposable {
const handle = this._handlePool++;
this._textSearchProvider.set(handle, provider);
this._proxy.$registerTextSearchProvider(handle, this._transformScheme(scheme));
this._internalFileSearchProvider = provider;
this._proxy.$registerFileSearchProvider(handle, this._transformScheme(scheme));
return toDisposable(() => {
this._textSearchProvider.delete(handle);
this._internalFileSearchProvider = null;
this._proxy.$unregisterProvider(handle);
});
}
registerFileIndexProvider(scheme: string, provider: vscode.FileIndexProvider) {
registerFileIndexProvider(scheme: string, provider: vscode.FileIndexProvider): IDisposable {
const handle = this._handlePool++;
this._fileIndexProvider.set(handle, provider);
this._proxy.$registerFileIndexProvider(handle, this._transformScheme(scheme));
......@@ -98,6 +111,10 @@ export class ExtHostSearch implements ExtHostSearchShape {
}
$clearCache(cacheKey: string): Thenable<void> {
if (this._internalFileSearchProvider) {
this._internalFileSearchProvider.clearCache();
}
// Actually called once per provider.
// Only relevant to file index search.
return this._fileIndexSearchManager.clearCache(cacheKey);
......@@ -117,11 +134,9 @@ export class ExtHostSearch implements ExtHostSearchShape {
function registerEHProviders(extHostSearch: ExtHostSearch, logService: ILogService, configService: ExtHostConfiguration) {
if (configService.getConfiguration('searchRipgrep').enable) {
console.log(`enabled`);
const outputChannel = new OutputChannel(logService);
extHostSearch.registerTextSearchProvider('file', new RipgrepSearchProvider(outputChannel));
}
const outputChannel = new OutputChannel(logService);
extHostSearch.registerTextSearchProvider('file', new RipgrepSearchProvider(outputChannel));
}
function reviveQuery(rawQuery: IRawSearchQuery): ISearchQuery {
......@@ -141,287 +156,6 @@ function reviveFolderQuery(rawFolderQuery: IFolderQuery<UriComponents>): IFolder
};
}
class TextSearchResultsCollector {
private _batchedCollector: BatchedCollector<IFileMatch>;
private _currentFolderIdx: number;
private _currentUri: URI;
private _currentFileMatch: IFileMatch;
constructor(private _onResult: (result: IFileMatch[]) => void) {
this._batchedCollector = new BatchedCollector<IFileMatch>(512, items => this.sendItems(items));
}
add(data: vscode.TextSearchResult, folderIdx: number): void {
// Collects TextSearchResults into IInternalFileMatches and collates using BatchedCollector.
// This is efficient for ripgrep which sends results back one file at a time. It wouldn't be efficient for other search
// providers that send results in random order. We could do this step afterwards instead.
if (this._currentFileMatch && (this._currentFolderIdx !== folderIdx || !resources.isEqual(this._currentUri, data.uri))) {
this.pushToCollector();
this._currentFileMatch = null;
}
if (!this._currentFileMatch) {
this._currentFolderIdx = folderIdx;
this._currentFileMatch = {
resource: data.uri,
matches: []
};
}
this._currentFileMatch.matches.push(extensionResultToFrontendResult(data));
}
private pushToCollector(): void {
const size = this._currentFileMatch ?
this._currentFileMatch.matches.length :
0;
this._batchedCollector.addItem(this._currentFileMatch, size);
}
flush(): void {
this.pushToCollector();
this._batchedCollector.flush();
}
private sendItems(items: IFileMatch[]): void {
this._onResult(items);
}
}
function extensionResultToFrontendResult(data: vscode.TextSearchResult): ITextSearchResult {
// Warning: result from RipgrepTextSearchEH has fake vscode.Range. Don't depend on any other props beyond these...
return {
preview: {
match: {
startLineNumber: data.preview.match.start.line,
startColumn: data.preview.match.start.character,
endLineNumber: data.preview.match.end.line,
endColumn: data.preview.match.end.character
},
text: data.preview.text
},
range: {
startLineNumber: data.range.start.line,
startColumn: data.range.start.character,
endLineNumber: data.range.end.line,
endColumn: data.range.end.character
}
};
}
/**
* Collects items that have a size - before the cumulative size of collected items reaches START_BATCH_AFTER_COUNT, the callback is called for every
* set of items collected.
* But after that point, the callback is called with batches of maxBatchSize.
* If the batch isn't filled within some time, the callback is also called.
*/
class BatchedCollector<T> {
private static readonly TIMEOUT = 4000;
// After START_BATCH_AFTER_COUNT items have been collected, stop flushing on timeout
private static readonly START_BATCH_AFTER_COUNT = 50;
private totalNumberCompleted = 0;
private batch: T[] = [];
private batchSize = 0;
private timeoutHandle: any;
constructor(private maxBatchSize: number, private cb: (items: T[]) => void) {
}
addItem(item: T, size: number): void {
if (!item) {
return;
}
this.addItemToBatch(item, size);
}
addItems(items: T[], size: number): void {
if (!items) {
return;
}
if (this.maxBatchSize > 0) {
this.addItemsToBatch(items, size);
} else {
this.cb(items);
}
}
private addItemToBatch(item: T, size: number): void {
this.batch.push(item);
this.batchSize += size;
this.onUpdate();
}
private addItemsToBatch(item: T[], size: number): void {
this.batch = this.batch.concat(item);
this.batchSize += size;
this.onUpdate();
}
private onUpdate(): void {
if (this.totalNumberCompleted < BatchedCollector.START_BATCH_AFTER_COUNT) {
// Flush because we aren't batching yet
this.flush();
} else if (this.batchSize >= this.maxBatchSize) {
// Flush because the batch is full
this.flush();
} else if (!this.timeoutHandle) {
// No timeout running, start a timeout to flush
this.timeoutHandle = setTimeout(() => {
this.flush();
}, BatchedCollector.TIMEOUT);
}
}
flush(): void {
if (this.batchSize) {
this.totalNumberCompleted += this.batchSize;
this.cb(this.batch);
this.batch = [];
this.batchSize = 0;
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = 0;
}
}
}
}
class TextSearchEngine {
private collector: TextSearchResultsCollector;
private isLimitHit: boolean;
private resultCount = 0;
constructor(private pattern: IPatternInfo, private config: ISearchQuery, private provider: vscode.TextSearchProvider, private _extfs: typeof extfs) {
}
public search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken): TPromise<ISearchCompleteStats> {
const folderQueries = this.config.folderQueries;
const tokenSource = new CancellationTokenSource();
token.onCancellationRequested(() => tokenSource.cancel());
return new TPromise<ISearchCompleteStats>((resolve, reject) => {
this.collector = new TextSearchResultsCollector(onProgress);
let isCanceled = false;
const onResult = (match: vscode.TextSearchResult, folderIdx: number) => {
if (isCanceled) {
return;
}
if (this.resultCount >= this.config.maxResults) {
this.isLimitHit = true;
isCanceled = true;
tokenSource.cancel();
}
if (!this.isLimitHit) {
this.resultCount++;
this.collector.add(match, folderIdx);
}
};
// For each root folder
TPromise.join(folderQueries.map((fq, i) => {
return this.searchInFolder(fq, r => onResult(r, i), tokenSource.token);
})).then(results => {
tokenSource.dispose();
this.collector.flush();
const someFolderHitLImit = results.some(result => result && result.limitHit);
resolve({
limitHit: this.isLimitHit || someFolderHitLImit,
stats: {
type: 'textSearchProvider'
}
});
}, (errs: Error[]) => {
tokenSource.dispose();
const errMsg = errs
.map(err => toErrorMessage(err))
.filter(msg => !!msg)[0];
reject(new Error(errMsg));
});
});
}
private searchInFolder(folderQuery: IFolderQuery<URI>, onResult: (result: vscode.TextSearchResult) => void, token: CancellationToken): TPromise<vscode.TextSearchComplete> {
const queryTester = new QueryGlobTester(this.config, folderQuery);
const testingPs = [];
const progress = {
report: (result: vscode.TextSearchResult) => {
const hasSibling = folderQuery.folder.scheme === 'file' && glob.hasSiblingPromiseFn(() => {
return this.readdir(path.dirname(result.uri.fsPath));
});
const relativePath = path.relative(folderQuery.folder.fsPath, result.uri.fsPath);
testingPs.push(
queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling)
.then(included => {
if (included) {
onResult(result);
}
}));
}
};
const searchOptions = this.getSearchOptionsForFolder(folderQuery);
return new TPromise(resolve => process.nextTick(resolve))
.then(() => this.provider.provideTextSearchResults(patternInfoToQuery(this.pattern), searchOptions, progress, token))
.then(result => {
return TPromise.join(testingPs)
.then(() => result);
});
}
private readdir(dirname: string): TPromise<string[]> {
return new TPromise((resolve, reject) => {
this._extfs.readdir(dirname, (err, files) => {
if (err) {
return reject(err);
}
resolve(files);
});
});
}
private getSearchOptionsForFolder(fq: IFolderQuery<URI>): vscode.TextSearchOptions {
const includes = resolvePatternsForProvider(this.config.includePattern, fq.includePattern);
const excludes = resolvePatternsForProvider(this.config.excludePattern, fq.excludePattern);
return {
folder: URI.from(fq.folder),
excludes,
includes,
useIgnoreFiles: !this.config.disregardIgnoreFiles,
useGlobalIgnoreFiles: !this.config.disregardGlobalIgnoreFiles,
followSymlinks: !this.config.ignoreSymlinks,
encoding: this.config.fileEncoding,
maxFileSize: this.config.maxFileSize,
maxResults: this.config.maxResults,
previewOptions: this.config.previewOptions
};
}
}
function patternInfoToQuery(patternInfo: IPatternInfo): vscode.TextSearchQuery {
return <vscode.TextSearchQuery>{
isCaseSensitive: patternInfo.isCaseSensitive || false,
isRegExp: patternInfo.isRegExp || false,
isWordMatch: patternInfo.isWordMatch || false,
pattern: patternInfo.pattern
};
}
class FileSearchEngine {
private filePattern: string;
private includePattern: glob.ParsedExpression;
......
......@@ -22,7 +22,6 @@ import * as extfs from 'vs/base/node/extfs';
import * as flow from 'vs/base/node/flow';
import { IProgress, ISearchEngineStats } from 'vs/platform/search/common/search';
import { spawnRipgrepCmd } from './ripgrepFileSearch';
import { rgErrorMsgForDisplay } from './ripgrepTextSearch';
import { IFolderSearch, IRawFileMatch, IRawSearch, ISearchEngine, ISearchEngineSuccess } from './search';
import { StopWatch } from 'vs/base/common/stopwatch';
......@@ -754,3 +753,34 @@ class AbsoluteAndRelativeParsedExpression {
return pathTerms;
}
}
export function rgErrorMsgForDisplay(msg: string): string | undefined {
const lines = msg.trim().split('\n');
const firstLine = lines[0].trim();
if (strings.startsWith(firstLine, 'Error parsing regex')) {
return firstLine;
}
if (strings.startsWith(firstLine, 'regex parse error')) {
return strings.uppercaseFirstLetter(lines[lines.length - 1].trim());
}
if (strings.startsWith(firstLine, 'error parsing glob') ||
strings.startsWith(firstLine, 'unsupported encoding')) {
// Uppercase first letter
return firstLine.charAt(0).toUpperCase() + firstLine.substr(1);
}
if (firstLine === `Literal '\\n' not allowed.`) {
// I won't localize this because none of the Ripgrep error messages are localized
return `Literal '\\n' currently not supported`;
}
if (strings.startsWith(firstLine, 'Literal ')) {
// Other unsupported chars
return firstLine;
}
return undefined;
}
......@@ -19,10 +19,11 @@ import { compareItemsByScore, IItemAccessor, prepareQuery, ScorerCache } from 'v
import { MAX_FILE_SIZE } from 'vs/platform/files/node/files';
import { ICachedSearchStats, IFileSearchStats, IProgress } from 'vs/platform/search/common/search';
import { Engine as FileSearchEngine, FileWalker } from 'vs/workbench/services/search/node/fileSearch';
import { RipgrepEngine } from 'vs/workbench/services/search/node/ripgrepTextSearch';
import { TextSearchEngineAdapter } from 'vs/workbench/services/search/node/textSearchAdapter';
import { Engine as TextSearchEngine } from 'vs/workbench/services/search/node/textSearch';
import { TextSearchWorkerProvider } from 'vs/workbench/services/search/node/textSearchWorkerProvider';
import { IFileSearchProgressItem, IRawFileMatch, IRawSearch, IRawSearchService, ISearchEngine, ISearchEngineSuccess, ISerializedFileMatch, ISerializedSearchComplete, ISerializedSearchProgressItem, ISerializedSearchSuccess } from './search';
import { BatchedCollector } from 'vs/workbench/services/search/node/textSearchEngine';
gracefulFs.gracefulify(fs);
......@@ -81,20 +82,10 @@ export class SearchService implements IRawSearchService {
private ripgrepTextSearch(config: IRawSearch, progressCallback: IProgressCallback, token: CancellationToken): Promise<ISerializedSearchSuccess> {
config.maxFilesize = MAX_FILE_SIZE;
let engine = new RipgrepEngine(config);
token.onCancellationRequested(() => engine.cancel());
const engine = new TextSearchEngineAdapter(config);
return new Promise<ISerializedSearchSuccess>((c, e) => {
// Use BatchedCollector to get new results to the frontend every 2s at least, until 50 results have been returned
const collector = new BatchedCollector<ISerializedFileMatch>(SearchService.BATCH_SIZE, progressCallback);
engine.search((match) => {
collector.addItem(match, match.numMatches);
}, (message) => {
progressCallback(message);
}, (error, stats) => {
collector.flush();
engine.search(token, progressCallback, progressCallback, (error, stats) => {
if (error) {
e(error);
} else {
......@@ -486,89 +477,3 @@ const FileMatchItemAccessor = new class implements IItemAccessor<IRawFileMatch>
return match.relativePath; // e.g. some/path/to/file/myFile.txt
}
};
/**
* Collects items that have a size - before the cumulative size of collected items reaches START_BATCH_AFTER_COUNT, the callback is called for every
* set of items collected.
* But after that point, the callback is called with batches of maxBatchSize.
* If the batch isn't filled within some time, the callback is also called.
*/
class BatchedCollector<T> {
private static readonly TIMEOUT = 4000;
// After RUN_TIMEOUT_UNTIL_COUNT items have been collected, stop flushing on timeout
private static readonly START_BATCH_AFTER_COUNT = 50;
private totalNumberCompleted = 0;
private batch: T[] = [];
private batchSize = 0;
private timeoutHandle: any;
constructor(private maxBatchSize: number, private cb: (items: T | T[]) => void) {
}
addItem(item: T, size: number): void {
if (!item) {
return;
}
if (this.maxBatchSize > 0) {
this.addItemToBatch(item, size);
} else {
this.cb(item);
}
}
addItems(items: T[], size: number): void {
if (!items) {
return;
}
if (this.maxBatchSize > 0) {
this.addItemsToBatch(items, size);
} else {
this.cb(items);
}
}
private addItemToBatch(item: T, size: number): void {
this.batch.push(item);
this.batchSize += size;
this.onUpdate();
}
private addItemsToBatch(item: T[], size: number): void {
this.batch = this.batch.concat(item);
this.batchSize += size;
this.onUpdate();
}
private onUpdate(): void {
if (this.totalNumberCompleted < BatchedCollector.START_BATCH_AFTER_COUNT) {
// Flush because we aren't batching yet
this.flush();
} else if (this.batchSize >= this.maxBatchSize) {
// Flush because the batch is full
this.flush();
} else if (!this.timeoutHandle) {
// No timeout running, start a timeout to flush
this.timeoutHandle = setTimeout(() => {
this.flush();
}, BatchedCollector.TIMEOUT);
}
}
flush(): void {
if (this.batchSize) {
this.totalNumberCompleted += this.batchSize;
this.cb(this.batch);
this.batch = [];
this.batchSize = 0;
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = 0;
}
}
}
}
......@@ -4,15 +4,16 @@
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import { rgPath } from 'vscode-ripgrep';
import { isMacintosh as isMac } from 'vs/base/common/platform';
import * as path from 'path';
import * as glob from 'vs/base/common/glob';
import { startsWith } from 'vs/base/common/strings';
import { normalizeNFD } from 'vs/base/common/normalization';
import * as objects from 'vs/base/common/objects';
import * as paths from 'vs/base/common/paths';
import { isMacintosh as isMac } from 'vs/base/common/platform';
import * as strings from 'vs/base/common/strings';
import { rgPath } from 'vscode-ripgrep';
import { IFolderSearch, IRawSearch } from './search';
import { foldersToIncludeGlobs, foldersToRgExcludeGlobs } from './ripgrepTextSearch';
import { anchorGlob } from 'vs/workbench/services/search/node/ripgrepSearchUtils';
// If vscode-ripgrep is in an .asar file, then the binary is unpacked.
const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked');
......@@ -33,7 +34,7 @@ function getRgArgs(config: IRawSearch, folderQuery: IFolderSearch, includePatter
// includePattern can't have siblingClauses
foldersToIncludeGlobs([folderQuery], includePattern, false).forEach(globArg => {
const inclusion = anchor(globArg);
const inclusion = anchorGlob(globArg);
args.push('-g', inclusion);
if (isMac) {
const normalized = normalizeNFD(inclusion);
......@@ -47,7 +48,7 @@ function getRgArgs(config: IRawSearch, folderQuery: IFolderSearch, includePatter
const rgGlobs = foldersToRgExcludeGlobs([folderQuery], excludePattern, undefined, false);
rgGlobs.globArgs.forEach(globArg => {
const exclusion = `!${anchor(globArg)}`;
const exclusion = `!${anchorGlob(globArg)}`;
args.push('-g', exclusion);
if (isMac) {
const normalized = normalizeNFD(exclusion);
......@@ -82,6 +83,100 @@ function getRgArgs(config: IRawSearch, folderQuery: IFolderSearch, includePatter
return { args, siblingClauses };
}
function anchor(glob: string) {
return startsWith(glob, '**') || startsWith(glob, '/') ? glob : `/${glob}`;
export interface IRgGlobResult {
globArgs: string[];
siblingClauses: glob.IExpression;
}
export function foldersToRgExcludeGlobs(folderQueries: IFolderSearch[], globalExclude: glob.IExpression, excludesToSkip?: Set<string>, absoluteGlobs = true): IRgGlobResult {
const globArgs: string[] = [];
let siblingClauses: glob.IExpression = {};
folderQueries.forEach(folderQuery => {
const totalExcludePattern = objects.assign({}, folderQuery.excludePattern || {}, globalExclude || {});
const result = globExprsToRgGlobs(totalExcludePattern, absoluteGlobs && folderQuery.folder, excludesToSkip);
globArgs.push(...result.globArgs);
if (result.siblingClauses) {
siblingClauses = objects.assign(siblingClauses, result.siblingClauses);
}
});
return { globArgs, siblingClauses };
}
export function foldersToIncludeGlobs(folderQueries: IFolderSearch[], globalInclude: glob.IExpression, absoluteGlobs = true): string[] {
const globArgs: string[] = [];
folderQueries.forEach(folderQuery => {
const totalIncludePattern = objects.assign({}, globalInclude || {}, folderQuery.includePattern || {});
const result = globExprsToRgGlobs(totalIncludePattern, absoluteGlobs && folderQuery.folder);
globArgs.push(...result.globArgs);
});
return globArgs;
}
function globExprsToRgGlobs(patterns: glob.IExpression, folder?: string, excludesToSkip?: Set<string>): IRgGlobResult {
const globArgs: string[] = [];
let siblingClauses: glob.IExpression = null;
Object.keys(patterns)
.forEach(key => {
if (excludesToSkip && excludesToSkip.has(key)) {
return;
}
if (!key) {
return;
}
const value = patterns[key];
key = trimTrailingSlash(folder ? getAbsoluteGlob(folder, key) : key);
// glob.ts requires forward slashes, but a UNC path still must start with \\
// #38165 and #38151
if (strings.startsWith(key, '\\\\')) {
key = '\\\\' + key.substr(2).replace(/\\/g, '/');
} else {
key = key.replace(/\\/g, '/');
}
if (typeof value === 'boolean' && value) {
if (strings.startsWith(key, '\\\\')) {
// Absolute globs UNC paths don't work properly, see #58758
key += '**';
}
globArgs.push(fixDriveC(key));
} else if (value && value.when) {
if (!siblingClauses) {
siblingClauses = {};
}
siblingClauses[key] = value;
}
});
return { globArgs, siblingClauses };
}
/**
* Resolves a glob like "node_modules/**" in "/foo/bar" to "/foo/bar/node_modules/**".
* Special cases C:/foo paths to write the glob like /foo instead - see https://github.com/BurntSushi/ripgrep/issues/530.
*
* Exported for testing
*/
export function getAbsoluteGlob(folder: string, key: string): string {
return paths.isAbsolute(key) ?
key :
path.join(folder, key);
}
function trimTrailingSlash(str: string): string {
str = strings.rtrim(str, '\\');
return strings.rtrim(str, '/');
}
export function fixDriveC(path: string): string {
const root = paths.getRoot(path);
return root.toLowerCase() === 'c:/' ?
path.replace(/^c:[/\\]/i, '/') :
path;
}
......@@ -3,20 +3,12 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { startsWith } from 'vs/base/common/strings';
import { ILogService } from 'vs/platform/log/common/log';
import * as vscode from 'vscode';
export type Maybe<T> = T | null | undefined;
export function fixDriveC(_path: string): string {
const root = path.parse(_path).root;
return root.toLowerCase() === 'c:/' ?
_path.replace(/^c:[/\\]/i, '/') :
_path;
}
export function anchorGlob(glob: string): string {
return startsWith(glob, '**') || startsWith(glob, '/') ? glob : `/${glob}`;
}
......@@ -85,7 +77,11 @@ export class Range {
with(_: any): Range { return null; }
}
export class OutputChannel {
export interface IOutputChannel {
appendLine(msg: string): void;
}
export class OutputChannel implements IOutputChannel {
constructor(@ILogService private logService: ILogService) { }
appendLine(msg: string): void {
......
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import { EventEmitter } from 'events';
import * as path from 'path';
import { NodeStringDecoder, StringDecoder } from 'string_decoder';
import * as glob from 'vs/base/common/glob';
import * as objects from 'vs/base/common/objects';
import * as paths from 'vs/base/common/paths';
import * as platform from 'vs/base/common/platform';
import * as strings from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { TPromise } from 'vs/base/common/winjs.base';
import * as encoding from 'vs/base/node/encoding';
import * as extfs from 'vs/base/node/extfs';
import { Range } from 'vs/editor/common/core/range';
import { IProgress, ITextSearchPreviewOptions, ITextSearchStats, TextSearchResult } from 'vs/platform/search/common/search';
import { rgPath } from 'vscode-ripgrep';
import { FileMatch, IFolderSearch, IRawSearch, ISerializedFileMatch, ISerializedSearchSuccess } from './search';
// If vscode-ripgrep is in an .asar file, then the binary is unpacked.
const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked');
export class RipgrepEngine {
private isDone = false;
private rgProc: cp.ChildProcess;
private killRgProcFn: (code?: number) => void;
private postProcessExclusions: glob.ParsedExpression;
private ripgrepParser: RipgrepParser;
private resultsHandledP: TPromise<any> = TPromise.wrap(null);
constructor(private config: IRawSearch) {
this.killRgProcFn = () => this.rgProc && this.rgProc.kill();
}
cancel(): void {
this.isDone = true;
this.ripgrepParser.cancel();
this.rgProc.kill();
}
// TODO@Rob - make promise-based once the old search is gone, and I don't need them to have matching interfaces anymore
search(onResult: (match: ISerializedFileMatch) => void, onMessage: (message: IProgress) => void, done: (error: Error, complete: ISerializedSearchSuccess) => void): void {
if (!this.config.folderQueries.length && !this.config.extraFiles.length) {
process.removeListener('exit', this.killRgProcFn);
done(null, {
type: 'success',
limitHit: false,
stats: <ITextSearchStats>{
type: 'searchProcess'
}
});
return;
}
const rgArgs = getRgArgs(this.config);
if (rgArgs.siblingClauses) {
this.postProcessExclusions = glob.parseToAsync(rgArgs.siblingClauses, { trimForExclusions: true });
}
const cwd = platform.isWindows ? 'c:/' : '/';
const escapedArgs = rgArgs.args
.map(arg => arg.match(/^-/) ? arg : `'${arg}'`)
.join(' ');
let rgCmd = `rg ${escapedArgs}\n - cwd: ${cwd}`;
if (rgArgs.siblingClauses) {
rgCmd += `\n - Sibling clauses: ${JSON.stringify(rgArgs.siblingClauses)}`;
}
onMessage({ message: rgCmd });
this.rgProc = cp.spawn(rgDiskPath, rgArgs.args, { cwd });
process.once('exit', this.killRgProcFn);
this.ripgrepParser = new RipgrepParser(this.config.maxResults, cwd, this.config.previewOptions);
this.ripgrepParser.on('result', (match: ISerializedFileMatch) => {
if (this.postProcessExclusions) {
const handleResultP = (<TPromise<string>>this.postProcessExclusions(match.path, undefined, glob.hasSiblingPromiseFn(() => getSiblings(match.path))))
.then(globMatch => {
if (!globMatch) {
onResult(match);
}
});
this.resultsHandledP = TPromise.join([this.resultsHandledP, handleResultP]);
} else {
onResult(match);
}
});
this.ripgrepParser.on('hitLimit', () => {
this.cancel();
process.removeListener('exit', this.killRgProcFn);
done(null, {
type: 'success',
limitHit: true,
stats: {
type: 'searchProcess'
}
});
});
this.rgProc.stdout.on('data', data => {
this.ripgrepParser.handleData(data);
});
let gotData = false;
this.rgProc.stdout.once('data', () => gotData = true);
let stderr = '';
this.rgProc.stderr.on('data', data => {
const message = data.toString();
onMessage({ message });
stderr += message;
});
this.rgProc.on('close', code => {
// Trigger last result, then wait on async result handling
this.ripgrepParser.flush();
this.resultsHandledP.then(() => {
this.rgProc = null;
if (!this.isDone) {
this.isDone = true;
let displayMsg: string;
process.removeListener('exit', this.killRgProcFn);
if (stderr && !gotData && (displayMsg = rgErrorMsgForDisplay(stderr))) {
done(new Error(displayMsg), {
type: 'success',
limitHit: false,
stats: null
});
} else {
done(null, {
type: 'success',
limitHit: false,
stats: null
});
}
}
});
});
}
}
/**
* Read the first line of stderr and return an error for display or undefined, based on a whitelist.
* Ripgrep produces stderr output which is not from a fatal error, and we only want the search to be
* "failed" when a fatal error was produced.
*/
export function rgErrorMsgForDisplay(msg: string): string | undefined {
const lines = msg.trim().split('\n');
const firstLine = lines[0].trim();
if (strings.startsWith(firstLine, 'Error parsing regex')) {
return firstLine;
}
if (strings.startsWith(firstLine, 'regex parse error')) {
return strings.uppercaseFirstLetter(lines[lines.length - 1].trim());
}
if (strings.startsWith(firstLine, 'error parsing glob') ||
strings.startsWith(firstLine, 'unsupported encoding')) {
// Uppercase first letter
return firstLine.charAt(0).toUpperCase() + firstLine.substr(1);
}
if (firstLine === `Literal '\\n' not allowed.`) {
// I won't localize this because none of the Ripgrep error messages are localized
return `Literal '\\n' currently not supported`;
}
if (strings.startsWith(firstLine, 'Literal ')) {
// Other unsupported chars
return firstLine;
}
return undefined;
}
export class RipgrepParser extends EventEmitter {
private fileMatch: FileMatch;
private remainder = '';
private isDone: boolean;
private stringDecoder: NodeStringDecoder;
private numResults = 0;
constructor(private maxResults: number, private rootFolder: string, private previewOptions?: ITextSearchPreviewOptions) {
super();
this.stringDecoder = new StringDecoder();
}
public cancel(): void {
this.isDone = true;
}
public flush(): void {
this.handleDecodedData(this.stringDecoder.end());
if (this.fileMatch) {
this.onResult();
}
}
public handleData(data: Buffer | string): void {
if (this.isDone) {
return;
}
const dataStr = typeof data === 'string' ? data : this.stringDecoder.write(data);
this.handleDecodedData(dataStr);
}
private handleDecodedData(decodedData: string): void {
// check for newline before appending to remainder
let newlineIdx = decodedData.indexOf('\n');
// If the previous data chunk didn't end in a newline, prepend it to this chunk
const dataStr = this.remainder + decodedData;
if (newlineIdx >= 0) {
newlineIdx += this.remainder.length;
} else {
// Shortcut
this.remainder = dataStr;
return;
}
let prevIdx = 0;
while (newlineIdx >= 0) {
this.handleLine(dataStr.substring(prevIdx, newlineIdx).trim());
prevIdx = newlineIdx + 1;
newlineIdx = dataStr.indexOf('\n', prevIdx);
}
this.remainder = dataStr.substring(prevIdx).trim();
}
private handleLine(outputLine: string): void {
if (this.isDone || !outputLine) {
return;
}
let parsedLine: any;
try {
parsedLine = JSON.parse(outputLine);
} catch (e) {
throw new Error(`malformed line from rg: ${outputLine}`);
}
if (parsedLine.type === 'begin') {
const path = bytesOrTextToString(parsedLine.data.path);
this.fileMatch = this.getFileMatch(path);
} else if (parsedLine.type === 'match') {
this.handleMatchLine(parsedLine);
} else if (parsedLine.type === 'end') {
if (this.fileMatch) {
this.onResult();
}
}
}
private handleMatchLine(parsedLine: any): void {
let hitLimit = false;
const uri = URI.file(path.join(this.rootFolder, parsedLine.data.path.text));
parsedLine.data.submatches.map((match: any) => {
if (hitLimit) {
return null;
}
this.numResults++;
if (this.numResults >= this.maxResults) {
// Finish the line, then report the result below
hitLimit = true;
}
return this.submatchToResult(parsedLine, match, uri);
}).forEach((result: any) => {
if (result) {
this.fileMatch.addMatch(result);
}
});
if (hitLimit) {
this.cancel();
this.onResult();
this.emit('hitLimit');
}
}
private submatchToResult(parsedLine: any, match: any, uri: URI): TextSearchResult {
const lineNumber = parsedLine.data.line_number - 1;
let lineText = bytesOrTextToString(parsedLine.data.lines);
let matchText = bytesOrTextToString(match.match);
const newlineMatches = matchText.match(/\n/g);
const newlines = newlineMatches ? newlineMatches.length : 0;
let startCol = match.start;
const endLineNumber = lineNumber + newlines;
let endCol = match.end - (lineText.lastIndexOf('\n', lineText.length - 2) + 1);
if (lineNumber === 0) {
if (strings.startsWithUTF8BOM(lineText)) {
lineText = strings.stripUTF8BOM(lineText);
startCol -= 3;
endCol -= 3;
}
}
const range = new Range(lineNumber, startCol, endLineNumber, endCol);
return new TextSearchResult(lineText, range, this.previewOptions);
}
private getFileMatch(relativeOrAbsolutePath: string): FileMatch {
const absPath = path.isAbsolute(relativeOrAbsolutePath) ?
relativeOrAbsolutePath :
path.join(this.rootFolder, relativeOrAbsolutePath);
return new FileMatch(absPath);
}
private onResult(): void {
this.emit('result', this.fileMatch.serialize());
this.fileMatch = null;
}
}
function bytesOrTextToString(obj: any): string {
return obj.bytes ?
new Buffer(obj.bytes, 'base64').toString() :
obj.text;
}
export interface IRgGlobResult {
globArgs: string[];
siblingClauses: glob.IExpression;
}
export function foldersToRgExcludeGlobs(folderQueries: IFolderSearch[], globalExclude: glob.IExpression, excludesToSkip?: Set<string>, absoluteGlobs = true): IRgGlobResult {
const globArgs: string[] = [];
let siblingClauses: glob.IExpression = {};
folderQueries.forEach(folderQuery => {
const totalExcludePattern = objects.assign({}, folderQuery.excludePattern || {}, globalExclude || {});
const result = globExprsToRgGlobs(totalExcludePattern, absoluteGlobs && folderQuery.folder, excludesToSkip);
globArgs.push(...result.globArgs);
if (result.siblingClauses) {
siblingClauses = objects.assign(siblingClauses, result.siblingClauses);
}
});
return { globArgs, siblingClauses };
}
export function foldersToIncludeGlobs(folderQueries: IFolderSearch[], globalInclude: glob.IExpression, absoluteGlobs = true): string[] {
const globArgs: string[] = [];
folderQueries.forEach(folderQuery => {
const totalIncludePattern = objects.assign({}, globalInclude || {}, folderQuery.includePattern || {});
const result = globExprsToRgGlobs(totalIncludePattern, absoluteGlobs && folderQuery.folder);
globArgs.push(...result.globArgs);
});
return globArgs;
}
function globExprsToRgGlobs(patterns: glob.IExpression, folder?: string, excludesToSkip?: Set<string>): IRgGlobResult {
const globArgs: string[] = [];
let siblingClauses: glob.IExpression = null;
Object.keys(patterns)
.forEach(key => {
if (excludesToSkip && excludesToSkip.has(key)) {
return;
}
if (!key) {
return;
}
const value = patterns[key];
key = trimTrailingSlash(folder ? getAbsoluteGlob(folder, key) : key);
// glob.ts requires forward slashes, but a UNC path still must start with \\
// #38165 and #38151
if (strings.startsWith(key, '\\\\')) {
key = '\\\\' + key.substr(2).replace(/\\/g, '/');
} else {
key = key.replace(/\\/g, '/');
}
if (typeof value === 'boolean' && value) {
if (strings.startsWith(key, '\\\\')) {
// Absolute globs UNC paths don't work properly, see #58758
key += '**';
}
globArgs.push(fixDriveC(key));
} else if (value && value.when) {
if (!siblingClauses) {
siblingClauses = {};
}
siblingClauses[key] = value;
}
});
return { globArgs, siblingClauses };
}
/**
* Resolves a glob like "node_modules/**" in "/foo/bar" to "/foo/bar/node_modules/**".
* Special cases C:/foo paths to write the glob like /foo instead - see https://github.com/BurntSushi/ripgrep/issues/530.
*
* Exported for testing
*/
export function getAbsoluteGlob(folder: string, key: string): string {
return paths.isAbsolute(key) ?
key :
path.join(folder, key);
}
function trimTrailingSlash(str: string): string {
str = strings.rtrim(str, '\\');
return strings.rtrim(str, '/');
}
export function fixDriveC(path: string): string {
const root = paths.getRoot(path);
return root.toLowerCase() === 'c:/' ?
path.replace(/^c:[/\\]/i, '/') :
path;
}
function getRgArgs(config: IRawSearch) {
const args = ['--hidden', '--heading', '--line-number', '--color', 'ansi', '--colors', 'path:none', '--colors', 'line:none', '--colors', 'match:fg:red', '--colors', 'match:style:nobold'];
args.push(config.contentPattern.isCaseSensitive ? '--case-sensitive' : '--ignore-case');
// includePattern can't have siblingClauses
foldersToIncludeGlobs(config.folderQueries, config.includePattern).forEach(globArg => {
args.push('-g', globArg);
});
let siblingClauses: glob.IExpression;
// Find excludes that are exactly the same in all folderQueries - e.g. from user settings, and that start with `**`.
// To make the command shorter, don't resolve these against every folderQuery path - see #33189.
const universalExcludes = findUniversalExcludes(config.folderQueries);
const rgGlobs = foldersToRgExcludeGlobs(config.folderQueries, config.excludePattern, universalExcludes);
rgGlobs.globArgs
.forEach(rgGlob => args.push('-g', `!${rgGlob}`));
if (universalExcludes) {
universalExcludes
.forEach(exclude => args.push('-g', `!${trimTrailingSlash(exclude)}`));
}
siblingClauses = rgGlobs.siblingClauses;
if (config.maxFilesize) {
args.push('--max-filesize', config.maxFilesize + '');
}
if (config.disregardIgnoreFiles) {
// Don't use .gitignore or .ignore
args.push('--no-ignore');
} else {
args.push('--no-ignore-parent');
}
// Follow symlinks
if (!config.ignoreSymlinks) {
args.push('--follow');
}
if (config.folderQueries[0]) {
const folder0Encoding = config.folderQueries[0].fileEncoding;
if (folder0Encoding && folder0Encoding !== 'utf8' && config.folderQueries.every(fq => fq.fileEncoding === folder0Encoding)) {
args.push('--encoding', encoding.toCanonicalName(folder0Encoding));
}
}
// Ripgrep handles -- as a -- arg separator. Only --.
// - is ok, --- is ok, --some-flag is handled as query text. Need to special case.
if (config.contentPattern.pattern === '--') {
config.contentPattern.isRegExp = true;
config.contentPattern.pattern = '\\-\\-';
}
let searchPatternAfterDoubleDashes: string;
if (config.contentPattern.isWordMatch) {
const regexp = strings.createRegExp(config.contentPattern.pattern, config.contentPattern.isRegExp, { wholeWord: config.contentPattern.isWordMatch });
const regexpStr = regexp.source.replace(/\\\//g, '/'); // RegExp.source arbitrarily returns escaped slashes. Search and destroy.
args.push('--regexp', regexpStr);
} else if (config.contentPattern.isRegExp) {
args.push('--regexp', fixRegexEndingPattern(config.contentPattern.pattern));
} else {
searchPatternAfterDoubleDashes = config.contentPattern.pattern;
args.push('--fixed-strings');
}
args.push('--no-config');
if (config.disregardGlobalIgnoreFiles) {
args.push('--no-ignore-global');
}
args.push('--json');
if (config.contentPattern.isMultiline) {
args.push('--multiline');
}
// Folder to search
args.push('--');
if (searchPatternAfterDoubleDashes) {
// Put the query after --, in case the query starts with a dash
args.push(searchPatternAfterDoubleDashes);
}
args.push(...config.folderQueries.map(q => q.folder));
args.push(...config.extraFiles);
return { args, siblingClauses };
}
function getSiblings(file: string): TPromise<string[]> {
return new TPromise<string[]>((resolve, reject) => {
extfs.readdir(path.dirname(file), (error: Error, files: string[]) => {
if (error) {
reject(error);
}
resolve(files);
});
});
}
function findUniversalExcludes(folderQueries: IFolderSearch[]): Set<string> {
if (folderQueries.length < 2) {
// Nothing to simplify
return null;
}
const firstFolder = folderQueries[0];
if (!firstFolder.excludePattern) {
return null;
}
const universalExcludes = new Set<string>();
Object.keys(firstFolder.excludePattern).forEach(key => {
if (strings.startsWith(key, '**') && folderQueries.every(q => q.excludePattern && q.excludePattern[key] === true)) {
universalExcludes.add(key);
}
});
return universalExcludes;
}
// Exported for testing
export function fixRegexEndingPattern(pattern: string): string {
// Replace an unescaped $ at the end of the pattern with \r?$
// Match $ preceeded by none or even number of literal \
return pattern.match(/([^\\]|^)(\\\\)*\$$/) ?
pattern.replace(/\$$/, '\\r?$') :
pattern;
}
......@@ -7,18 +7,18 @@ import * as cp from 'child_process';
import { EventEmitter } from 'events';
import * as path from 'path';
import { NodeStringDecoder, StringDecoder } from 'string_decoder';
import { startsWith } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import * as vscode from 'vscode';
import { rgPath } from 'vscode-ripgrep';
import { anchorGlob, createTextSearchResult, Maybe, Range, OutputChannel } from './ripgrepSearchUtils';
import { URI } from 'vs/base/common/uri';
import { startsWith } from 'vs/base/common/strings';
import { anchorGlob, createTextSearchResult, IOutputChannel, Maybe, Range } from './ripgrepSearchUtils';
// If vscode-ripgrep is in an .asar file, then the binary is unpacked.
const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked');
export class RipgrepTextSearchEngine {
constructor(private outputChannel: OutputChannel) { }
constructor(private outputChannel: IOutputChannel) { }
provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress<vscode.TextSearchResult>, token: vscode.CancellationToken): Thenable<vscode.TextSearchComplete> {
this.outputChannel.appendLine(`provideTextSearchResults ${query.pattern}, ${JSON.stringify({
......@@ -302,6 +302,10 @@ function getRgArgs(query: vscode.TextSearchQuery, options: vscode.TextSearchOpti
args.push('--json');
if (query.isMultiline) {
args.push('--multiline');
}
// Folder to search
args.push('--');
......@@ -370,7 +374,7 @@ function stripUTF8BOM(str: string): string {
return startsWithUTF8BOM(str) ? str.substr(1) : str;
}
function fixRegexEndingPattern(pattern: string): string {
export function fixRegexEndingPattern(pattern: string): string {
// Replace an unescaped $ at the end of the pattern with \r?$
// Match $ preceeded by none or even number of literal \
return pattern.match(/([^\\]|^)(\\\\)*\$$/) ?
......
......@@ -4,15 +4,15 @@
*--------------------------------------------------------------------------------------------*/
import { Event } from 'vs/base/common/event';
import { IExpression } from 'vs/base/common/glob';
import * as glob from 'vs/base/common/glob';
import { TPromise } from 'vs/base/common/winjs.base';
import { IFileSearchStats, IPatternInfo, IProgress, ISearchEngineStats, ITextSearchPreviewOptions, ITextSearchResult, ITextSearchStats } from 'vs/platform/search/common/search';
import { IFileSearchStats, IPatternInfo, IProgress, ISearchEngineStats, ITextSearchPreviewOptions, ITextSearchResult, ITextSearchStats, ISearchQuery, IFolderQuery } from 'vs/platform/search/common/search';
import { ITelemetryData } from 'vs/platform/telemetry/common/telemetry';
export interface IFolderSearch {
folder: string;
excludePattern?: IExpression;
includePattern?: IExpression;
excludePattern?: glob.IExpression;
includePattern?: glob.IExpression;
fileEncoding?: string;
disregardIgnoreFiles?: boolean;
disregardGlobalIgnoreFiles?: boolean;
......@@ -23,8 +23,8 @@ export interface IRawSearch {
ignoreSymlinks?: boolean;
extraFiles?: string[];
filePattern?: string;
excludePattern?: IExpression;
includePattern?: IExpression;
excludePattern?: glob.IExpression;
includePattern?: glob.IExpression;
contentPattern?: IPatternInfo;
maxResults?: number;
exists?: boolean;
......@@ -127,3 +127,102 @@ export class FileMatch implements ISerializedFileMatch {
};
}
}
/**
* Computes the patterns that the provider handles. Discards sibling clauses and 'false' patterns
*/
export function resolvePatternsForProvider(globalPattern: glob.IExpression, folderPattern: glob.IExpression): string[] {
const merged = {
...(globalPattern || {}),
...(folderPattern || {})
};
return Object.keys(merged)
.filter(key => {
const value = merged[key];
return typeof value === 'boolean' && value;
});
}
export 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, hasSibling?: (name: string) => boolean): boolean {
if (this._parsedExcludeExpression && this._parsedExcludeExpression(testPath, basename, hasSibling)) {
return false;
}
if (this._parsedIncludeExpression && !this._parsedIncludeExpression(testPath, basename, hasSibling)) {
return false;
}
return true;
}
/**
* Guaranteed async.
*/
public includedInQuery(testPath: string, basename?: string, hasSibling?: (name: string) => boolean | TPromise<boolean>): TPromise<boolean> {
const excludeP = this._parsedExcludeExpression ?
TPromise.as(this._parsedExcludeExpression(testPath, basename, hasSibling)).then(result => !!result) :
TPromise.wrap(false);
return excludeP.then(excluded => {
if (excluded) {
return false;
}
return this._parsedIncludeExpression ?
TPromise.as(this._parsedIncludeExpression(testPath, basename, hasSibling)).then(result => !!result) :
TPromise.wrap(true);
}).then(included => {
return included;
});
}
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;
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from 'vs/base/common/cancellation';
import { URI } from 'vs/base/common/uri';
import * as extfs from 'vs/base/node/extfs';
import { IFolderQuery, IProgress, ISearchQuery, ITextSearchStats, QueryType, IFileMatch } from 'vs/platform/search/common/search';
import { RipgrepTextSearchEngine } from 'vs/workbench/services/search/node/ripgrepTextSearchEH';
import { TextSearchEngine } from 'vs/workbench/services/search/node/textSearchEngine';
import { IRawSearch, ISerializedFileMatch, ISerializedSearchSuccess } from './search';
export class TextSearchEngineAdapter {
constructor(private config: IRawSearch) {
}
// TODO@Rob - make promise-based once the old search is gone, and I don't need them to have matching interfaces anymore
search(token: CancellationToken, onResult: (matches: ISerializedFileMatch[]) => void, onMessage: (message: IProgress) => void, done: (error: Error, complete: ISerializedSearchSuccess) => void): void {
if (!this.config.folderQueries.length && !this.config.extraFiles.length) {
done(null, {
type: 'success',
limitHit: false,
stats: <ITextSearchStats>{
type: 'searchProcess'
}
});
return;
}
const query: ISearchQuery = {
type: QueryType.Text,
cacheKey: this.config.cacheKey,
contentPattern: this.config.contentPattern,
extraFileResources: this.config.extraFiles && this.config.extraFiles.map(f => URI.file(f)),
fileEncoding: this.config.folderQueries[0].fileEncoding, // ?
maxResults: this.config.maxResults,
exists: this.config.exists,
sortByScore: this.config.sortByScore,
disregardIgnoreFiles: this.config.disregardIgnoreFiles,
disregardGlobalIgnoreFiles: this.config.disregardGlobalIgnoreFiles,
ignoreSymlinks: this.config.ignoreSymlinks,
maxFileSize: this.config.maxFilesize,
previewOptions: this.config.previewOptions
};
query.folderQueries = this.config.folderQueries.map(fq => <IFolderQuery>{
disregardGlobalIgnoreFiles: fq.disregardGlobalIgnoreFiles,
disregardIgnoreFiles: fq.disregardIgnoreFiles,
excludePattern: fq.excludePattern,
fileEncoding: fq.fileEncoding,
folder: URI.file(fq.folder),
includePattern: fq.includePattern
});
const pretendOutputChannel = {
appendLine(msg) {
onMessage(msg);
}
};
const textSearchEngine = new TextSearchEngine(this.config.contentPattern, query, new RipgrepTextSearchEngine(pretendOutputChannel), extfs);
textSearchEngine
.search(
matches => {
onResult(matches.map(fileMatchToSerialized));
},
token)
.then(() => done(null, { limitHit: false, stats: null, type: 'success' }));
}
}
function fileMatchToSerialized(match: IFileMatch): ISerializedFileMatch {
return {
path: match.resource.fsPath,
matches: match.matches
};
}
\ No newline at end of file
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import * as glob from 'vs/base/common/glob';
import * as resources from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { TPromise } from 'vs/base/common/winjs.base';
import * as extfs from 'vs/base/node/extfs';
import { IFileMatch, IFolderQuery, IPatternInfo, ISearchCompleteStats, ISearchQuery, ITextSearchResult } from 'vs/platform/search/common/search';
import * as vscode from 'vscode';
import { QueryGlobTester, resolvePatternsForProvider } from 'vs/workbench/services/search/node/search';
export class TextSearchEngine {
private collector: TextSearchResultsCollector;
private isLimitHit: boolean;
private resultCount = 0;
constructor(private pattern: IPatternInfo, private config: ISearchQuery, private provider: vscode.TextSearchProvider, private _extfs: typeof extfs) {
}
public search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken): TPromise<ISearchCompleteStats> {
const folderQueries = this.config.folderQueries;
const tokenSource = new CancellationTokenSource();
token.onCancellationRequested(() => tokenSource.cancel());
return new TPromise<ISearchCompleteStats>((resolve, reject) => {
this.collector = new TextSearchResultsCollector(onProgress);
let isCanceled = false;
const onResult = (match: vscode.TextSearchResult, folderIdx: number) => {
if (isCanceled) {
return;
}
if (this.resultCount >= this.config.maxResults) {
this.isLimitHit = true;
isCanceled = true;
tokenSource.cancel();
}
if (!this.isLimitHit) {
this.resultCount++;
this.collector.add(match, folderIdx);
}
};
// For each root folder
TPromise.join(folderQueries.map((fq, i) => {
return this.searchInFolder(fq, r => onResult(r, i), tokenSource.token);
})).then(results => {
tokenSource.dispose();
this.collector.flush();
const someFolderHitLImit = results.some(result => result && result.limitHit);
resolve({
limitHit: this.isLimitHit || someFolderHitLImit,
stats: {
type: 'textSearchProvider'
}
});
}, (errs: Error[]) => {
tokenSource.dispose();
const errMsg = errs
.map(err => toErrorMessage(err))
.filter(msg => !!msg)[0];
reject(new Error(errMsg));
});
});
}
private searchInFolder(folderQuery: IFolderQuery<URI>, onResult: (result: vscode.TextSearchResult) => void, token: CancellationToken): TPromise<vscode.TextSearchComplete> {
const queryTester = new QueryGlobTester(this.config, folderQuery);
const testingPs = [];
const progress = {
report: (result: vscode.TextSearchResult) => {
const hasSibling = folderQuery.folder.scheme === 'file' && glob.hasSiblingPromiseFn(() => {
return this.readdir(path.dirname(result.uri.fsPath));
});
const relativePath = path.relative(folderQuery.folder.fsPath, result.uri.fsPath);
testingPs.push(
queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling)
.then(included => {
if (included) {
onResult(result);
}
}));
}
};
const searchOptions = this.getSearchOptionsForFolder(folderQuery);
return new TPromise(resolve => process.nextTick(resolve))
.then(() => this.provider.provideTextSearchResults(patternInfoToQuery(this.pattern), searchOptions, progress, token))
.then(result => {
return TPromise.join(testingPs)
.then(() => result);
});
}
private readdir(dirname: string): TPromise<string[]> {
return new TPromise((resolve, reject) => {
this._extfs.readdir(dirname, (err, files) => {
if (err) {
return reject(err);
}
resolve(files);
});
});
}
private getSearchOptionsForFolder(fq: IFolderQuery<URI>): vscode.TextSearchOptions {
const includes = resolvePatternsForProvider(this.config.includePattern, fq.includePattern);
const excludes = resolvePatternsForProvider(this.config.excludePattern, fq.excludePattern);
return {
folder: URI.from(fq.folder),
excludes,
includes,
useIgnoreFiles: !this.config.disregardIgnoreFiles,
useGlobalIgnoreFiles: !this.config.disregardGlobalIgnoreFiles,
followSymlinks: !this.config.ignoreSymlinks,
encoding: this.config.fileEncoding,
maxFileSize: this.config.maxFileSize,
maxResults: this.config.maxResults,
previewOptions: this.config.previewOptions
};
}
}
function patternInfoToQuery(patternInfo: IPatternInfo): vscode.TextSearchQuery {
return <vscode.TextSearchQuery>{
isCaseSensitive: patternInfo.isCaseSensitive || false,
isRegExp: patternInfo.isRegExp || false,
isWordMatch: patternInfo.isWordMatch || false,
isMultiline: patternInfo.isMultiline || false,
pattern: patternInfo.pattern
};
}
export class TextSearchResultsCollector {
private _batchedCollector: BatchedCollector<IFileMatch>;
private _currentFolderIdx: number;
private _currentUri: URI;
private _currentFileMatch: IFileMatch;
constructor(private _onResult: (result: IFileMatch[]) => void) {
this._batchedCollector = new BatchedCollector<IFileMatch>(512, items => this.sendItems(items));
}
add(data: vscode.TextSearchResult, folderIdx: number): void {
// Collects TextSearchResults into IInternalFileMatches and collates using BatchedCollector.
// This is efficient for ripgrep which sends results back one file at a time. It wouldn't be efficient for other search
// providers that send results in random order. We could do this step afterwards instead.
if (this._currentFileMatch && (this._currentFolderIdx !== folderIdx || !resources.isEqual(this._currentUri, data.uri))) {
this.pushToCollector();
this._currentFileMatch = null;
}
if (!this._currentFileMatch) {
this._currentFolderIdx = folderIdx;
this._currentFileMatch = {
resource: data.uri,
matches: []
};
}
this._currentFileMatch.matches.push(extensionResultToFrontendResult(data));
}
private pushToCollector(): void {
const size = this._currentFileMatch ?
this._currentFileMatch.matches.length :
0;
this._batchedCollector.addItem(this._currentFileMatch, size);
}
flush(): void {
this.pushToCollector();
this._batchedCollector.flush();
}
private sendItems(items: IFileMatch[]): void {
this._onResult(items);
}
}
function extensionResultToFrontendResult(data: vscode.TextSearchResult): ITextSearchResult {
// Warning: result from RipgrepTextSearchEH has fake vscode.Range. Don't depend on any other props beyond these...
return {
preview: {
match: {
startLineNumber: data.preview.match.start.line,
startColumn: data.preview.match.start.character,
endLineNumber: data.preview.match.end.line,
endColumn: data.preview.match.end.character
},
text: data.preview.text
},
range: {
startLineNumber: data.range.start.line,
startColumn: data.range.start.character,
endLineNumber: data.range.end.line,
endColumn: data.range.end.character
}
};
}
/**
* Collects items that have a size - before the cumulative size of collected items reaches START_BATCH_AFTER_COUNT, the callback is called for every
* set of items collected.
* But after that point, the callback is called with batches of maxBatchSize.
* If the batch isn't filled within some time, the callback is also called.
*/
export class BatchedCollector<T> {
private static readonly TIMEOUT = 4000;
// After START_BATCH_AFTER_COUNT items have been collected, stop flushing on timeout
private static readonly START_BATCH_AFTER_COUNT = 50;
private totalNumberCompleted = 0;
private batch: T[] = [];
private batchSize = 0;
private timeoutHandle: any;
constructor(private maxBatchSize: number, private cb: (items: T[]) => void) {
}
addItem(item: T, size: number): void {
if (!item) {
return;
}
this.addItemToBatch(item, size);
}
addItems(items: T[], size: number): void {
if (!items) {
return;
}
this.addItemsToBatch(items, size);
}
private addItemToBatch(item: T, size: number): void {
this.batch.push(item);
this.batchSize += size;
this.onUpdate();
}
private addItemsToBatch(item: T[], size: number): void {
this.batch = this.batch.concat(item);
this.batchSize += size;
this.onUpdate();
}
private onUpdate(): void {
if (this.totalNumberCompleted < BatchedCollector.START_BATCH_AFTER_COUNT) {
// Flush because we aren't batching yet
this.flush();
} else if (this.batchSize >= this.maxBatchSize) {
// Flush because the batch is full
this.flush();
} else if (!this.timeoutHandle) {
// No timeout running, start a timeout to flush
this.timeoutHandle = setTimeout(() => {
this.flush();
}, BatchedCollector.TIMEOUT);
}
}
flush(): void {
if (this.batchSize) {
this.totalNumberCompleted += this.batchSize;
this.cb(this.batch);
this.batch = [];
this.batchSize = 0;
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = 0;
}
}
}
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as platform from 'vs/base/common/platform';
import { fixDriveC, getAbsoluteGlob } from 'vs/workbench/services/search/node/ripgrepFileSearch';
suite('RipgrepFileSearch - etc', () => {
function testGetAbsGlob(params: string[]): void {
const [folder, glob, expectedResult] = params;
assert.equal(fixDriveC(getAbsoluteGlob(folder, glob)), expectedResult, JSON.stringify(params));
}
test('getAbsoluteGlob_win', () => {
if (!platform.isWindows) {
return;
}
[
['C:/foo/bar', 'glob/**', '/foo\\bar\\glob\\**'],
['c:/', 'glob/**', '/glob\\**'],
['C:\\foo\\bar', 'glob\\**', '/foo\\bar\\glob\\**'],
['c:\\foo\\bar', 'glob\\**', '/foo\\bar\\glob\\**'],
['c:\\', 'glob\\**', '/glob\\**'],
['\\\\localhost\\c$\\foo\\bar', 'glob/**', '\\\\localhost\\c$\\foo\\bar\\glob\\**'],
// absolute paths are not resolved further
['c:/foo/bar', '/path/something', '/path/something'],
['c:/foo/bar', 'c:\\project\\folder', '/project\\folder']
].forEach(testGetAbsGlob);
});
test('getAbsoluteGlob_posix', () => {
if (platform.isWindows) {
return;
}
[
['/foo/bar', 'glob/**', '/foo/bar/glob/**'],
['/', 'glob/**', '/glob/**'],
// absolute paths are not resolved further
['/', '/project/folder', '/project/folder'],
].forEach(testGetAbsGlob);
});
});
......@@ -4,46 +4,23 @@
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as platform from 'vs/base/common/platform';
import { fixDriveC, fixRegexEndingPattern, getAbsoluteGlob } from 'vs/workbench/services/search/node/ripgrepTextSearch';
import { fixRegexEndingPattern } from 'vs/workbench/services/search/node/ripgrepTextSearchEH';
suite('RipgrepTextSearch - etc', () => {
function testGetAbsGlob(params: string[]): void {
const [folder, glob, expectedResult] = params;
assert.equal(fixDriveC(getAbsoluteGlob(folder, glob)), expectedResult, JSON.stringify(params));
}
test('getAbsoluteGlob_win', () => {
if (!platform.isWindows) {
return;
}
[
['C:/foo/bar', 'glob/**', '/foo\\bar\\glob\\**'],
['c:/', 'glob/**', '/glob\\**'],
['C:\\foo\\bar', 'glob\\**', '/foo\\bar\\glob\\**'],
['c:\\foo\\bar', 'glob\\**', '/foo\\bar\\glob\\**'],
['c:\\', 'glob\\**', '/glob\\**'],
['\\\\localhost\\c$\\foo\\bar', 'glob/**', '\\\\localhost\\c$\\foo\\bar\\glob\\**'],
// absolute paths are not resolved further
['c:/foo/bar', '/path/something', '/path/something'],
['c:/foo/bar', 'c:\\project\\folder', '/project\\folder']
].forEach(testGetAbsGlob);
});
test('getAbsoluteGlob_posix', () => {
if (platform.isWindows) {
return;
test('fixRegexEndingPattern', () => {
function testFixRegexEndingPattern([input, expectedResult]: string[]): void {
assert.equal(fixRegexEndingPattern(input), expectedResult);
}
[
['/foo/bar', 'glob/**', '/foo/bar/glob/**'],
['/', 'glob/**', '/glob/**'],
// absolute paths are not resolved further
['/', '/project/folder', '/project/folder'],
].forEach(testGetAbsGlob);
['foo', 'foo'],
['', ''],
['^foo.*bar\\s+', '^foo.*bar\\s+'],
['foo$', 'foo\\r?$'],
['$', '\\r?$'],
['foo\\$', 'foo\\$'],
['foo\\\\$', 'foo\\\\\\r?$'],
].forEach(testFixRegexEndingPattern);
});
test('fixRegexEndingPattern', () => {
......
......@@ -11,9 +11,10 @@ import { TPromise } from 'vs/base/common/winjs.base';
import { FileWalker } from 'vs/workbench/services/search/node/fileSearch';
import { ISerializedFileMatch, IRawSearch, IFolderSearch } from 'vs/workbench/services/search/node/search';
import { Engine as TextSearchEngine } from 'vs/workbench/services/search/node/textSearch';
import { RipgrepEngine } from 'vs/workbench/services/search/node/ripgrepTextSearch';
import { TextSearchEngineAdapter } from 'vs/workbench/services/search/node/textSearchAdapter';
import { TextSearchWorkerProvider } from 'vs/workbench/services/search/node/textSearchWorkerProvider';
import { getPathFromAmdModule } from 'vs/base/common/amd';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
function countAll(matches: ISerializedFileMatch[]): number {
return matches.reduce((acc, m) => acc + m.numMatches, 0);
......@@ -62,12 +63,12 @@ function doLegacySearchTest(config: IRawSearch, expectedResultCount: number | Fu
function doRipgrepSearchTest(config: IRawSearch, expectedResultCount: number | Function): TPromise<void> {
return new TPromise<void>((resolve, reject) => {
let engine = new RipgrepEngine(config);
let engine = new TextSearchEngineAdapter(config);
let c = 0;
engine.search((result) => {
if (result) {
c += result.numMatches;
engine.search(new CancellationTokenSource().token, (results) => {
if (results) {
c += results.reduce((acc, cur) => acc + cur.numMatches, 0);
}
}, () => { }, (error) => {
try {
......@@ -75,7 +76,7 @@ function doRipgrepSearchTest(config: IRawSearch, expectedResultCount: number | F
if (typeof expectedResultCount === 'function') {
assert(expectedResultCount(c));
} else {
assert.equal(c, expectedResultCount, 'rg');
assert.equal(c, expectedResultCount, `rg ${c} !== ${expectedResultCount}`);
}
} catch (e) {
reject(e);
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册