// ***************************************************************************** // Copyright (C) 2024 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core'; import { CancellationToken } from '@theia/core'; import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service'; import { inject, injectable } from '@theia/core/shared/inversify'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { SearchInWorkspaceService, SearchInWorkspaceCallbacks } from '@theia/search-in-workspace/lib/browser/search-in-workspace-service'; import { SearchInWorkspaceResult, SearchInWorkspaceOptions } from '@theia/search-in-workspace/lib/common/search-in-workspace-interface'; import { SEARCH_IN_WORKSPACE_FUNCTION_ID } from '../common/workspace-functions'; import { WorkspaceFunctionScope } from './workspace-functions'; import { SEARCH_IN_WORKSPACE_MAX_RESULTS_PREF } from '../common/workspace-preferences'; import { optimizeSearchResults } from '../common/workspace-search-provider-util'; @injectable() export class WorkspaceSearchProvider implements ToolProvider { @inject(SearchInWorkspaceService) protected readonly searchService: SearchInWorkspaceService; @inject(WorkspaceFunctionScope) protected readonly workspaceScope: WorkspaceFunctionScope; @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(FileService) protected readonly fileService: FileService; getTool(): ToolRequest { return { id: SEARCH_IN_WORKSPACE_FUNCTION_ID, name: SEARCH_IN_WORKSPACE_FUNCTION_ID, description: 'Searches file contents within the workspace for lines matching the given search term. ' + 'Returns up to 30 matching results by default (configurable via preferences). If results are truncated, ' + 'refine your search with fileExtensions or subDirectoryPath filters. ' + 'The search uses case-insensitive string matching or regular expressions (controlled by the `useRegExp` parameter). ' + 'Returns a list of matches including: file path, line number, and the matching line content. ' + 'Multi-word patterns must match exactly (including spaces, case-insensitively). ' + 'For best results, use specific search terms and filter by file extensions or subdirectories. ' + 'For complex searches, prefer multiple simpler queries over one complex regex. ' + 'Use this for finding code patterns, function usages, or text across the codebase. ' + 'Do NOT use this for finding files by name - use findFilesByPattern instead.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'The search term or regular expression pattern.', }, useRegExp: { type: 'boolean', description: 'Set to true if the query is a regular expression.', }, fileExtensions: { type: 'array', items: { type: 'string' }, description: 'Optional array of file extensions to search in (e.g., ["ts", "js", "py"]). If not specified, searches all files.' }, subDirectoryPath: { type: 'string', description: 'Optional directory to limit the search scope ' + '(e.g., "frontend/src", "backend/packages/core/src/browser"). ' + 'May also be an absolute path or `file://` URI of a directory the AI tools may access, ' + 'such as one listed in the `ai-features.workspaceFunctions.allowedExternalPaths` preference. ' + 'If not specified, searches the entire workspace.' } }, required: ['query', 'useRegExp'] }, handler: (argString, ctx?: ToolInvocationContext) => this.handleSearch(argString, ctx?.cancellationToken) }; } private async determineSearchRoots(subDirectoryPath?: string): Promise { const rootMapping = this.workspaceScope.getRootMapping(); if (rootMapping.size === 0) { throw new Error('No workspace has been opened yet'); } if (!subDirectoryPath) { return Array.from(rootMapping.values()).map(uri => uri.toString()); } const subDirUri = await this.workspaceScope.resolveAccessiblePath(subDirectoryPath); try { const stat = await this.fileService.resolve(subDirUri); if (!stat || !stat.isDirectory) { throw new Error(`Subdirectory '${subDirectoryPath}' does not exist or is not a directory`); } } catch (error) { throw new Error(`Invalid subdirectory path '${subDirectoryPath}': ${error.message}`); } return [subDirUri.toString()]; } private async handleSearch(argString: string, cancellationToken?: CancellationToken): Promise { try { const args: { query: string, useRegExp: boolean, fileExtensions?: string[] | string, subDirectoryPath?: string } = JSON.parse(argString); if (cancellationToken?.isCancellationRequested) { return JSON.stringify({ error: 'Operation cancelled by user' }); } const results: SearchInWorkspaceResult[] = []; let expectedSearchId: number | undefined; let searchCompleted = false; cancellationToken?.onCancellationRequested(() => { if (expectedSearchId !== undefined && !searchCompleted) { this.searchService.cancel(expectedSearchId); searchCompleted = true; } }); // Use one more than our actual maximum. this way we can determine if we have more results than our maximum and warn the user const maxResultsForTheiaAPI = this.preferenceService.get(SEARCH_IN_WORKSPACE_MAX_RESULTS_PREF, 30) + 1; const options: SearchInWorkspaceOptions = { useRegExp: args.useRegExp, matchCase: false, matchWholeWord: false, maxResults: maxResultsForTheiaAPI, }; // The model may supply fileExtensions as a single string instead of an array; normalize to an array so that `.map` never throws. const fileExtensions = args.fileExtensions === undefined ? undefined : Array.isArray(args.fileExtensions) ? args.fileExtensions : [args.fileExtensions]; if (fileExtensions && fileExtensions.length > 0) { options.include = fileExtensions.map(ext => `**/*.${ext}`); } // Resolve the search roots before starting the search so that any error is reported through the outer try/catch. const rootUris = await this.determineSearchRoots(args.subDirectoryPath); const searchPromise = new Promise((resolve, reject) => { const callbacks: SearchInWorkspaceCallbacks = { onResult: (id, result) => { if (expectedSearchId !== undefined && id !== expectedSearchId) { return; } if (searchCompleted) { return; } results.push(result); }, onDone: (id, error) => { if (expectedSearchId !== undefined && id !== expectedSearchId) { return; } if (searchCompleted) { return; } searchCompleted = true; if (error) { reject(new Error('Search failed: ' + error)); } else { resolve(results); } } }; this.searchService.searchWithCallback(args.query, rootUris, callbacks, options) .then(id => { expectedSearchId = id; // Handle cancellation that was requested before the search id became available. if (cancellationToken?.isCancellationRequested && !searchCompleted) { this.searchService.cancel(id); } }) .catch(err => { searchCompleted = true; reject(err); }); }); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { if (!searchCompleted) { if (expectedSearchId !== undefined) { this.searchService.cancel(expectedSearchId); } searchCompleted = true; reject(new Error('Search timed out after 30 seconds')); } }, 30000); }); const finalResults = await Promise.race([searchPromise, timeoutPromise]); const maxResults = this.preferenceService.get(SEARCH_IN_WORKSPACE_MAX_RESULTS_PREF, 30); const formattedResults = optimizeSearchResults(finalResults, this.workspaceScope); let numberOfMatchesInFinalResults = 0; for (const result of finalResults) { numberOfMatchesInFinalResults += result.matches.length; } if (numberOfMatchesInFinalResults > maxResults) { return JSON.stringify({ info: 'Search limit exceeded: Found ' + maxResults + '+ results. ' + 'Please refine your search with more specific terms or use file extension filters. ' + 'You can increase the limit in preferences under \'ai-features.workspaceFunctions.searchMaxResults\'.', incompleteResults: formattedResults }); } return JSON.stringify(formattedResults); } catch (error) { return JSON.stringify({ error: error.message || 'Failed to execute search' }); } } }