SQL Operations Studio Public Preview 1 (0.23) release source code

This commit is contained in:
Karl Burtram
2017-11-09 14:30:27 -08:00
parent b88ecb8d93
commit 3cdac41339
8829 changed files with 759707 additions and 286 deletions

View File

@@ -0,0 +1,741 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as childProcess from 'child_process';
import { StringDecoder, NodeStringDecoder } from 'string_decoder';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import fs = require('fs');
import path = require('path');
import { isEqualOrParent } from 'vs/base/common/paths';
import { Readable } from 'stream';
import { TPromise } from 'vs/base/common/winjs.base';
import scorer = require('vs/base/common/scorer');
import objects = require('vs/base/common/objects');
import arrays = require('vs/base/common/arrays');
import platform = require('vs/base/common/platform');
import strings = require('vs/base/common/strings');
import types = require('vs/base/common/types');
import glob = require('vs/base/common/glob');
import { IProgress, IUncachedSearchStats } from 'vs/platform/search/common/search';
import extfs = require('vs/base/node/extfs');
import flow = require('vs/base/node/flow');
import { IRawFileMatch, ISerializedSearchComplete, IRawSearch, ISearchEngine, IFolderSearch } from './search';
enum Traversal {
Node = 1,
MacFind,
WindowsDir,
LinuxFind
}
interface IDirectoryEntry {
base: string;
relativePath: string;
basename: string;
}
interface IDirectoryTree {
rootEntries: IDirectoryEntry[];
pathToEntries: { [relativePath: string]: IDirectoryEntry[] };
}
export class FileWalker {
private config: IRawSearch;
private filePattern: string;
private normalizedFilePatternLowercase: string;
private includePattern: glob.ParsedExpression;
private maxResults: number;
private maxFilesize: number;
private isLimitHit: boolean;
private resultCount: number;
private isCanceled: boolean;
private fileWalkStartTime: number;
private directoriesWalked: number;
private filesWalked: number;
private traversal: Traversal;
private errors: string[];
private cmdForkStartTime: number;
private cmdForkResultTime: number;
private cmdResultCount: number;
private folderExcludePatterns: Map<string, AbsoluteAndRelativeParsedExpression>;
private globalExcludePattern: glob.ParsedExpression;
private walkedPaths: { [path: string]: boolean; };
constructor(config: IRawSearch) {
this.config = config;
this.filePattern = config.filePattern;
this.includePattern = config.includePattern && glob.parse(config.includePattern);
this.maxResults = config.maxResults || null;
this.maxFilesize = config.maxFilesize || null;
this.walkedPaths = Object.create(null);
this.resultCount = 0;
this.isLimitHit = false;
this.directoriesWalked = 0;
this.filesWalked = 0;
this.traversal = Traversal.Node;
this.errors = [];
if (this.filePattern) {
this.normalizedFilePatternLowercase = strings.stripWildcards(this.filePattern).toLowerCase();
}
this.globalExcludePattern = config.excludePattern && glob.parse(config.excludePattern);
this.folderExcludePatterns = new Map<string, AbsoluteAndRelativeParsedExpression>();
config.folderQueries.forEach(folderQuery => {
const folderExcludeExpression: glob.IExpression = objects.assign({}, folderQuery.excludePattern || {}, this.config.excludePattern || {});
// Add excludes for other root folders
config.folderQueries
.map(rootFolderQuery => rootFolderQuery.folder)
.filter(rootFolder => rootFolder !== folderQuery.folder)
.forEach(otherRootFolder => {
// Exclude nested root folders
if (isEqualOrParent(otherRootFolder, folderQuery.folder)) {
folderExcludeExpression[path.relative(folderQuery.folder, otherRootFolder)] = true;
}
});
this.folderExcludePatterns.set(folderQuery.folder, new AbsoluteAndRelativeParsedExpression(folderExcludeExpression, folderQuery.folder));
});
}
public cancel(): void {
this.isCanceled = true;
}
public walk(folderQueries: IFolderSearch[], extraFiles: string[], onResult: (result: IRawFileMatch) => void, done: (error: Error, isLimitHit: boolean) => void): void {
this.fileWalkStartTime = Date.now();
// Support that the file pattern is a full path to a file that exists
this.checkFilePatternAbsoluteMatch((exists, size) => {
if (this.isCanceled) {
return done(null, this.isLimitHit);
}
// Report result from file pattern if matching
if (exists) {
this.resultCount++;
onResult({
relativePath: this.filePattern,
basename: path.basename(this.filePattern),
size
});
// Optimization: a match on an absolute path is a good result and we do not
// continue walking the entire root paths array for other matches because
// it is very unlikely that another file would match on the full absolute path
return done(null, this.isLimitHit);
}
// For each extra file
if (extraFiles) {
extraFiles.forEach(extraFilePath => {
const basename = path.basename(extraFilePath);
if (this.globalExcludePattern && this.globalExcludePattern(extraFilePath, basename)) {
return; // excluded
}
// File: Check for match on file pattern and include pattern
this.matchFile(onResult, { relativePath: extraFilePath /* no workspace relative path */, basename });
});
}
let traverse = this.nodeJSTraversal;
if (!this.maxFilesize) {
if (platform.isMacintosh) {
this.traversal = Traversal.MacFind;
traverse = this.findTraversal;
// Disable 'dir' for now (#11181, #11179, #11183, #11182).
} /* else if (platform.isWindows) {
this.traversal = Traversal.WindowsDir;
traverse = this.windowsDirTraversal;
} */ else if (platform.isLinux) {
this.traversal = Traversal.LinuxFind;
traverse = this.findTraversal;
}
}
const isNodeTraversal = traverse === this.nodeJSTraversal;
if (!isNodeTraversal) {
this.cmdForkStartTime = Date.now();
}
// For each root folder
flow.parallel<IFolderSearch, void>(folderQueries, (folderQuery: IFolderSearch, rootFolderDone: (err: Error, result: void) => void) => {
this.call(traverse, this, folderQuery, onResult, (err?: Error) => {
if (err) {
if (isNodeTraversal) {
rootFolderDone(err, undefined);
} else {
// fallback
const errorMessage = toErrorMessage(err);
console.error(errorMessage);
this.errors.push(errorMessage);
this.nodeJSTraversal(folderQuery, onResult, err => rootFolderDone(err, undefined));
}
} else {
rootFolderDone(undefined, undefined);
}
});
}, (err, result) => {
done(err ? err[0] : null, this.isLimitHit);
});
});
}
private call(fun: Function, that: any, ...args: any[]): void {
try {
fun.apply(that, args);
} catch (e) {
args[args.length - 1](e);
}
}
private findTraversal(folderQuery: IFolderSearch, onResult: (result: IRawFileMatch) => void, cb: (err?: Error) => void): void {
const rootFolder = folderQuery.folder;
const isMac = platform.isMacintosh;
let done = (err?: Error) => {
done = () => { };
cb(err);
};
let leftover = '';
let first = true;
const tree = this.initDirectoryTree();
const cmd = this.spawnFindCmd(folderQuery);
this.collectStdout(cmd, 'utf8', (err: Error, stdout?: string, last?: boolean) => {
if (err) {
done(err);
return;
}
// Mac: uses NFD unicode form on disk, but we want NFC
const normalized = leftover + (isMac ? strings.normalizeNFC(stdout) : stdout);
const relativeFiles = normalized.split('\n./');
if (first && normalized.length >= 2) {
first = false;
relativeFiles[0] = relativeFiles[0].trim().substr(2);
}
if (last) {
const n = relativeFiles.length;
relativeFiles[n - 1] = relativeFiles[n - 1].trim();
if (!relativeFiles[n - 1]) {
relativeFiles.pop();
}
} else {
leftover = relativeFiles.pop();
}
if (relativeFiles.length && relativeFiles[0].indexOf('\n') !== -1) {
done(new Error('Splitting up files failed'));
return;
}
this.addDirectoryEntries(tree, rootFolder, relativeFiles, onResult);
if (last) {
this.matchDirectoryTree(tree, rootFolder, onResult);
done();
}
});
}
// protected windowsDirTraversal(rootFolder: string, onResult: (result: IRawFileMatch) => void, done: (err?: Error) => void): void {
// const cmd = childProcess.spawn('cmd', ['/U', '/c', 'dir', '/s', '/b', '/a-d', rootFolder]);
// this.readStdout(cmd, 'ucs2', (err: Error, stdout?: string) => {
// if (err) {
// done(err);
// return;
// }
// const relativeFiles = stdout.split(`\r\n${rootFolder}\\`);
// relativeFiles[0] = relativeFiles[0].trim().substr(rootFolder.length + 1);
// const n = relativeFiles.length;
// relativeFiles[n - 1] = relativeFiles[n - 1].trim();
// if (!relativeFiles[n - 1]) {
// relativeFiles.pop();
// }
// if (relativeFiles.length && relativeFiles[0].indexOf('\n') !== -1) {
// done(new Error('Splitting up files failed'));
// return;
// }
// this.matchFiles(rootFolder, relativeFiles, onResult);
// done();
// });
// }
/**
* Public for testing.
*/
public spawnFindCmd(folderQuery: IFolderSearch) {
const excludePattern = this.folderExcludePatterns.get(folderQuery.folder);
const basenames = excludePattern.getBasenameTerms();
const pathTerms = excludePattern.getPathTerms();
let args = ['-L', '.'];
if (basenames.length || pathTerms.length) {
args.push('-not', '(', '(');
for (const basename of basenames) {
args.push('-name', basename);
args.push('-o');
}
for (const path of pathTerms) {
args.push('-path', path);
args.push('-o');
}
args.pop();
args.push(')', '-prune', ')');
}
args.push('-type', 'f');
return childProcess.spawn('find', args, { cwd: folderQuery.folder });
}
/**
* Public for testing.
*/
public readStdout(cmd: childProcess.ChildProcess, encoding: string, cb: (err: Error, stdout?: string) => void): void {
let all = '';
this.collectStdout(cmd, encoding, (err: Error, stdout?: string, last?: boolean) => {
if (err) {
cb(err);
return;
}
all += stdout;
if (last) {
cb(null, all);
}
});
}
private collectStdout(cmd: childProcess.ChildProcess, encoding: string, cb: (err: Error, stdout?: string, last?: boolean) => void): void {
let done = (err: Error, stdout?: string, last?: boolean) => {
if (err || last) {
done = () => { };
this.cmdForkResultTime = Date.now();
}
cb(err, stdout, last);
};
this.forwardData(cmd.stdout, encoding, done);
const stderr = this.collectData(cmd.stderr);
cmd.on('error', (err: Error) => {
done(err);
});
cmd.on('close', (code: number) => {
if (code !== 0) {
done(new Error(`find failed with error code ${code}: ${this.decodeData(stderr, encoding)}`));
} else {
done(null, '', true);
}
});
}
private forwardData(stream: Readable, encoding: string, cb: (err: Error, stdout?: string) => void): NodeStringDecoder {
const decoder = new StringDecoder(encoding);
stream.on('data', (data: Buffer) => {
cb(null, decoder.write(data));
});
return decoder;
}
private collectData(stream: Readable): Buffer[] {
const buffers: Buffer[] = [];
stream.on('data', (data: Buffer) => {
buffers.push(data);
});
return buffers;
}
private decodeData(buffers: Buffer[], encoding: string): string {
const decoder = new StringDecoder(encoding);
return buffers.map(buffer => decoder.write(buffer)).join('');
}
private initDirectoryTree(): IDirectoryTree {
const tree: IDirectoryTree = {
rootEntries: [],
pathToEntries: Object.create(null)
};
tree.pathToEntries['.'] = tree.rootEntries;
return tree;
}
private addDirectoryEntries({ pathToEntries }: IDirectoryTree, base: string, relativeFiles: string[], onResult: (result: IRawFileMatch) => void) {
this.cmdResultCount += relativeFiles.length;
// Support relative paths to files from a root resource (ignores excludes)
if (relativeFiles.indexOf(this.filePattern) !== -1) {
const basename = path.basename(this.filePattern);
this.matchFile(onResult, { base: base, relativePath: this.filePattern, basename });
}
function add(relativePath: string) {
const basename = path.basename(relativePath);
const dirname = path.dirname(relativePath);
let entries = pathToEntries[dirname];
if (!entries) {
entries = pathToEntries[dirname] = [];
add(dirname);
}
entries.push({
base,
relativePath,
basename
});
}
relativeFiles.forEach(add);
}
private matchDirectoryTree({ rootEntries, pathToEntries }: IDirectoryTree, rootFolder: string, onResult: (result: IRawFileMatch) => void) {
const self = this;
const excludePattern = this.folderExcludePatterns.get(rootFolder);
const filePattern = this.filePattern;
function matchDirectory(entries: IDirectoryEntry[]) {
self.directoriesWalked++;
for (let i = 0, n = entries.length; i < n; i++) {
const entry = entries[i];
const { relativePath, basename } = entry;
// Check exclude pattern
// 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.test(relativePath, basename, () => filePattern !== basename ? entries.map(entry => entry.basename) : [])) {
continue;
}
const sub = pathToEntries[relativePath];
if (sub) {
matchDirectory(sub);
} else {
self.filesWalked++;
if (relativePath === filePattern) {
continue; // ignore file if its path matches with the file pattern because that is already matched above
}
self.matchFile(onResult, entry);
}
};
}
matchDirectory(rootEntries);
}
private nodeJSTraversal(folderQuery: IFolderSearch, onResult: (result: IRawFileMatch) => void, done: (err?: Error) => void): void {
this.directoriesWalked++;
extfs.readdir(folderQuery.folder, (error: Error, files: string[]) => {
if (error || this.isCanceled || this.isLimitHit) {
return done();
}
// Support relative paths to files from a root resource (ignores excludes)
return this.checkFilePatternRelativeMatch(folderQuery.folder, (match, size) => {
if (this.isCanceled || this.isLimitHit) {
return done();
}
// Report result from file pattern if matching
if (match) {
this.resultCount++;
onResult({
base: folderQuery.folder,
relativePath: this.filePattern,
basename: path.basename(this.filePattern),
size
});
}
return this.doWalk(folderQuery, '', files, onResult, done);
});
});
}
public getStats(): IUncachedSearchStats {
return {
fromCache: false,
traversal: Traversal[this.traversal],
errors: this.errors,
fileWalkStartTime: this.fileWalkStartTime,
fileWalkResultTime: Date.now(),
directoriesWalked: this.directoriesWalked,
filesWalked: this.filesWalked,
resultCount: this.resultCount,
cmdForkStartTime: this.cmdForkStartTime,
cmdForkResultTime: this.cmdForkResultTime,
cmdResultCount: this.cmdResultCount
};
}
private checkFilePatternAbsoluteMatch(clb: (exists: boolean, size?: number) => void): void {
if (!this.filePattern || !path.isAbsolute(this.filePattern)) {
return clb(false);
}
return fs.stat(this.filePattern, (error, stat) => {
return clb(!error && !stat.isDirectory(), stat && stat.size); // only existing files
});
}
private checkFilePatternRelativeMatch(basePath: string, clb: (matchPath: string, size?: number) => void): void {
if (!this.filePattern || path.isAbsolute(this.filePattern)) {
return clb(null);
}
const absolutePath = path.join(basePath, this.filePattern);
return fs.stat(absolutePath, (error, stat) => {
return clb(!error && !stat.isDirectory() ? absolutePath : null, stat && stat.size); // only existing files
});
}
private doWalk(folderQuery: IFolderSearch, relativeParentPath: string, files: string[], onResult: (result: IRawFileMatch) => void, done: (error: Error) => void): void {
const rootFolder = folderQuery.folder;
// Execute tasks on each file in parallel to optimize throughput
flow.parallel(files, (file: string, clb: (error: Error, result: {}) => void): void => {
// Check canceled
if (this.isCanceled || this.isLimitHit) {
return clb(null, undefined);
}
// 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
let siblings = files;
if (this.config.filePattern === file) {
siblings = [];
}
// Check exclude pattern
let currentRelativePath = relativeParentPath ? [relativeParentPath, file].join(path.sep) : file;
if (this.folderExcludePatterns.get(folderQuery.folder).test(currentRelativePath, file, () => siblings)) {
return clb(null, undefined);
}
// Use lstat to detect links
let currentAbsolutePath = [rootFolder, currentRelativePath].join(path.sep);
fs.lstat(currentAbsolutePath, (error, lstat) => {
if (error || this.isCanceled || this.isLimitHit) {
return clb(null, undefined);
}
// If the path is a link, we must instead use fs.stat() to find out if the
// link is a directory or not because lstat will always return the stat of
// the link which is always a file.
this.statLinkIfNeeded(currentAbsolutePath, lstat, (error, stat) => {
if (error || this.isCanceled || this.isLimitHit) {
return clb(null, undefined);
}
// Directory: Follow directories
if (stat.isDirectory()) {
this.directoriesWalked++;
// to really prevent loops with links we need to resolve the real path of them
return this.realPathIfNeeded(currentAbsolutePath, lstat, (error, realpath) => {
if (error || this.isCanceled || this.isLimitHit) {
return clb(null, undefined);
}
if (this.walkedPaths[realpath]) {
return clb(null, undefined); // escape when there are cycles (can happen with symlinks)
}
this.walkedPaths[realpath] = true; // remember as walked
// Continue walking
return extfs.readdir(currentAbsolutePath, (error: Error, children: string[]): void => {
if (error || this.isCanceled || this.isLimitHit) {
return clb(null, undefined);
}
this.doWalk(folderQuery, currentRelativePath, children, onResult, err => clb(err, undefined));
});
});
}
// File: Check for match on file pattern and include pattern
else {
this.filesWalked++;
if (currentRelativePath === this.filePattern) {
return clb(null, undefined); // ignore file if its path matches with the file pattern because checkFilePatternRelativeMatch() takes care of those
}
if (this.maxFilesize && types.isNumber(stat.size) && stat.size > this.maxFilesize) {
return clb(null, undefined); // ignore file if max file size is hit
}
this.matchFile(onResult, { base: rootFolder, relativePath: currentRelativePath, basename: file, size: stat.size });
}
// Unwind
return clb(null, undefined);
});
});
}, (error: Error[]): void => {
if (error) {
error = arrays.coalesce(error); // find any error by removing null values first
}
return done(error && error.length > 0 ? error[0] : null);
});
}
private matchFile(onResult: (result: IRawFileMatch) => void, candidate: IRawFileMatch): void {
if (this.isFilePatternMatch(candidate.relativePath) && (!this.includePattern || this.includePattern(candidate.relativePath, candidate.basename))) {
this.resultCount++;
if (this.maxResults && this.resultCount > this.maxResults) {
this.isLimitHit = true;
}
if (!this.isLimitHit) {
onResult(candidate);
}
}
}
private isFilePatternMatch(path: string): boolean {
// Check for search pattern
if (this.filePattern) {
if (this.filePattern === '*') {
return true; // support the all-matching wildcard
}
return scorer.matches(path, this.normalizedFilePatternLowercase);
}
// No patterns means we match all
return true;
}
private statLinkIfNeeded(path: string, lstat: fs.Stats, clb: (error: Error, stat: fs.Stats) => void): void {
if (lstat.isSymbolicLink()) {
return fs.stat(path, clb); // stat the target the link points to
}
return clb(null, lstat); // not a link, so the stat is already ok for us
}
private realPathIfNeeded(path: string, lstat: fs.Stats, clb: (error: Error, realpath?: string) => void): void {
if (lstat.isSymbolicLink()) {
return fs.realpath(path, (error, realpath) => {
if (error) {
return clb(error);
}
return clb(null, realpath);
});
}
return clb(null, path);
}
}
export class Engine implements ISearchEngine<IRawFileMatch> {
private folderQueries: IFolderSearch[];
private extraFiles: string[];
private walker: FileWalker;
constructor(config: IRawSearch) {
this.folderQueries = config.folderQueries;
this.extraFiles = config.extraFiles;
this.walker = new FileWalker(config);
}
public search(onResult: (result: IRawFileMatch) => void, onProgress: (progress: IProgress) => void, done: (error: Error, complete: ISerializedSearchComplete) => void): void {
this.walker.walk(this.folderQueries, this.extraFiles, onResult, (err: Error, isLimitHit: boolean) => {
done(err, {
limitHit: isLimitHit,
stats: this.walker.getStats()
});
});
}
public cancel(): void {
this.walker.cancel();
}
}
/**
* This class exists to provide one interface on top of two ParsedExpressions, one for absolute expressions and one for relative expressions.
* The absolute and relative expressions don't "have" to be kept separate, but this keeps us from having to path.join every single
* file searched, it's only used for a text search with a searchPath
*/
class AbsoluteAndRelativeParsedExpression {
private absoluteParsedExpr: glob.ParsedExpression;
private relativeParsedExpr: glob.ParsedExpression;
constructor(expr: glob.IExpression, private root: string) {
this.init(expr);
}
/**
* Split the IExpression into its absolute and relative components, and glob.parse them separately.
*/
private init(expr: glob.IExpression): void {
let absoluteGlobExpr: glob.IExpression;
let relativeGlobExpr: glob.IExpression;
Object.keys(expr)
.filter(key => expr[key])
.forEach(key => {
if (path.isAbsolute(key)) {
absoluteGlobExpr = absoluteGlobExpr || glob.getEmptyExpression();
absoluteGlobExpr[key] = expr[key];
} else {
relativeGlobExpr = relativeGlobExpr || glob.getEmptyExpression();
relativeGlobExpr[key] = expr[key];
}
});
this.absoluteParsedExpr = absoluteGlobExpr && glob.parse(absoluteGlobExpr, { trimForExclusions: true });
this.relativeParsedExpr = relativeGlobExpr && glob.parse(relativeGlobExpr, { trimForExclusions: true });
}
public test(_path: string, basename?: string, siblingsFn?: () => string[] | TPromise<string[]>): string | TPromise<string> {
return (this.relativeParsedExpr && this.relativeParsedExpr(_path, basename, siblingsFn)) ||
(this.absoluteParsedExpr && this.absoluteParsedExpr(path.join(this.root, _path), basename, siblingsFn));
}
public getBasenameTerms(): string[] {
const basenameTerms = [];
if (this.absoluteParsedExpr) {
basenameTerms.push(...glob.getBasenameTerms(this.absoluteParsedExpr));
}
if (this.relativeParsedExpr) {
basenameTerms.push(...glob.getBasenameTerms(this.relativeParsedExpr));
}
return basenameTerms;
}
public getPathTerms(): string[] {
const pathTerms = [];
if (this.absoluteParsedExpr) {
pathTerms.push(...glob.getPathTerms(this.absoluteParsedExpr));
}
if (this.relativeParsedExpr) {
pathTerms.push(...glob.getPathTerms(this.relativeParsedExpr));
}
return pathTerms;
}
}

View File

@@ -0,0 +1,496 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import fs = require('fs');
import { isAbsolute, sep } from 'path';
import gracefulFs = require('graceful-fs');
gracefulFs.gracefulify(fs);
import arrays = require('vs/base/common/arrays');
import { compareByScore } from 'vs/base/common/comparers';
import objects = require('vs/base/common/objects');
import scorer = require('vs/base/common/scorer');
import strings = require('vs/base/common/strings');
import { PPromise, TPromise } from 'vs/base/common/winjs.base';
import { FileWalker, Engine as FileSearchEngine } from 'vs/workbench/services/search/node/fileSearch';
import { MAX_FILE_SIZE } from 'vs/platform/files/common/files';
import { RipgrepEngine } from 'vs/workbench/services/search/node/ripgrepTextSearch';
import { Engine as TextSearchEngine } from 'vs/workbench/services/search/node/textSearch';
import { TextSearchWorkerProvider } from 'vs/workbench/services/search/node/textSearchWorkerProvider';
import { IRawSearchService, IRawSearch, IRawFileMatch, ISerializedFileMatch, ISerializedSearchProgressItem, ISerializedSearchComplete, ISearchEngine, IFileSearchProgressItem } from './search';
import { ICachedSearchStats, IProgress } from 'vs/platform/search/common/search';
export class SearchService implements IRawSearchService {
private static BATCH_SIZE = 512;
private caches: { [cacheKey: string]: Cache; } = Object.create(null);
private textSearchWorkerProvider: TextSearchWorkerProvider;
public fileSearch(config: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
return this.doFileSearch(FileSearchEngine, config, SearchService.BATCH_SIZE);
}
public textSearch(config: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
return config.useRipgrep ?
this.ripgrepTextSearch(config) :
this.legacyTextSearch(config);
}
public ripgrepTextSearch(config: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
config.maxFilesize = MAX_FILE_SIZE;
let engine = new RipgrepEngine(config);
return new PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>((c, e, p) => {
// 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, p);
engine.search((match) => {
collector.addItem(match, match.numMatches);
}, (message) => {
p(message);
}, (error, stats) => {
collector.flush();
if (error) {
e(error);
} else {
c(stats);
}
});
}, () => {
engine.cancel();
});
}
public legacyTextSearch(config: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
if (!this.textSearchWorkerProvider) {
this.textSearchWorkerProvider = new TextSearchWorkerProvider();
}
let engine = new TextSearchEngine(
config,
new FileWalker({
folderQueries: config.folderQueries,
extraFiles: config.extraFiles,
includePattern: config.includePattern,
excludePattern: config.excludePattern,
filePattern: config.filePattern,
maxFilesize: MAX_FILE_SIZE
}),
this.textSearchWorkerProvider);
return this.doTextSearch(engine, SearchService.BATCH_SIZE);
}
public doFileSearch(EngineClass: { new(config: IRawSearch): ISearchEngine<IRawFileMatch>; }, config: IRawSearch, batchSize?: number): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
if (config.sortByScore) {
let sortedSearch = this.trySortedSearchFromCache(config);
if (!sortedSearch) {
const walkerConfig = config.maxResults ? objects.assign({}, config, { maxResults: null }) : config;
const engine = new EngineClass(walkerConfig);
sortedSearch = this.doSortedSearch(engine, config);
}
return new PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>((c, e, p) => {
process.nextTick(() => { // allow caller to register progress callback first
sortedSearch.then(([result, rawMatches]) => {
const serializedMatches = rawMatches.map(rawMatch => this.rawMatchToSearchItem(rawMatch));
this.sendProgress(serializedMatches, p, batchSize);
c(result);
}, e, p);
});
}, () => {
sortedSearch.cancel();
});
}
let searchPromise: PPromise<void, IFileSearchProgressItem>;
return new PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>((c, e, p) => {
const engine = new EngineClass(config);
searchPromise = this.doSearch(engine, batchSize)
.then(c, e, progress => {
if (Array.isArray(progress)) {
p(progress.map(m => this.rawMatchToSearchItem(m)));
} else if ((<IRawFileMatch>progress).relativePath) {
p(this.rawMatchToSearchItem(<IRawFileMatch>progress));
} else {
p(<IProgress>progress);
}
});
}, () => {
searchPromise.cancel();
});
}
private rawMatchToSearchItem(match: IRawFileMatch): ISerializedFileMatch {
return { path: match.base ? [match.base, match.relativePath].join(sep) : match.relativePath };
}
private doSortedSearch(engine: ISearchEngine<IRawFileMatch>, config: IRawSearch): PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IProgress> {
let searchPromise: PPromise<void, IFileSearchProgressItem>;
let allResultsPromise = new PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IFileSearchProgressItem>((c, e, p) => {
let results: IRawFileMatch[] = [];
searchPromise = this.doSearch(engine, -1)
.then(result => {
c([result, results]);
}, e, progress => {
if (Array.isArray(progress)) {
results = progress;
} else {
p(progress);
}
});
}, () => {
searchPromise.cancel();
});
let cache: Cache;
if (config.cacheKey) {
cache = this.getOrCreateCache(config.cacheKey);
cache.resultsToSearchCache[config.filePattern] = allResultsPromise;
allResultsPromise.then(null, err => {
delete cache.resultsToSearchCache[config.filePattern];
});
allResultsPromise = this.preventCancellation(allResultsPromise);
}
return new PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IProgress>((c, e, p) => {
allResultsPromise.then(([result, results]) => {
const scorerCache: ScorerCache = cache ? cache.scorerCache : Object.create(null);
const unsortedResultTime = Date.now();
const sortedResults = this.sortResults(config, results, scorerCache);
const sortedResultTime = Date.now();
c([{
stats: objects.assign({}, result.stats, {
unsortedResultTime,
sortedResultTime
}),
limitHit: result.limitHit || typeof config.maxResults === 'number' && results.length > config.maxResults
}, sortedResults]);
}, e, p);
}, () => {
allResultsPromise.cancel();
});
}
private getOrCreateCache(cacheKey: string): Cache {
const existing = this.caches[cacheKey];
if (existing) {
return existing;
}
return this.caches[cacheKey] = new Cache();
}
private trySortedSearchFromCache(config: IRawSearch): PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IProgress> {
const cache = config.cacheKey && this.caches[config.cacheKey];
if (!cache) {
return undefined;
}
const cacheLookupStartTime = Date.now();
const cached = this.getResultsFromCache(cache, config.filePattern);
if (cached) {
return new PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IProgress>((c, e, p) => {
cached.then(([result, results, cacheStats]) => {
const cacheLookupResultTime = Date.now();
const sortedResults = this.sortResults(config, results, cache.scorerCache);
const sortedResultTime = Date.now();
const stats: ICachedSearchStats = {
fromCache: true,
cacheLookupStartTime: cacheLookupStartTime,
cacheFilterStartTime: cacheStats.cacheFilterStartTime,
cacheLookupResultTime: cacheLookupResultTime,
cacheEntryCount: cacheStats.cacheFilterResultCount,
resultCount: results.length
};
if (config.sortByScore) {
stats.unsortedResultTime = cacheLookupResultTime;
stats.sortedResultTime = sortedResultTime;
}
if (!cacheStats.cacheWasResolved) {
stats.joined = result.stats;
}
c([
{
limitHit: result.limitHit || typeof config.maxResults === 'number' && results.length > config.maxResults,
stats: stats
},
sortedResults
]);
}, e, p);
}, () => {
cached.cancel();
});
}
return undefined;
}
private sortResults(config: IRawSearch, results: IRawFileMatch[], scorerCache: ScorerCache): IRawFileMatch[] {
const filePattern = config.filePattern;
const normalizedSearchValue = strings.stripWildcards(filePattern).toLowerCase();
const compare = (elementA: IRawFileMatch, elementB: IRawFileMatch) => compareByScore(elementA, elementB, FileMatchAccessor, filePattern, normalizedSearchValue, scorerCache);
return arrays.top(results, compare, config.maxResults);
}
private sendProgress(results: ISerializedFileMatch[], progressCb: (batch: ISerializedFileMatch[]) => void, batchSize: number) {
if (batchSize && batchSize > 0) {
for (let i = 0; i < results.length; i += batchSize) {
progressCb(results.slice(i, i + batchSize));
}
} else {
progressCb(results);
}
}
private getResultsFromCache(cache: Cache, searchValue: string): PPromise<[ISerializedSearchComplete, IRawFileMatch[], CacheStats], IProgress> {
if (isAbsolute(searchValue)) {
return null; // bypass cache if user looks up an absolute path where matching goes directly on disk
}
// Find cache entries by prefix of search value
const hasPathSep = searchValue.indexOf(sep) >= 0;
let cached: PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IFileSearchProgressItem>;
let wasResolved: boolean;
for (let previousSearch in cache.resultsToSearchCache) {
// If we narrow down, we might be able to reuse the cached results
if (strings.startsWith(searchValue, previousSearch)) {
if (hasPathSep && previousSearch.indexOf(sep) < 0) {
continue; // since a path character widens the search for potential more matches, require it in previous search too
}
const c = cache.resultsToSearchCache[previousSearch];
c.then(() => { wasResolved = false; });
wasResolved = true;
cached = this.preventCancellation(c);
break;
}
}
if (!cached) {
return null;
}
return new PPromise<[ISerializedSearchComplete, IRawFileMatch[], CacheStats], IProgress>((c, e, p) => {
cached.then(([complete, cachedEntries]) => {
const cacheFilterStartTime = Date.now();
// Pattern match on results
let results: IRawFileMatch[] = [];
const normalizedSearchValueLowercase = strings.stripWildcards(searchValue).toLowerCase();
for (let i = 0; i < cachedEntries.length; i++) {
let entry = cachedEntries[i];
// Check if this entry is a match for the search value
if (!scorer.matches(entry.relativePath, normalizedSearchValueLowercase)) {
continue;
}
results.push(entry);
}
c([complete, results, {
cacheWasResolved: wasResolved,
cacheFilterStartTime: cacheFilterStartTime,
cacheFilterResultCount: cachedEntries.length
}]);
}, e, p);
}, () => {
cached.cancel();
});
}
private doTextSearch(engine: TextSearchEngine, batchSize: number): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
return new PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>((c, e, p) => {
// Use BatchedCollector to get new results to the frontend every 2s at least, until 50 results have been returned
const collector = new BatchedCollector<ISerializedFileMatch>(batchSize, p);
engine.search((matches) => {
const totalMatches = matches.reduce((acc, m) => acc + m.numMatches, 0);
collector.addItems(matches, totalMatches);
}, (progress) => {
p(progress);
}, (error, stats) => {
collector.flush();
if (error) {
e(error);
} else {
c(stats);
}
});
}, () => {
engine.cancel();
});
}
private doSearch(engine: ISearchEngine<IRawFileMatch>, batchSize?: number): PPromise<ISerializedSearchComplete, IFileSearchProgressItem> {
return new PPromise<ISerializedSearchComplete, IFileSearchProgressItem>((c, e, p) => {
let batch: IRawFileMatch[] = [];
engine.search((match) => {
if (match) {
if (batchSize) {
batch.push(match);
if (batchSize > 0 && batch.length >= batchSize) {
p(batch);
batch = [];
}
} else {
p(match);
}
}
}, (progress) => {
p(progress);
}, (error, stats) => {
if (batch.length) {
p(batch);
}
if (error) {
e(error);
} else {
c(stats);
}
});
}, () => {
engine.cancel();
});
}
public clearCache(cacheKey: string): TPromise<void> {
delete this.caches[cacheKey];
return TPromise.as(undefined);
}
private preventCancellation<C, P>(promise: PPromise<C, P>): PPromise<C, P> {
return new PPromise<C, P>((c, e, p) => {
// Allow for piled up cancellations to come through first.
process.nextTick(() => {
promise.then(c, e, p);
});
}, () => {
// Do not propagate.
});
}
}
class Cache {
public resultsToSearchCache: { [searchValue: string]: PPromise<[ISerializedSearchComplete, IRawFileMatch[]], IFileSearchProgressItem>; } = Object.create(null);
public scorerCache: ScorerCache = Object.create(null);
}
interface ScorerCache {
[key: string]: number;
}
class FileMatchAccessor {
public static getLabel(match: IRawFileMatch): string {
return match.basename;
}
public static getResourcePath(match: IRawFileMatch): string {
return match.relativePath;
}
}
interface CacheStats {
cacheWasResolved: boolean;
cacheFilterStartTime: number;
cacheFilterResultCount: number;
}
/**
* 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 TIMEOUT = 4000;
// After RUN_TIMEOUT_UNTIL_COUNT items have been collected, stop flushing on timeout
private static START_BATCH_AFTER_COUNT = 50;
private totalNumberCompleted = 0;
private batch: T[] = [];
private batchSize = 0;
private timeoutHandle: number;
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;
}
}
}
}

View File

@@ -0,0 +1,553 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { EventEmitter } from 'events';
import * as path from 'path';
import { StringDecoder, NodeStringDecoder } from 'string_decoder';
import * as cp from 'child_process';
import { rgPath } from 'vscode-ripgrep';
import objects = require('vs/base/common/objects');
import platform = require('vs/base/common/platform');
import * as strings from 'vs/base/common/strings';
import * as paths from 'vs/base/common/paths';
import * as extfs from 'vs/base/node/extfs';
import * as encoding from 'vs/base/node/encoding';
import * as glob from 'vs/base/common/glob';
import { ILineMatch, ISearchLog } from 'vs/platform/search/common/search';
import { TPromise } from 'vs/base/common/winjs.base';
import { ISerializedFileMatch, ISerializedSearchComplete, IRawSearch, IFolderSearch } from './search';
export class RipgrepEngine {
private isDone = false;
private rgProc: cp.ChildProcess;
private postProcessExclusions: glob.ParsedExpression;
private ripgrepParser: RipgrepParser;
private resultsHandledP: TPromise<any> = TPromise.wrap(null);
constructor(private config: IRawSearch) {
}
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: ISearchLog) => void, done: (error: Error, complete: ISerializedSearchComplete) => void): void {
if (!this.config.folderQueries.length && !this.config.extraFiles.length) {
done(null, {
limitHit: false,
stats: null
});
return;
}
const rgArgs = getRgArgs(this.config);
if (rgArgs.siblingClauses) {
this.postProcessExclusions = glob.parseToAsync(rgArgs.siblingClauses, { trimForExclusions: true });
}
const cwd = platform.isWindows ? 'c:/' : '/';
process.nextTick(() => { // Allow caller to register progress callback
const escapedArgs = rgArgs.globArgs
.map(arg => arg.match(/^-/) ? arg : `'${arg}'`)
.join(' ');
const rgCmd = `rg ${escapedArgs}\n - cwd: ${cwd}\n`;
onMessage({ message: rgCmd });
if (rgArgs.siblingClauses) {
onMessage({ message: ` - Sibling clauses: ${JSON.stringify(rgArgs.siblingClauses)}\n` });
}
});
this.rgProc = cp.spawn(rgPath, rgArgs.globArgs, { cwd });
this.ripgrepParser = new RipgrepParser(this.config.maxResults, cwd);
this.ripgrepParser.on('result', (match: ISerializedFileMatch) => {
if (this.postProcessExclusions) {
const handleResultP = (<TPromise<string>>this.postProcessExclusions(match.path, undefined, () => 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();
done(null, {
limitHit: true,
stats: null
});
});
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;
if (stderr && !gotData && (displayMsg = this.rgErrorMsgForDisplay(stderr))) {
done(new Error(displayMsg), {
limitHit: false,
stats: null
});
} else {
done(null, {
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.
*/
private rgErrorMsgForDisplay(msg: string): string | undefined {
const firstLine = msg.split('\n')[0];
if (strings.startsWith(firstLine, 'Error parsing regex')) {
return firstLine;
}
if (strings.startsWith(firstLine, 'error parsing glob')) {
return firstLine;
}
return undefined;
}
}
export class RipgrepParser extends EventEmitter {
private static RESULT_REGEX = /^\u001b\[m(\d+)\u001b\[m:(.*)(\r?)/;
private static FILE_REGEX = /^\u001b\[m(.+)\u001b\[m$/;
public static MATCH_START_MARKER = '\u001b[m\u001b[31m';
public static MATCH_END_MARKER = '\u001b[m';
private fileMatch: FileMatch;
private remainder: string;
private isDone: boolean;
private stringDecoder: NodeStringDecoder;
private numResults = 0;
constructor(private maxResults: number, private rootFolder: string) {
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 {
const dataStr = typeof data === 'string' ? data : this.stringDecoder.write(data);
this.handleDecodedData(dataStr);
}
private handleDecodedData(decodedData: string): void {
// If the previous data chunk didn't end in a newline, prepend it to this chunk
const dataStr = this.remainder ?
this.remainder + decodedData :
decodedData;
const dataLines: string[] = dataStr.split(/\r\n|\n/);
this.remainder = dataLines[dataLines.length - 1] ? dataLines.pop() : null;
for (let l = 0; l < dataLines.length; l++) {
const outputLine = dataLines[l].trim();
if (this.isDone) {
break;
}
let r: RegExpMatchArray;
if (r = outputLine.match(RipgrepParser.RESULT_REGEX)) {
const lineNum = parseInt(r[1]) - 1;
let matchText = r[2];
// workaround https://github.com/BurntSushi/ripgrep/issues/416
// If the match line ended with \r, append a match end marker so the match isn't lost
if (r[3]) {
matchText += RipgrepParser.MATCH_END_MARKER;
}
// Line is a result - add to collected results for the current file path
this.handleMatchLine(outputLine, lineNum, matchText);
} else if (r = outputLine.match(RipgrepParser.FILE_REGEX)) {
// Line is a file path - send all collected results for the previous file path
if (this.fileMatch) {
this.onResult();
}
this.fileMatch = new FileMatch(path.isAbsolute(r[1]) ? r[1] : path.join(this.rootFolder, r[1]));
} else {
// Line is empty (or malformed)
}
}
}
private handleMatchLine(outputLine: string, lineNum: number, text: string): void {
const lineMatch = new LineMatch(text, lineNum);
this.fileMatch.addMatch(lineMatch);
let lastMatchEndPos = 0;
let matchTextStartPos = -1;
// Track positions with color codes subtracted - offsets in the final text preview result
let matchTextStartRealIdx = -1;
let textRealIdx = 0;
let hitLimit = false;
const realTextParts: string[] = [];
for (let i = 0; i < text.length - (RipgrepParser.MATCH_END_MARKER.length - 1);) {
if (text.substr(i, RipgrepParser.MATCH_START_MARKER.length) === RipgrepParser.MATCH_START_MARKER) {
// Match start
const chunk = text.slice(lastMatchEndPos, i);
realTextParts.push(chunk);
i += RipgrepParser.MATCH_START_MARKER.length;
matchTextStartPos = i;
matchTextStartRealIdx = textRealIdx;
} else if (text.substr(i, RipgrepParser.MATCH_END_MARKER.length) === RipgrepParser.MATCH_END_MARKER) {
// Match end
const chunk = text.slice(matchTextStartPos, i);
realTextParts.push(chunk);
if (!hitLimit) {
lineMatch.addMatch(matchTextStartRealIdx, textRealIdx - matchTextStartRealIdx);
}
matchTextStartPos = -1;
matchTextStartRealIdx = -1;
i += RipgrepParser.MATCH_END_MARKER.length;
lastMatchEndPos = i;
this.numResults++;
// Check hit maxResults limit
if (this.numResults >= this.maxResults) {
// Finish the line, then report the result below
hitLimit = true;
}
} else {
i++;
textRealIdx++;
}
}
const chunk = text.slice(lastMatchEndPos);
realTextParts.push(chunk);
// Replace preview with version without color codes
const preview = realTextParts.join('');
lineMatch.preview = preview;
if (hitLimit) {
this.cancel();
this.onResult();
this.emit('hitLimit');
}
}
private onResult(): void {
this.emit('result', this.fileMatch.serialize());
this.fileMatch = null;
}
}
export class FileMatch implements ISerializedFileMatch {
path: string;
lineMatches: LineMatch[];
constructor(path: string) {
this.path = path;
this.lineMatches = [];
}
addMatch(lineMatch: LineMatch): void {
this.lineMatches.push(lineMatch);
}
isEmpty(): boolean {
return this.lineMatches.length === 0;
}
serialize(): ISerializedFileMatch {
let lineMatches: ILineMatch[] = [];
let numMatches = 0;
for (let i = 0; i < this.lineMatches.length; i++) {
numMatches += this.lineMatches[i].offsetAndLengths.length;
lineMatches.push(this.lineMatches[i].serialize());
}
return {
path: this.path,
lineMatches,
numMatches
};
}
}
export class LineMatch implements ILineMatch {
preview: string;
lineNumber: number;
offsetAndLengths: number[][];
constructor(preview: string, lineNumber: number) {
this.preview = preview.replace(/(\r|\n)*$/, '');
this.lineNumber = lineNumber;
this.offsetAndLengths = [];
}
getText(): string {
return this.preview;
}
getLineNumber(): number {
return this.lineNumber;
}
addMatch(offset: number, length: number): void {
this.offsetAndLengths.push([offset, length]);
}
serialize(): ILineMatch {
const result = {
preview: this.preview,
lineNumber: this.lineNumber,
offsetAndLengths: this.offsetAndLengths
};
return result;
}
}
interface IRgGlobResult {
globArgs: string[];
siblingClauses: glob.IExpression;
}
function foldersToRgExcludeGlobs(folderQueries: IFolderSearch[], globalExclude: glob.IExpression, excludesToSkip: Set<string>): IRgGlobResult {
const globArgs: string[] = [];
let siblingClauses: glob.IExpression = {};
folderQueries.forEach(folderQuery => {
const totalExcludePattern = objects.assign({}, folderQuery.excludePattern || {}, globalExclude || {});
const result = globExprsToRgGlobs(totalExcludePattern, folderQuery.folder, excludesToSkip);
globArgs.push(...result.globArgs);
if (result.siblingClauses) {
siblingClauses = objects.assign(siblingClauses, result.siblingClauses);
}
});
return { globArgs, siblingClauses };
}
function foldersToIncludeGlobs(folderQueries: IFolderSearch[], globalInclude: glob.IExpression): string[] {
const globArgs = [];
folderQueries.forEach(folderQuery => {
const totalIncludePattern = objects.assign({}, globalInclude || {}, folderQuery.includePattern || {});
const result = globExprsToRgGlobs(totalIncludePattern, 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;
}
const value = patterns[key];
key = getAbsoluteGlob(folder, key);
if (typeof value === 'boolean' && value) {
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 {
const absolute = paths.isAbsolute(key) ?
key :
path.join(folder, key);
return trimTrailingSlash(absolute);
}
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): IRgGlobResult {
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');
}
// Follow symlinks
args.push('--follow');
// Set default encoding if only one folder is opened
if (config.folderQueries.length === 1 && config.folderQueries[0].fileEncoding && config.folderQueries[0].fileEncoding !== 'utf8') {
args.push('--encoding', encoding.toCanonicalName(config.folderQueries[0].fileEncoding));
}
// 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', config.contentPattern.pattern);
} else {
searchPatternAfterDoubleDashes = config.contentPattern.pattern;
args.push('--fixed-strings');
}
// 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 { globArgs: 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;
}

View File

@@ -0,0 +1,65 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { PPromise, TPromise } from 'vs/base/common/winjs.base';
import { IExpression } from 'vs/base/common/glob';
import { IProgress, ILineMatch, IPatternInfo, ISearchStats, ISearchLog } from 'vs/platform/search/common/search';
export interface IFolderSearch {
folder: string;
excludePattern?: IExpression;
includePattern?: IExpression;
fileEncoding?: string;
}
export interface IRawSearch {
folderQueries: IFolderSearch[];
extraFiles?: string[];
filePattern?: string;
excludePattern?: IExpression;
includePattern?: IExpression;
contentPattern?: IPatternInfo;
maxResults?: number;
sortByScore?: boolean;
cacheKey?: string;
maxFilesize?: number;
useRipgrep?: boolean;
disregardIgnoreFiles?: boolean;
}
export interface IRawSearchService {
fileSearch(search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>;
textSearch(search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>;
clearCache(cacheKey: string): TPromise<void>;
}
export interface IRawFileMatch {
base?: string;
relativePath: string;
basename: string;
size?: number;
}
export interface ISearchEngine<T> {
search: (onResult: (matches: T) => void, onProgress: (progress: IProgress) => void, done: (error: Error, complete: ISerializedSearchComplete) => void) => void;
cancel: () => void;
}
export interface ISerializedSearchComplete {
limitHit: boolean;
stats: ISearchStats;
}
export interface ISerializedFileMatch {
path: string;
lineMatches?: ILineMatch[];
numMatches?: number;
}
// Type of the possible values for progress calls from the engine
export type ISerializedSearchProgressItem = ISerializedFileMatch | ISerializedFileMatch[] | IProgress | ISearchLog;
export type IFileSearchProgressItem = IRawFileMatch | IRawFileMatch[] | IProgress;

View File

@@ -0,0 +1,15 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { Server } from 'vs/base/parts/ipc/node/ipc.cp';
import { SearchChannel } from './searchIpc';
import { SearchService } from './rawSearchService';
const server = new Server();
const service = new SearchService();
const channel = new SearchChannel(service);
server.registerChannel('search', channel);

View File

@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { PPromise, TPromise } from 'vs/base/common/winjs.base';
import { IChannel } from 'vs/base/parts/ipc/common/ipc';
import { IRawSearchService, IRawSearch, ISerializedSearchComplete, ISerializedSearchProgressItem } from './search';
export interface ISearchChannel extends IChannel {
call(command: 'fileSearch', search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>;
call(command: 'textSearch', search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>;
call(command: 'clearCache', cacheKey: string): TPromise<void>;
call(command: string, arg: any): TPromise<any>;
}
export class SearchChannel implements ISearchChannel {
constructor(private service: IRawSearchService) { }
call(command: string, arg: any): TPromise<any> {
switch (command) {
case 'fileSearch': return this.service.fileSearch(arg);
case 'textSearch': return this.service.textSearch(arg);
case 'clearCache': return this.service.clearCache(arg);
}
return undefined;
}
}
export class SearchChannelClient implements IRawSearchService {
constructor(private channel: ISearchChannel) { }
fileSearch(search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
return this.channel.call('fileSearch', search);
}
textSearch(search: IRawSearch): PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem> {
return this.channel.call('textSearch', search);
}
public clearCache(cacheKey: string): TPromise<void> {
return this.channel.call('clearCache', cacheKey);
}
}

View File

@@ -0,0 +1,321 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { PPromise, TPromise } from 'vs/base/common/winjs.base';
import uri from 'vs/base/common/uri';
import objects = require('vs/base/common/objects');
import scorer = require('vs/base/common/scorer');
import strings = require('vs/base/common/strings');
import { getNextTickChannel } from 'vs/base/parts/ipc/common/ipc';
import { Client } from 'vs/base/parts/ipc/node/ipc.cp';
import { IProgress, LineMatch, FileMatch, ISearchComplete, ISearchProgressItem, QueryType, IFileMatch, ISearchQuery, ISearchConfiguration, ISearchService, pathIncludedInQuery, ISearchResultProvider } from 'vs/platform/search/common/search';
import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService';
import { IModelService } from 'vs/editor/common/services/modelService';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IRawSearch, IFolderSearch, ISerializedSearchComplete, ISerializedSearchProgressItem, ISerializedFileMatch, IRawSearchService } from './search';
import { ISearchChannel, SearchChannelClient } from './searchIpc';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { ResourceMap } from 'vs/base/common/map';
import { IDisposable } from 'vs/base/common/lifecycle';
export class SearchService implements ISearchService {
public _serviceBrand: any;
private diskSearch: DiskSearch;
private readonly searchProvider: ISearchResultProvider[] = [];
constructor(
@IModelService private modelService: IModelService,
@IUntitledEditorService private untitledEditorService: IUntitledEditorService,
@IEnvironmentService environmentService: IEnvironmentService,
@IWorkspaceContextService private contextService: IWorkspaceContextService,
@IConfigurationService private configurationService: IConfigurationService
) {
this.diskSearch = new DiskSearch(!environmentService.isBuilt || environmentService.verbose);
this.registerSearchResultProvider(this.diskSearch);
}
public registerSearchResultProvider(provider: ISearchResultProvider): IDisposable {
this.searchProvider.push(provider);
return {
dispose: () => {
const idx = this.searchProvider.indexOf(provider);
if (idx >= 0) {
this.searchProvider.splice(idx, 1);
}
}
};
}
public extendQuery(query: ISearchQuery): void {
const configuration = this.configurationService.getConfiguration<ISearchConfiguration>();
// Configuration: Encoding
if (!query.fileEncoding) {
const fileEncoding = configuration && configuration.files && configuration.files.encoding;
query.fileEncoding = fileEncoding;
}
// Configuration: File Excludes
if (!query.disregardExcludeSettings) {
const fileExcludes = configuration && configuration.files && configuration.files.exclude;
if (fileExcludes) {
if (!query.excludePattern) {
query.excludePattern = fileExcludes;
} else {
objects.mixin(query.excludePattern, fileExcludes, false /* no overwrite */);
}
}
}
}
public search(query: ISearchQuery): PPromise<ISearchComplete, ISearchProgressItem> {
let combinedPromise: TPromise<void>;
return new PPromise<ISearchComplete, ISearchProgressItem>((onComplete, onError, onProgress) => {
// Get local results from dirty/untitled
const localResults = this.getLocalResults(query);
// Allow caller to register progress callback
process.nextTick(() => localResults.values().filter((res) => !!res).forEach(onProgress));
const providerPromises = this.searchProvider.map(provider => TPromise.wrap(provider.search(query)).then(e => e,
err => {
// TODO@joh
// single provider fail. fail all?
onError(err);
},
progress => {
if (progress.resource) {
// Match
if (!localResults.has(progress.resource)) { // don't override local results
onProgress(progress);
}
} else {
// Progress
onProgress(<IProgress>progress);
}
}
));
combinedPromise = TPromise.join(providerPromises).then(values => {
const result: ISearchComplete = {
limitHit: false,
results: [],
stats: undefined
};
// TODO@joh
// sorting, disjunct results
for (const value of values) {
if (!value) {
continue;
}
// TODO@joh individual stats/limit
result.stats = value.stats || result.stats;
result.limitHit = value.limitHit || result.limitHit;
for (const match of value.results) {
if (!localResults.has(match.resource)) {
result.results.push(match);
}
}
}
return result;
}).then(onComplete, onError);
}, () => combinedPromise && combinedPromise.cancel());
}
private getLocalResults(query: ISearchQuery): ResourceMap<IFileMatch> {
const localResults = new ResourceMap<IFileMatch>();
if (query.type === QueryType.Text) {
let models = this.modelService.getModels();
models.forEach((model) => {
let resource = model.uri;
if (!resource) {
return;
}
// Support untitled files
if (resource.scheme === 'untitled') {
if (!this.untitledEditorService.exists(resource)) {
return;
}
}
// Don't support other resource schemes than files for now
else if (resource.scheme !== 'file') {
return;
}
if (!this.matches(resource, query)) {
return; // respect user filters
}
// Use editor API to find matches
let matches = model.findMatches(query.contentPattern.pattern, false, query.contentPattern.isRegExp, query.contentPattern.isCaseSensitive, query.contentPattern.isWordMatch ? query.contentPattern.wordSeparators : null, false, query.maxResults);
if (matches.length) {
let fileMatch = new FileMatch(resource);
localResults.set(resource, fileMatch);
matches.forEach((match) => {
fileMatch.lineMatches.push(new LineMatch(model.getLineContent(match.range.startLineNumber), match.range.startLineNumber - 1, [[match.range.startColumn - 1, match.range.endColumn - match.range.startColumn]]));
});
} else {
localResults.set(resource, null);
}
});
}
return localResults;
}
private matches(resource: uri, query: ISearchQuery): boolean {
// file pattern
if (query.filePattern) {
if (resource.scheme !== 'file') {
return false; // if we match on file pattern, we have to ignore non file resources
}
if (!scorer.matches(resource.fsPath, strings.stripWildcards(query.filePattern).toLowerCase())) {
return false;
}
}
// includes
if (query.includePattern) {
if (resource.scheme !== 'file') {
return false; // if we match on file patterns, we have to ignore non file resources
}
}
return pathIncludedInQuery(query, resource.fsPath);
}
public clearCache(cacheKey: string): TPromise<void> {
return this.diskSearch.clearCache(cacheKey);
}
}
export class DiskSearch implements ISearchResultProvider {
private raw: IRawSearchService;
constructor(verboseLogging: boolean, timeout: number = 60 * 60 * 1000) {
const client = new Client(
uri.parse(require.toUrl('bootstrap')).fsPath,
{
serverName: 'Search',
timeout: timeout,
args: ['--type=searchService'],
// See https://github.com/Microsoft/vscode/issues/27665
// Pass in fresh execArgv to the forked process such that it doesn't inherit them from `process.execArgv`.
// e.g. Launching the extension host process with `--inspect-brk=xxx` and then forking a process from the extension host
// results in the forked process inheriting `--inspect-brk=xxx`.
freshExecArgv: true,
env: {
AMD_ENTRYPOINT: 'vs/workbench/services/search/node/searchApp',
PIPE_LOGGING: 'true',
VERBOSE_LOGGING: verboseLogging
}
}
);
const channel = getNextTickChannel(client.getChannel<ISearchChannel>('search'));
this.raw = new SearchChannelClient(channel);
}
public search(query: ISearchQuery): PPromise<ISearchComplete, ISearchProgressItem> {
let request: PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>;
let rawSearch: IRawSearch = {
folderQueries: query.folderQueries ? query.folderQueries.map(q => {
return <IFolderSearch>{
excludePattern: q.excludePattern,
includePattern: q.includePattern,
fileEncoding: q.fileEncoding,
folder: q.folder.fsPath
};
}) : [],
extraFiles: query.extraFileResources ? query.extraFileResources.map(r => r.fsPath) : [],
filePattern: query.filePattern,
excludePattern: query.excludePattern,
includePattern: query.includePattern,
maxResults: query.maxResults,
sortByScore: query.sortByScore,
cacheKey: query.cacheKey,
useRipgrep: query.useRipgrep,
disregardIgnoreFiles: query.disregardIgnoreFiles
};
if (query.type === QueryType.Text) {
rawSearch.contentPattern = query.contentPattern;
}
if (query.type === QueryType.File) {
request = this.raw.fileSearch(rawSearch);
} else {
request = this.raw.textSearch(rawSearch);
}
return DiskSearch.collectResults(request);
}
public static collectResults(request: PPromise<ISerializedSearchComplete, ISerializedSearchProgressItem>): PPromise<ISearchComplete, ISearchProgressItem> {
let result: IFileMatch[] = [];
return new PPromise<ISearchComplete, ISearchProgressItem>((c, e, p) => {
request.done((complete) => {
c({
limitHit: complete.limitHit,
results: result,
stats: complete.stats
});
}, e, (data) => {
// Matches
if (Array.isArray(data)) {
const fileMatches = data.map(d => this.createFileMatch(d));
result = result.concat(fileMatches);
fileMatches.forEach(p);
}
// Match
else if ((<ISerializedFileMatch>data).path) {
const fileMatch = this.createFileMatch(<ISerializedFileMatch>data);
result.push(fileMatch);
p(fileMatch);
}
// Progress
else {
p(<IProgress>data);
}
});
}, () => request.cancel());
}
private static createFileMatch(data: ISerializedFileMatch): FileMatch {
let fileMatch = new FileMatch(uri.file(data.path));
if (data.lineMatches) {
for (let j = 0; j < data.lineMatches.length; j++) {
fileMatch.lineMatches.push(new LineMatch(data.lineMatches[j].preview, data.lineMatches[j].lineNumber, data.lineMatches[j].offsetAndLengths));
}
}
return fileMatch;
}
public clearCache(cacheKey: string): TPromise<void> {
return this.raw.clearCache(cacheKey);
}
}

View File

@@ -0,0 +1,166 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as path from 'path';
import { onUnexpectedError } from 'vs/base/common/errors';
import { IProgress } from 'vs/platform/search/common/search';
import { FileWalker } from 'vs/workbench/services/search/node/fileSearch';
import { ISerializedFileMatch, ISerializedSearchComplete, IRawSearch, ISearchEngine } from './search';
import { ISearchWorker } from './worker/searchWorkerIpc';
import { ITextSearchWorkerProvider } from './textSearchWorkerProvider';
export class Engine implements ISearchEngine<ISerializedFileMatch[]> {
private static PROGRESS_FLUSH_CHUNK_SIZE = 50; // optimization: number of files to process before emitting progress event
private config: IRawSearch;
private walker: FileWalker;
private walkerError: Error;
private isCanceled = false;
private isDone = false;
private totalBytes = 0;
private processedBytes = 0;
private progressed = 0;
private walkerIsDone = false;
private limitReached = false;
private numResults = 0;
private workerProvider: ITextSearchWorkerProvider;
private workers: ISearchWorker[];
private nextWorker = 0;
constructor(config: IRawSearch, walker: FileWalker, workerProvider: ITextSearchWorkerProvider) {
this.config = config;
this.walker = walker;
this.workerProvider = workerProvider;
}
cancel(): void {
this.isCanceled = true;
this.walker.cancel();
this.workers.forEach(w => {
w.cancel()
.then(null, onUnexpectedError);
});
}
initializeWorkers(): void {
this.workers.forEach(w => {
w.initialize()
.then(null, onUnexpectedError);
});
}
search(onResult: (match: ISerializedFileMatch[]) => void, onProgress: (progress: IProgress) => void, done: (error: Error, complete: ISerializedSearchComplete) => void): void {
this.workers = this.workerProvider.getWorkers();
this.initializeWorkers();
const fileEncoding = this.config.folderQueries.length === 1 ?
this.config.folderQueries[0].fileEncoding || 'utf8' :
'utf8';
const progress = () => {
if (++this.progressed % Engine.PROGRESS_FLUSH_CHUNK_SIZE === 0) {
onProgress({ total: this.totalBytes, worked: this.processedBytes }); // buffer progress in chunks to reduce pressure
}
};
const unwind = (processed: number) => {
this.processedBytes += processed;
// Emit progress() unless we got canceled or hit the limit
if (processed && !this.isDone && !this.isCanceled && !this.limitReached) {
progress();
}
// Emit done()
if (!this.isDone && this.processedBytes === this.totalBytes && this.walkerIsDone) {
this.isDone = true;
done(this.walkerError, {
limitHit: this.limitReached,
stats: this.walker.getStats()
});
}
};
const run = (batch: string[], batchBytes: number): void => {
const worker = this.workers[this.nextWorker];
this.nextWorker = (this.nextWorker + 1) % this.workers.length;
const maxResults = this.config.maxResults && (this.config.maxResults - this.numResults);
const searchArgs = { absolutePaths: batch, maxResults, pattern: this.config.contentPattern, fileEncoding };
worker.search(searchArgs).then(result => {
if (!result || this.limitReached || this.isCanceled) {
return unwind(batchBytes);
}
const matches = result.matches;
onResult(matches);
this.numResults += result.numMatches;
if (this.config.maxResults && this.numResults >= this.config.maxResults) {
// It's possible to go over maxResults like this, but it's much simpler than trying to extract the exact number
// of file matches, line matches, and matches within a line to == maxResults.
this.limitReached = true;
}
unwind(batchBytes);
},
error => {
// An error on the worker's end, not in reading the file, but in processing the batch. Log and continue.
onUnexpectedError(error);
unwind(batchBytes);
});
};
// Walk over the file system
let nextBatch: string[] = [];
let nextBatchBytes = 0;
const batchFlushBytes = 2 ** 20; // 1MB
this.walker.walk(this.config.folderQueries, this.config.extraFiles, result => {
let bytes = result.size || 1;
this.totalBytes += bytes;
// If we have reached the limit or we are canceled, ignore it
if (this.limitReached || this.isCanceled) {
return unwind(bytes);
}
// Indicate progress to the outside
progress();
const absolutePath = result.base ? [result.base, result.relativePath].join(path.sep) : result.relativePath;
nextBatch.push(absolutePath);
nextBatchBytes += bytes;
if (nextBatchBytes >= batchFlushBytes) {
run(nextBatch, nextBatchBytes);
nextBatch = [];
nextBatchBytes = 0;
}
}, (error, isLimitHit) => {
this.walkerIsDone = true;
this.walkerError = error;
// Send any remaining paths to a worker, or unwind if we're stopping
if (nextBatch.length) {
if (this.limitReached || this.isCanceled) {
unwind(nextBatchBytes);
} else {
run(nextBatch, nextBatchBytes);
}
} else {
unwind(0);
}
});
}
}

View File

@@ -0,0 +1,52 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as os from 'os';
import uri from 'vs/base/common/uri';
import * as ipc from 'vs/base/parts/ipc/common/ipc';
import { Client } from 'vs/base/parts/ipc/node/ipc.cp';
import { ISearchWorker, ISearchWorkerChannel, SearchWorkerChannelClient } from './worker/searchWorkerIpc';
export interface ITextSearchWorkerProvider {
getWorkers(): ISearchWorker[];
}
export class TextSearchWorkerProvider implements ITextSearchWorkerProvider {
private workers: ISearchWorker[] = [];
getWorkers(): ISearchWorker[] {
const numWorkers = os.cpus().length;
while (this.workers.length < numWorkers) {
this.createWorker();
}
return this.workers;
}
private createWorker(): void {
let client = new Client(
uri.parse(require.toUrl('bootstrap')).fsPath,
{
serverName: 'Search Worker ' + this.workers.length,
args: ['--type=searchWorker'],
timeout: 30 * 1000,
env: {
AMD_ENTRYPOINT: 'vs/workbench/services/search/node/worker/searchWorkerApp',
PIPE_LOGGING: 'true',
VERBOSE_LOGGING: process.env.VERBOSE_LOGGING
},
useQueue: true
});
const channel = ipc.getNextTickChannel(client.getChannel<ISearchWorkerChannel>('searchWorker'));
const channelClient = new SearchWorkerChannelClient(channel);
this.workers.push(channelClient);
}
}

View File

@@ -0,0 +1,369 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as fs from 'fs';
import gracefulFs = require('graceful-fs');
gracefulFs.gracefulify(fs);
import { onUnexpectedError } from 'vs/base/common/errors';
import * as strings from 'vs/base/common/strings';
import { TPromise } from 'vs/base/common/winjs.base';
import { ISerializedFileMatch } from '../search';
import * as baseMime from 'vs/base/common/mime';
import { ILineMatch } from 'vs/platform/search/common/search';
import { UTF16le, UTF16be, UTF8, UTF8_with_bom, encodingExists, decode, bomLength } from 'vs/base/node/encoding';
import { detectMimeAndEncodingFromBuffer } from 'vs/base/node/mime';
import { ISearchWorker, ISearchWorkerSearchArgs, ISearchWorkerSearchResult } from './searchWorkerIpc';
interface ReadLinesOptions {
bufferLength: number;
encoding: string;
}
const MAX_FILE_ERRORS = 5; // Don't report more than this number of errors, 1 per file, to avoid flooding the log when there's a general issue
let numErrorsLogged = 0;
function onError(error: any): void {
if (numErrorsLogged++ < MAX_FILE_ERRORS) {
onUnexpectedError(error);
}
}
export class SearchWorker implements ISearchWorker {
private currentSearchEngine: SearchWorkerEngine;
initialize(): TPromise<void> {
this.currentSearchEngine = new SearchWorkerEngine();
return TPromise.wrap<void>(undefined);
}
cancel(): TPromise<void> {
// Cancel the current search. It will stop searching and close its open files.
if (this.currentSearchEngine) {
this.currentSearchEngine.cancel();
}
return TPromise.wrap<void>(null);
}
search(args: ISearchWorkerSearchArgs): TPromise<ISearchWorkerSearchResult> {
if (!this.currentSearchEngine) {
// Worker timed out during search
this.initialize();
}
return this.currentSearchEngine.searchBatch(args);
}
}
interface IFileSearchResult {
match: FileMatch;
numMatches: number;
limitReached?: boolean;
}
const LF = 0x0a;
const CR = 0x0d;
export class SearchWorkerEngine {
private nextSearch = TPromise.wrap(null);
private isCanceled = false;
/**
* Searches some number of the given paths concurrently, and starts searches in other paths when those complete.
*/
searchBatch(args: ISearchWorkerSearchArgs): TPromise<ISearchWorkerSearchResult> {
const contentPattern = strings.createRegExp(args.pattern.pattern, args.pattern.isRegExp, { matchCase: args.pattern.isCaseSensitive, wholeWord: args.pattern.isWordMatch, multiline: false, global: true });
const fileEncoding = encodingExists(args.fileEncoding) ? args.fileEncoding : UTF8;
return this.nextSearch =
this.nextSearch.then(() => this._searchBatch(args, contentPattern, fileEncoding));
}
private _searchBatch(args: ISearchWorkerSearchArgs, contentPattern: RegExp, fileEncoding: string): TPromise<ISearchWorkerSearchResult> {
if (this.isCanceled) {
return TPromise.wrap<ISearchWorkerSearchResult>(null);
}
return new TPromise<ISearchWorkerSearchResult>(batchDone => {
const result: ISearchWorkerSearchResult = {
matches: [],
numMatches: 0,
limitReached: false
};
// Search in the given path, and when it's finished, search in the next path in absolutePaths
const startSearchInFile = (absolutePath: string): TPromise<void> => {
return this.searchInFile(absolutePath, contentPattern, fileEncoding, args.maxResults && (args.maxResults - result.numMatches)).then(fileResult => {
// Finish early if search is canceled
if (this.isCanceled) {
return;
}
if (fileResult) {
result.numMatches += fileResult.numMatches;
result.matches.push(fileResult.match.serialize());
if (fileResult.limitReached) {
// If the limit was reached, terminate early with the results so far and cancel in-progress searches.
this.cancel();
result.limitReached = true;
return batchDone(result);
}
}
}, onError);
};
TPromise.join(args.absolutePaths.map(startSearchInFile)).then(() => {
batchDone(result);
});
});
}
cancel(): void {
this.isCanceled = true;
}
private searchInFile(absolutePath: string, contentPattern: RegExp, fileEncoding: string, maxResults?: number): TPromise<IFileSearchResult> {
let fileMatch: FileMatch = null;
let limitReached = false;
let numMatches = 0;
const perLineCallback = (line: string, lineNumber: number) => {
let lineMatch: LineMatch = null;
let match = contentPattern.exec(line);
// Record all matches into file result
while (match !== null && match[0].length > 0 && !this.isCanceled && !limitReached) {
if (fileMatch === null) {
fileMatch = new FileMatch(absolutePath);
}
if (lineMatch === null) {
lineMatch = new LineMatch(line, lineNumber);
fileMatch.addMatch(lineMatch);
}
lineMatch.addMatch(match.index, match[0].length);
numMatches++;
if (maxResults && numMatches >= maxResults) {
limitReached = true;
}
match = contentPattern.exec(line);
}
};
// Read lines buffered to support large files
return this.readlinesAsync(absolutePath, perLineCallback, { bufferLength: 8096, encoding: fileEncoding }).then(
() => fileMatch ? { match: fileMatch, limitReached, numMatches } : null);
}
private readlinesAsync(filename: string, perLineCallback: (line: string, lineNumber: number) => void, options: ReadLinesOptions): TPromise<void> {
return new TPromise<void>((resolve, reject) => {
fs.open(filename, 'r', null, (error: Error, fd: number) => {
if (error) {
return resolve(null);
}
const buffer = new Buffer(options.bufferLength);
let line = '';
let lineNumber = 0;
let lastBufferHadTrailingCR = false;
const readFile = (isFirstRead: boolean, clb: (error: Error) => void): void => {
if (this.isCanceled) {
return clb(null); // return early if canceled or limit reached
}
fs.read(fd, buffer, 0, buffer.length, null, (error: Error, bytesRead: number, buffer: NodeBuffer) => {
const decodeBuffer = (buffer: NodeBuffer, start: number, end: number): string => {
if (options.encoding === UTF8 || options.encoding === UTF8_with_bom) {
return buffer.toString(undefined, start, end); // much faster to use built in toString() when encoding is default
}
return decode(buffer.slice(start, end), options.encoding);
};
const lineFinished = (offset: number): void => {
line += decodeBuffer(buffer, pos, i + offset);
perLineCallback(line, lineNumber);
line = '';
lineNumber++;
pos = i + offset;
};
if (error || bytesRead === 0 || this.isCanceled) {
return clb(error); // return early if canceled or limit reached or no more bytes to read
}
let crlfCharSize = 1;
let crBytes = [CR];
let lfBytes = [LF];
let pos = 0;
let i = 0;
// Detect encoding and mime when this is the beginning of the file
if (isFirstRead) {
const mimeAndEncoding = detectMimeAndEncodingFromBuffer({ buffer, bytesRead }, false);
if (mimeAndEncoding.mimes[mimeAndEncoding.mimes.length - 1] !== baseMime.MIME_TEXT) {
return clb(null); // skip files that seem binary
}
// Check for BOM offset
switch (mimeAndEncoding.encoding) {
case UTF8:
pos = i = bomLength(UTF8);
options.encoding = UTF8;
break;
case UTF16be:
pos = i = bomLength(UTF16be);
options.encoding = UTF16be;
break;
case UTF16le:
pos = i = bomLength(UTF16le);
options.encoding = UTF16le;
break;
}
// when we are running with UTF16le/be, LF and CR are encoded as
// two bytes, like 0A 00 (LF) / 0D 00 (CR) for LE or flipped around
// for BE. We need to account for this when splitting the buffer into
// newlines, and when detecting a CRLF combo.
if (options.encoding === UTF16le) {
crlfCharSize = 2;
crBytes = [CR, 0x00];
lfBytes = [LF, 0x00];
} else if (options.encoding === UTF16be) {
crlfCharSize = 2;
crBytes = [0x00, CR];
lfBytes = [0x00, LF];
}
}
if (lastBufferHadTrailingCR) {
if (buffer[i] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + 1] === lfBytes[1])) {
lineFinished(1 * crlfCharSize);
i++;
} else {
lineFinished(0);
}
lastBufferHadTrailingCR = false;
}
/**
* This loop executes for every byte of every file in the workspace - it is highly performance-sensitive!
* Hence the duplication in reading the buffer to avoid a function call. Previously a function call was not
* being inlined by V8.
*/
for (; i < bytesRead; ++i) {
if (buffer[i] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + 1] === lfBytes[1])) {
lineFinished(1 * crlfCharSize);
} else if (buffer[i] === crBytes[0] && (crBytes.length === 1 || buffer[i + 1] === crBytes[1])) { // CR (Carriage Return)
if (i + crlfCharSize === bytesRead) {
lastBufferHadTrailingCR = true;
} else if (buffer[i + crlfCharSize] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + crlfCharSize + 1] === lfBytes[1])) {
lineFinished(2 * crlfCharSize);
i += 2 * crlfCharSize - 1;
} else {
lineFinished(1 * crlfCharSize);
}
}
}
line += decodeBuffer(buffer, pos, bytesRead);
readFile(/*isFirstRead=*/false, clb); // Continue reading
});
};
readFile(/*isFirstRead=*/true, (error: Error) => {
if (error) {
return resolve(null);
}
if (line.length) {
perLineCallback(line, lineNumber); // handle last line
}
fs.close(fd, (error: Error) => {
resolve(null);
});
});
});
});
}
}
export class FileMatch implements ISerializedFileMatch {
path: string;
lineMatches: LineMatch[];
constructor(path: string) {
this.path = path;
this.lineMatches = [];
}
addMatch(lineMatch: LineMatch): void {
this.lineMatches.push(lineMatch);
}
isEmpty(): boolean {
return this.lineMatches.length === 0;
}
serialize(): ISerializedFileMatch {
let lineMatches: ILineMatch[] = [];
let numMatches = 0;
for (let i = 0; i < this.lineMatches.length; i++) {
numMatches += this.lineMatches[i].offsetAndLengths.length;
lineMatches.push(this.lineMatches[i].serialize());
}
return {
path: this.path,
lineMatches,
numMatches
};
}
}
export class LineMatch implements ILineMatch {
preview: string;
lineNumber: number;
offsetAndLengths: number[][];
constructor(preview: string, lineNumber: number) {
this.preview = preview.replace(/(\r|\n)*$/, '');
this.lineNumber = lineNumber;
this.offsetAndLengths = [];
}
getText(): string {
return this.preview;
}
getLineNumber(): number {
return this.lineNumber;
}
addMatch(offset: number, length: number): void {
this.offsetAndLengths.push([offset, length]);
}
serialize(): ILineMatch {
const result = {
preview: this.preview,
lineNumber: this.lineNumber,
offsetAndLengths: this.offsetAndLengths
};
return result;
}
}

View File

@@ -0,0 +1,15 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { Server } from 'vs/base/parts/ipc/node/ipc.cp';
import { SearchWorkerChannel } from './searchWorkerIpc';
import { SearchWorker } from './searchWorker';
const server = new Server();
const worker = new SearchWorker();
const channel = new SearchWorkerChannel(worker);
server.registerChannel('searchWorker', channel);

View File

@@ -0,0 +1,68 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { TPromise } from 'vs/base/common/winjs.base';
import { IChannel } from 'vs/base/parts/ipc/common/ipc';
import { ISerializedFileMatch } from '../search';
import { IPatternInfo } from 'vs/platform/search/common/search';
import { SearchWorker } from './searchWorker';
export interface ISearchWorkerSearchArgs {
pattern: IPatternInfo;
fileEncoding: string;
absolutePaths: string[];
maxResults?: number;
}
export interface ISearchWorkerSearchResult {
matches: ISerializedFileMatch[];
numMatches: number;
limitReached: boolean;
}
export interface ISearchWorker {
initialize(): TPromise<void>;
search(args: ISearchWorkerSearchArgs): TPromise<ISearchWorkerSearchResult>;
cancel(): TPromise<void>;
}
export interface ISearchWorkerChannel extends IChannel {
call(command: 'initialize'): TPromise<void>;
call(command: 'search', args: ISearchWorkerSearchArgs): TPromise<ISearchWorkerSearchResult>;
call(command: 'cancel'): TPromise<void>;
call(command: string, arg?: any): TPromise<any>;
}
export class SearchWorkerChannel implements ISearchWorkerChannel {
constructor(private worker: SearchWorker) {
}
call(command: string, arg?: any): TPromise<any> {
switch (command) {
case 'initialize': return this.worker.initialize();
case 'search': return this.worker.search(arg);
case 'cancel': return this.worker.cancel();
}
return undefined;
}
}
export class SearchWorkerChannelClient implements ISearchWorker {
constructor(private channel: ISearchWorkerChannel) { }
initialize(): TPromise<void> {
return this.channel.call('initialize');
}
search(args: ISearchWorkerSearchArgs): TPromise<ISearchWorkerSearchResult> {
return this.channel.call('search', args);
}
cancel(): TPromise<void> {
return this.channel.call('cancel');
}
}