src/vs/workbench/services/search/common/textSearchManager.ts
394 LOC · 353 covered · 41 uncovered · 90 ranges · 72 concepts · 15 introducers · 34 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
textSearchManager.ts ×23
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isThenable } from '../../../../base/common/async.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { toErrorMessage } from '../../../../base/common/errorMessage.js';
import { Schemas } from '../../../../base/common/network.js';
import * as path from '../../../../base/common/path.js';
import * as resources from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { FolderQuerySearchTree } from './folderQuerySearchTree.js';
import { DEFAULT_MAX_SEARCH_RESULTS, hasSiblingPromiseFn, IAITextQuery, IFileMatch, IFolderQuery, excludeToGlobPattern, IPatternInfo, ISearchCompleteStats, ITextQuery, ITextSearchContext, ITextSearchMatch, ITextSearchResult, ITextSearchStats, QueryGlobTester, QueryType, resolvePatternsForProvider, ISearchRange, DEFAULT_TEXT_SEARCH_PREVIEW_OPTIONS } from './search.js';
import { TextSearchComplete2, TextSearchMatch2, TextSearchProviderFolderOptions, TextSearchProvider2, TextSearchProviderOptions, TextSearchQuery2, TextSearchResult2, AITextSearchProvider, AISearchResult, AISearchKeyword } from './searchExtTypes.js';
export interface IFileUtils {
readdir: (resource: URI) => Promise<string[]>;
toCanonicalName: (encoding: string) => string;
}
interface IAITextQueryProviderPair {
query: IAITextQuery; provider: AITextSearchProvider;
}
interface ITextQueryProviderPair {
query: ITextQuery; provider: TextSearchProvider2;
}
interface FolderQueryInfo {
queryTester: QueryGlobTester;
folder: URI;
folderIdx: number;
}
export class TextSearchManager {
private collector: TextSearchResultsCollector | null = null;
private isLimitHit = false;
private resultCount = 0;
constructor(private queryProviderPair: IAITextQueryProviderPair | ITextQueryProviderPair,
private processType: ITextSearchStats['type']) { }
private get query() {
}
search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise<ISearchCompleteStats> {
const tokenSource = new CancellationTokenSource(token);
return new Promise<ISearchCompleteStats>((resolve, reject) => {
this.collector = new TextSearchResultsCollector(onProgress);
let isCanceled = false;
const onResult = (result: TextSearchResult2, folderIdx: number) => {
// Already processed by the callback.
return;
}
}
if (!this.isLimitHit) {
const resultSize = this.resultSize(result);
if (result instanceof TextSearchMatch2 && typeof this.query.maxResults === 'number' && this.resultCount + resultSize > this.query.maxResults) {
isCanceled = true;
tokenSource.cancel();
result = this.trimResultToSize(result, this.query.maxResults - this.resultCount);
}
const newResultSize = this.resultSize(result);
this.resultCount += newResultSize;
const a = result instanceof TextSearchMatch2;
if (newResultSize > 0 || !a) {
this.collector!.add(result, folderIdx);
}
}
};
// For each root folder
this.doSearch(folderQueries, onResult, tokenSource.token, onKeywordResult).then(result => {
this.collector!.flush();
resolve({
limitHit: this.isLimitHit || result?.limitHit,
messages: this.getMessagesFromResults(result),
stats: {
type: this.processType
}
});
const errMsg = toErrorMessage(err);
reject(new Error(errMsg));
});
}
private getMessagesFromResults(result: TextSearchComplete2 | null | undefined) {
return [result.message];
private resultSize(result: TextSearchResult2): number {
return Array.isArray(result.ranges) ?
result.ranges.length :
1;
else {
// #104400 context lines shoudn't count towards result count
return 0;
}
private trimResultToSize(result: TextSearchMatch2, size: number): TextSearchMatch2 {
return new TextSearchMatch2(result.uri, result.ranges.slice(0, size), result.previewText);
textSearchManager.ts ×2
}
private async doSearch(folderQueries: IFolderQuery<URI>[], onResult: (result: TextSearchResult2, folderIdx: number) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise<TextSearchComplete2 | null | undefined> {
const folderMappings: FolderQuerySearchTree<FolderQueryInfo> = new FolderQuerySearchTree<FolderQueryInfo>(
textSearchManager.ts ×16
folderQueries,
(fq, i) => {
const queryTester = new QueryGlobTester(this.query, fq);
return { queryTester, folder: fq.folder, folderIdx: i };
},
() => true
);
const testingPs: Promise<void>[] = [];
const progress = {
report: (result: TextSearchResult2 | AISearchResult) => {
onKeywordResult?.(result);
if (result.uri === undefined) {
throw Error('Text search result URI is undefined. Please check provider implementation.');
}
const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri);
textSearchManager.ts ×25
if (folderQuery?.folder?.scheme) {
const hasSibling = folderQuery.folder.scheme === Schemas.file ?
const relativePath = resources.relativePath(folderQuery.folder, result.uri);
if (relativePath) {
// This method is only async when the exclude contains sibling clauses
const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling);
if (isThenable(included)) {
testingPs.push(
included.then(isIncluded => {
if (isIncluded) {
onResult(result, folderQuery.folderIdx);
}
}));
} else if (included) {
onResult(result, folderQuery.folderIdx);
}
}
}
}
const folderOptions = folderQueries.map(fq => this.getSearchOptionsForFolder(fq));
const searchOptions: TextSearchProviderOptions = {
folderOptions,
maxFileSize: this.query.maxFileSize,
maxResults: this.query.maxResults ?? DEFAULT_MAX_SEARCH_RESULTS,
previewOptions: this.query.previewOptions ?? DEFAULT_TEXT_SEARCH_PREVIEW_OPTIONS,
surroundingContext: this.query.surroundingContext ?? 0,
};
let result;
if (this.queryProviderPair.query.type === QueryType.aiText) {
result = await (this.queryProviderPair as IAITextQueryProviderPair).provider.provideAITextSearchResults(this.queryProviderPair.query.contentPattern, searchOptions, progress, token);
result = await (this.queryProviderPair as ITextQueryProviderPair).provider.provideTextSearchResults(patternInfoToQuery(this.queryProviderPair.query.contentPattern), searchOptions, progress, token);
if (testingPs.length) {
}
return result;
private getSearchOptionsForFolder(fq: IFolderQuery<URI>): TextSearchProviderFolderOptions {
const includes = resolvePatternsForProvider(this.query.includePattern, fq.includePattern);
textSearchManager.ts ×16
let excludePattern = fq.excludePattern?.map(e => ({
patterns: resolvePatternsForProvider(this.query.excludePattern, e.pattern)
if (!excludePattern || excludePattern.length === 0) {
folder: undefined,
patterns: resolvePatternsForProvider(this.query.excludePattern, undefined)
}];
}
const options = {
folder: URI.from(fq.folder),
excludes,
includes,
useIgnoreFiles: {
local: !fq.disregardIgnoreFiles,
parent: !fq.disregardParentIgnoreFiles,
global: !fq.disregardGlobalIgnoreFiles
},
followSymlinks: !fq.ignoreSymlinks,
encoding: (fq.fileEncoding && this.fileUtils.toCanonicalName(fq.fileEncoding)) ?? '',
ignoreGlobCase: this.query.ignoreGlobCase || fq.ignoreGlobCase,
};
return options;
}
function patternInfoToQuery(patternInfo: IPatternInfo): TextSearchQuery2 {
textSearchManager.ts ×16
return {
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 = -1;
private _currentUri: URI | undefined;
private _currentFileMatch: IFileMatch | null = null;
constructor(private _onResult: (result: IFileMatch[]) => void) {
this._batchedCollector = new BatchedCollector<IFileMatch>(512, items => this.sendItems(items));
textSearchManager.ts ×16
}
add(data: TextSearchResult2, folderIdx: number): void {
// Collects TextSearchResults into IInternalFileMatches and collates using BatchedCollector.
textSearchManager.ts ×25
// 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._currentFileMatch = null;
}
if (!this._currentFileMatch) {
this._currentFolderIdx = folderIdx;
this._currentUri = data.uri;
this._currentFileMatch = {
resource: data.uri,
results: []
};
}
this._currentFileMatch.results!.push(extensionResultToFrontendResult(data));
}
private pushToCollector(): void {
const size = this._currentFileMatch && this._currentFileMatch.results ?
textSearchManager.ts ×12
}
flush(): void {
this._batchedCollector.flush();
}
private sendItems(items: IFileMatch[]): void {
}
function extensionResultToFrontendResult(data: TextSearchResult2): ITextSearchResult {
textSearchManager.ts ×25
// Warning: result from RipgrepTextSearchEH has fake Range. Don't depend on any other props beyond these...
if (data instanceof TextSearchMatch2) {
return {
previewText: data.previewText,
rangeLocations: data.ranges.map(r => ({
preview: {
startLineNumber: r.previewRange.start.line,
startColumn: r.previewRange.start.character,
endLineNumber: r.previewRange.end.line,
endColumn: r.previewRange.end.character
} satisfies ISearchRange,
source: {
startLineNumber: r.sourceRange.start.line,
startColumn: r.sourceRange.start.character,
endLineNumber: r.sourceRange.end.line,
endColumn: r.sourceRange.end.character
} satisfies ISearchRange,
})),
} satisfies ITextSearchMatch;
} else {
return {
text: data.text,
lineNumber: data.lineNumber
} satisfies ITextSearchContext;
}
/**
* 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: Timeout | undefined;
constructor(private maxBatchSize: number, private cb: (items: T[]) => void) {
addItem(item: T, size: number): void {
}
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.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) {
textSearchManager.ts ×25
// 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 {
this.cb(this.batch);
this.batch = [];
this.batchSize = 0;
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = undefined;
}