diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0fa911ca91e..0c8d1ae9c53 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -132,6 +132,7 @@ jobs: NODE_OPTIONS: '--max-old-space-size=8192' AWS_TOOLKIT_TEST_CACHE_DIR: '/tmp/.vscode-test/' AWS_TOOLKIT_TEST_USER_DIR: '/tmp/.vscode-test/user-data/' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -169,6 +170,7 @@ jobs: NODE_OPTIONS: '--max-old-space-size=8192' AWS_TOOLKIT_TEST_CACHE_DIR: '/tmp/.vscode-test/' AWS_TOOLKIT_TEST_USER_DIR: '/tmp/.vscode-test/user-data/' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} diff --git a/buildspec/linuxTests.yml b/buildspec/linuxTests.yml index 900b720e61a..9e787406813 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -13,6 +13,7 @@ env: # followed by Error: Could not delete obsolete instance handle Error: ENOENT: no such file or directory, unlink AWS_TOOLKIT_TEST_CACHE_DIR: '/tmp/.vscode-test/' AWS_TOOLKIT_TEST_USER_DIR: '/tmp/.vscode-test/user-data/' + VSCODE_RIPGREP_TOKEN: ${GITHUB_READONLY_TOKEN} phases: install: diff --git a/buildspec/packageTestVsix.yml b/buildspec/packageTestVsix.yml index 73f15c0ba10..ee9ad7b7034 100644 --- a/buildspec/packageTestVsix.yml +++ b/buildspec/packageTestVsix.yml @@ -8,6 +8,7 @@ env: AWS_TOOLKIT_TEST_USER_DIR: '/tmp/' # needed or else webpack will cause it to run out of memory NODE_OPTIONS: '--max-old-space-size=8192' + # VSCODE_RIPGREP_TOKEN will be set in pre_build phase phases: install: @@ -20,6 +21,9 @@ phases: pre_build: commands: - export HOME=/home/codebuild-user + # Set up VSCODE_RIPGREP_TOKEN for GitHub API access + - export VSCODE_RIPGREP_TOKEN=${GITHUB_READONLY_TOKEN} + - echo "Setting up VSCODE_RIPGREP_TOKEN for GitHub API access" - bash buildspec/shared/linux-pre_build.sh build: diff --git a/package-lock.json b/package-lock.json index a52329b07aa..ba9fe4d1a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ ], "dependencies": { "@types/node": "^22.7.5", + "@vscode/ripgrep": "1.15.11", "vscode-nls": "^5.2.0", "vscode-nls-dev": "^4.0.4" }, @@ -15022,6 +15023,40 @@ "version": "1.64.0", "license": "MIT" }, + "node_modules/@vscode/ripgrep": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.11.tgz", + "integrity": "sha512-G/VqtA6kR50mJkIH4WA+I2Q78V5blovgPPq0VPYM0QIRp57lYMkdV+U9VrY80b3AvaC72A1z8STmyxc8ZKiTsw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.2", + "proxy-from-env": "^1.1.0", + "yauzl": "^2.9.2" + } + }, + "node_modules/@vscode/ripgrep/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@vscode/ripgrep/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@vscode/test-electron": { "version": "2.3.8", "dev": true, @@ -16514,7 +16549,6 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -17509,7 +17543,6 @@ }, "node_modules/debug": { "version": "4.3.4", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -18902,7 +18935,6 @@ }, "node_modules/fd-slicer": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -22507,7 +22539,6 @@ }, "node_modules/pend": { "version": "1.2.0", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -22965,7 +22996,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/psl": { @@ -26690,7 +26720,6 @@ }, "node_modules/yauzl": { "version": "2.10.0", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/package.json b/package.json index 68463ece306..8d86b4a5b0b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "dependencies": { "@types/node": "^22.7.5", "vscode-nls": "^5.2.0", - "vscode-nls-dev": "^4.0.4" + "vscode-nls-dev": "^4.0.4", + "@vscode/ripgrep": "1.15.11" } } diff --git a/packages/amazonq/scripts/build/copyFiles.ts b/packages/amazonq/scripts/build/copyFiles.ts index 45b1d263f0b..42cda370e80 100644 --- a/packages/amazonq/scripts/build/copyFiles.ts +++ b/packages/amazonq/scripts/build/copyFiles.ts @@ -60,6 +60,11 @@ const tasks: CopyTask[] = [ target: path.join('../../node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), destination: path.join('src', 'tree-sitter.wasm'), }, + // ripgrep binary + { + target: path.join('../../node_modules', '@vscode/ripgrep', 'bin'), + destination: 'bin/', + }, ] function copy(task: CopyTask): void { diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index f7a1b7d125b..73b4ce40c0c 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -40,6 +40,11 @@ export class ChatSession { private _pairProgrammingModeOn: boolean = true private _fsWriteBackups: Map = new Map() private _agenticLoopInProgress: boolean = false + private _searchResults: DocumentReference[] = [] + + // // Search-related properties + // public _lastSearchQuery?: string + // public _lastSearchPath?: string /** * True if messages from local history have been sent to session. @@ -47,11 +52,15 @@ export class ChatSession { localHistoryHydrated: boolean = false private _messageIdToUpdate: string | undefined private _messageIdToUpdateListDirectory: string | undefined + private _messageIdToUpdateGrepSearch: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root // e.g. root_a/file1 vs root_b/file1 relativePathToWorkspaceRoot: Map = new Map() + + // lastSearchQuery: string | undefined + // lastSearchPath: string | undefined public get sessionIdentifier(): string { return this.sessionId } @@ -71,6 +80,14 @@ export class ChatSession { this._messageIdToUpdateListDirectory = messageId } + public get messageIdToUpdateGrepSearch(): string | undefined { + return this._messageIdToUpdateGrepSearch + } + + public setMessageIdToUpdateGrepSearch(messageId: string | undefined) { + this._messageIdToUpdateGrepSearch = messageId + } + public get agenticLoopInProgress(): boolean { return this._agenticLoopInProgress } @@ -147,6 +164,10 @@ export class ChatSession { public get readFiles(): DocumentReference[] { return this._readFiles } + + public get searchResults(): DocumentReference[] { + return this._searchResults + } public get readFolders(): DocumentReference[] { return this._readFolders } @@ -159,6 +180,9 @@ export class ChatSession { public addToReadFiles(filePath: DocumentReference) { this._readFiles.push(filePath) } + public setSearchResults(searchResults: DocumentReference[]) { + this.searchResults.push(...searchResults) + } public clearListOfReadFiles() { this._readFiles = [] } @@ -168,6 +192,10 @@ export class ChatSession { public clearListOfReadFolders() { this._readFolders = [] } + public clearSearchResults() { + this._searchResults = [] + this._messageIdToUpdateGrepSearch = undefined + } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 2e02c2037fb..01e0d08bc4e 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -71,6 +71,7 @@ import { ConversationTracker } from '../../../storages/conversationTracker' import { waitTimeout, Timeout } from '../../../../shared/utilities/timeoutUtils' import { FsReadParams } from '../../../tools/fsRead' import { ListDirectoryParams } from '../../../tools/listDirectory' +import { SanitizedRipgrepOutput } from '../../../tools/grepSearch' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -307,7 +308,13 @@ export class Messenger { let explanation: string | undefined = undefined let changeList: Change[] | undefined = undefined let messageIdToUpdate: string | undefined = undefined - const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( + // eslint-disable-next-line prettier/prettier + const isReadOrList: boolean = [ + ToolType.FsRead, + ToolType.ListDirectory, + ToolType.GrepSearch, + ].includes( + // eslint-disable-next-line prettier/prettier tool.type ) if (tool.type === ToolType.ExecuteBash) { @@ -391,6 +398,12 @@ export class Messenger { ) { session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) } + if ( + session.messageIdToUpdateGrepSearch === undefined && + tool.type === ToolType.GrepSearch + ) { + session.setMessageIdToUpdateGrepSearch(toolUse.toolUseId) + } getLogger().debug( `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` ) @@ -743,6 +756,80 @@ export class Messenger { ) } + private sendGrepSearchToolMessage( + message: string, + toolUse: ToolUse, + session: ChatSession, + tabID: string, + triggerID: string, + messageIdToUpdate?: string + ) { + getLogger().info(`Grep search update message is: "${message}"`) + let searchResults = session.searchResults + + // Check if the message contains grep search results + if (message.includes('Found') && message.includes('matches')) { + try { + // Remove the first line summary if present + const jsonStartIndex = message.indexOf('{') + if (jsonStartIndex !== -1) { + const jsonString = message.substring(jsonStartIndex) + + // Parse the JSON string to get the SanitizedRipgrepOutput + const ripgrepOutput: SanitizedRipgrepOutput = JSON.parse(jsonString) + + // Convert the fileMatches to the format expected by session.searchResults + searchResults = ripgrepOutput.fileMatches.map((file) => ({ + relativeFilePath: file.filePath, + // Get line numbers from the matches object + lineRanges: Object.keys(file.matches || {}).map((lineNum) => ({ + first: parseInt(lineNum, 10), + second: parseInt(lineNum, 10), + })), + })) + + // Store the search results in the session for context transparency + session.setSearchResults(searchResults) + } + } catch (error) { + getLogger().error(`Error parsing grep search results: ${error}`) + } + } + + const contextList = session.searchResults + + const itemCount = contextList.length + + // Create a title based on search results + const title = + itemCount === 0 ? 'No search results found' : `Found ${itemCount} match${itemCount !== 1 ? 'es' : ''}` + + // Send the tool message with the context list + this.dispatcher.sendToolMessage( + new ToolMessage( + { + message: '', + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList, + canBeVoted: false, + buttons: undefined, + fullWidth: false, + padding: false, + codeBlockActions: undefined, + rootFolderTitle: title, + }, + tabID + ) + ) + } + public sendPartialToolLog( message: string, tabID: string, @@ -757,12 +844,18 @@ export class Messenger { return } - // Handle read tool and list directory messages + // Handle read tool, grep search, and list directory messages if ( - (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) && + (toolUse?.name === ToolType.FsRead || + toolUse?.name === ToolType.ListDirectory || + toolUse?.name === ToolType.GrepSearch) && !validation.requiresAcceptance ) { - return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + if (toolUse?.name === ToolType.GrepSearch) { + return this.sendGrepSearchToolMessage(message, toolUse, session, tabID, triggerID, messageIdToUpdate) + } else { + return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + } } // Handle file write tool, execute bash tool and bash command output log messages diff --git a/packages/core/src/codewhispererChat/tools/fileSearch.ts b/packages/core/src/codewhispererChat/tools/fileSearch.ts new file mode 100644 index 00000000000..c346f4664b1 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/fileSearch.ts @@ -0,0 +1,111 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import path from 'path' +import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' +import { isInDirectory } from '../../shared/filesystemUtilities' + +export interface FileSearchParams { + path: string + pattern: string + maxDepth?: number + caseSensitive?: boolean +} + +export class FileSearch { + private fsPath: string + private pattern: RegExp + private maxDepth?: number + private readonly logger = getLogger('fileSearch') + + constructor(params: FileSearchParams) { + this.fsPath = params.path + // Create RegExp with case sensitivity option + this.pattern = new RegExp(params.pattern, params.caseSensitive ? '' : 'i') + this.maxDepth = params.maxDepth + } + + public async validate(): Promise { + if (!this.fsPath || this.fsPath.trim().length === 0) { + throw new Error('Path cannot be empty.') + } + if (this.maxDepth !== undefined && this.maxDepth < 0) { + throw new Error('MaxDepth cannot be negative.') + } + + const sanitized = sanitizePath(this.fsPath) + this.fsPath = sanitized + + const pathUri = vscode.Uri.file(this.fsPath) + let pathExists: boolean + try { + pathExists = await fs.existsDir(pathUri) + if (!pathExists) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`) + } + } + + public queueDescription(updates: Writable): void { + const fileName = path.basename(this.fsPath) + if (this.maxDepth === undefined) { + updates.write(`Searching for files matching pattern: ${this.pattern} in ${fileName} recursively`) + } else if (this.maxDepth === 0) { + updates.write(`Searching for files matching pattern: ${this.pattern} in ${fileName}`) + } else { + const level = this.maxDepth > 1 ? 'levels' : 'level' + updates.write( + `Searching for files matching pattern: ${this.pattern} in ${fileName} limited to ${this.maxDepth} subfolder ${level}` + ) + } + updates.end() + } + + public requiresAcceptance(): CommandValidation { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + return { requiresAcceptance: false } + } + + public async invoke(updates?: Writable): Promise { + try { + const fileUri = vscode.Uri.file(this.fsPath) + const allFiles = await readDirectoryRecursively(fileUri, this.maxDepth) + + // Filter files by regex pattern + const matchedFiles = allFiles.filter((filePath) => { + // Extract just the filename from the path + const fileName = path.basename(filePath.split(' ').slice(1).join(' ')) + return this.pattern.test(fileName) + }) + + return this.createOutput(matchedFiles.join('\n')) + } catch (error: any) { + this.logger.error(`Failed to search files in "${this.fsPath}": ${error.message || error}`) + throw new Error(`Failed to search files in "${this.fsPath}": ${error.message || error}`) + } + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content, + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts new file mode 100644 index 00000000000..e03fdca7414 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -0,0 +1,305 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { sanitizePath, InvokeOutput, OutputKind } from './toolShared' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { rgPath } from '@vscode/ripgrep' +import path from 'path' + +export interface GrepSearchParams { + path?: string + query: string + caseSensitive?: boolean + excludePattern?: string + includePattern?: string + explanation?: string +} + +/** + * Represents the structured output from ripgrep search results + */ +export interface SanitizedRipgrepOutput { + /** Total number of matches across all files */ + totalMatchCount: number + + /** Array of file match details */ + fileMatches: Array<{ + /** Full path to the file */ + filePath: string + + /** Base name of the file */ + fileName: string + + /** Number of matches in this file */ + matchCount: number + + /** Record of line numbers to matched content */ + matches: Record + }> +} + +export class GrepSearch { + private path: string + private query: string + private caseSensitive: boolean + private excludePattern?: string + private includePattern?: string + private readonly logger = getLogger('grepSearch') + + constructor(params: GrepSearchParams) { + this.path = this.getSearchDirectory(params.path) + this.query = params.query + this.caseSensitive = params.caseSensitive ?? false + this.excludePattern = params.excludePattern + this.includePattern = params.includePattern + } + + public async validate(): Promise { + if (!this.query || this.query.trim().length === 0) { + throw new Error('Grep search query cannot be empty.') + } + + if (this.path.trim().length === 0) { + throw new Error('Path cannot be empty and no workspace folder is available.') + } + + const sanitized = sanitizePath(this.path) + this.path = sanitized + + const pathUri = vscode.Uri.file(this.path) + let pathExists: boolean + try { + pathExists = await fs.existsDir(pathUri) + if (!pathExists) { + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed. (${err})`) + } + } + + public queueDescription(updates: Writable): void { + updates.write('') + updates.end() + } + + public requiresAcceptance(): { requiresAcceptance: boolean } { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + + // Check if the search path is within the workspace + const isInWorkspace = workspaceFolders.some((folder) => this.path.startsWith(folder.uri.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + + return { requiresAcceptance: false } + } + + public async invoke(updates?: Writable): Promise { + try { + const results = await this.executeRipgrep(updates) + return this.createOutput(results) + } catch (error: any) { + this.logger.error(`Failed to search in "${this.path}": ${error.message || error}`) + throw new Error(`Failed to search in "${this.path}": ${error.message || error}`) + } + } + + private getSearchDirectory(path?: string): string { + let searchLocation = '' + if (path && path.trim().length !== 0) { + searchLocation = path + } else { + // Handle optional path parameter + // Use current workspace folder as default if path is not provided + const workspaceFolders = vscode.workspace.workspaceFolders + this.logger.info(`Using default workspace folder: ${workspaceFolders?.length}`) + if (workspaceFolders && workspaceFolders.length !== 0) { + searchLocation = workspaceFolders[0].uri.fsPath + this.logger.debug(`Using default workspace folder: ${searchLocation}`) + } + } + return searchLocation + } + + private async executeRipgrep(updates?: Writable): Promise { + return new Promise(async (resolve, reject) => { + const args: string[] = [] + + // Add search options + if (!this.caseSensitive) { + args.push('-i') // Case insensitive search + } + args.push('--line-number') // Show line numbers + + // No heading (don't group matches by file) + args.push('--no-heading') + + // Don't use color in output + args.push('--color', 'never') + + // Limit results to prevent overwhelming output + args.push('--max-count', '50') + + // Add include/exclude patterns + if (this.includePattern) { + // Support multiple include patterns + const patterns = this.includePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', pattern.trim()) + } + } + + if (this.excludePattern) { + // Support multiple exclude patterns + const patterns = this.excludePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', `!${pattern.trim()}`) + } + } + + // Add search pattern and path + args.push(this.query, this.path) + + this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`) + + const options: ChildProcessOptions = { + collect: true, + logging: 'yes', + rejectOnErrorCode: (code) => { + if (code !== 0 && code !== 1) { + this.logger.error(`Ripgrep process exited with code ${code}`) + return new Error(`Ripgrep process exited with code ${code}`) + } + // For exit codes 0 and 1, don't reject + return false as unknown as Error + }, + } + + try { + const rg = new ChildProcess(rgPath, args, options) + const result = await rg.run() + this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`) + + // Process the output to format with file URLs and content previews + const sanitizedOutput = this.processRipgrepOutput(result.stdout) + + // If updates is provided, write the processed output + if (updates) { + if (sanitizedOutput.totalMatchCount > 0) { + updates.write(`Found total matches: "${sanitizedOutput.totalMatchCount}" in "${this.path}\n`) + // Ensure matches is properly serialized as a plain object + const serializedOutput = { + ...sanitizedOutput, + fileMatches: sanitizedOutput.fileMatches.map((file) => ({ + ...file, + // Ensure matches is a plain object for serialization + matches: { ...file.matches }, + })), + } + updates.write(JSON.stringify(serializedOutput, undefined, 2)) + } else { + updates.write('No matches found.') + } + } + + resolve(sanitizedOutput) + + // // If updates is provided, write the processed output + // if (updates) { + // if (totalMatchCount > 0) { + // updates.write(sanitizedOutput) + // } else { + // updates.write('No matches found.') + // } + // } + + // this.logger.info(`Processed ripgrep result: ${totalMatchCount} matches found`) + // resolve(sanitizedOutput || 'No matches found.') + } catch (err) { + if (updates) { + updates.write(`Error executing search: ${err}`) + } + reject(err) + } + }) + } + + /** + * Process ripgrep output to: + * 1. Group results by file + * 2. Return structured match details for each file + */ + private processRipgrepOutput(output: string): SanitizedRipgrepOutput { + if (!output || output.trim() === '') { + return { + totalMatchCount: 0, + fileMatches: [], + } + } + const lines = output.split('\n') + // Group by file path + const fileGroups: Record = {} + let totalMatchCount = 0 + for (const line of lines) { + if (!line || line.trim() === '') { + continue + } + // Extract file path, line number, and content + const parts = line.split(':') + if (parts.length < 3) { + continue + } + const filePath = parts[0] + const lineNumber = parts[1] + const content = parts.slice(2).join(':').trim() + if (!fileGroups[filePath]) { + fileGroups[filePath] = { lineNumbers: [], content: [] } + } + fileGroups[filePath].lineNumbers.push(lineNumber) + fileGroups[filePath].content.push(content) + totalMatchCount++ + } + // Sort files by match count (most matches first) + const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].lineNumbers.length - a[1].lineNumbers.length) + // Create structured file matches + const fileMatches = sortedFiles.map(([filePath, data]) => { + const fileName = path.basename(filePath) + const matchCount = data.lineNumbers.length + // Create a regular object instead of a Map for better JSON serialization + const matches: Record = {} + for (const [idx, lineNum] of data.lineNumbers.entries()) { + matches[lineNum] = data.content[idx] + } + + return { + filePath, + fileName, + matchCount, + matches, + } + }) + + return { + totalMatchCount, + fileMatches, + } + } + + private createOutput(content: SanitizedRipgrepOutput): InvokeOutput { + return { + output: { + kind: OutputKind.Json, + content: content, + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index 300d357a9fa..6fa08df39ed 100644 --- a/packages/core/src/codewhispererChat/tools/toolUtils.ts +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -2,7 +2,6 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import * as vscode from 'vscode' import { Writable } from 'stream' import { FsRead, FsReadParams } from './fsRead' import { FsWrite, FsWriteParams } from './fsWrite' @@ -15,12 +14,17 @@ import { fsReadToolResponseSize, } from './toolShared' import { ListDirectory, ListDirectoryParams } from './listDirectory' +import { GrepSearch, GrepSearchParams } from './grepSearch' +import * as vscode from 'vscode' +import { FileSearch, FileSearchParams } from './fileSearch' export enum ToolType { FsRead = 'fsRead', FsWrite = 'fsWrite', ExecuteBash = 'executeBash', ListDirectory = 'listDirectory', + GrepSearch = 'grepSearch', + FileSearch = 'fileSearch', } export type Tool = @@ -28,6 +32,8 @@ export type Tool = | { type: ToolType.FsWrite; tool: FsWrite } | { type: ToolType.ExecuteBash; tool: ExecuteBash } | { type: ToolType.ListDirectory; tool: ListDirectory } + | { type: ToolType.GrepSearch; tool: GrepSearch } + | { type: ToolType.FileSearch; tool: FileSearch } export class ToolUtils { static displayName(tool: Tool): string { @@ -40,6 +46,10 @@ export class ToolUtils { return 'Execute shell command' case ToolType.ListDirectory: return 'List directory from filesystem' + case ToolType.GrepSearch: + return 'Run Fast text-based regex search' + case ToolType.FileSearch: + return `Search for files in a directory` } } @@ -53,6 +63,10 @@ export class ToolUtils { return tool.tool.requiresAcceptance() case ToolType.ListDirectory: return tool.tool.requiresAcceptance() + case ToolType.GrepSearch: + return { requiresAcceptance: false } + case ToolType.FileSearch: + return tool.tool.requiresAcceptance() } } @@ -75,6 +89,10 @@ export class ToolUtils { return tool.tool.invoke(updates ?? undefined, cancellationToken) case ToolType.ListDirectory: return tool.tool.invoke(updates) + case ToolType.GrepSearch: + return tool.tool.invoke(updates) + case ToolType.FileSearch: + return tool.tool.invoke(updates) } } @@ -109,6 +127,12 @@ export class ToolUtils { case ToolType.ListDirectory: tool.tool.queueDescription(updates, requiresAcceptance) break + case ToolType.GrepSearch: + tool.tool.queueDescription(updates) + break + case ToolType.FileSearch: + tool.tool.queueDescription(updates) + break } } @@ -122,6 +146,10 @@ export class ToolUtils { return tool.tool.validate() case ToolType.ListDirectory: return tool.tool.validate() + case ToolType.GrepSearch: + return tool.tool.validate() + case ToolType.FileSearch: + return tool.tool.validate() } } @@ -159,6 +187,16 @@ export class ToolUtils { type: ToolType.ListDirectory, tool: new ListDirectory(value.input as unknown as ListDirectoryParams), } + case ToolType.GrepSearch: + return { + type: ToolType.GrepSearch, + tool: new GrepSearch(value.input as unknown as GrepSearchParams), + } + case ToolType.FileSearch: + return { + type: ToolType.FileSearch, + tool: new FileSearch(value.input as unknown as FileSearchParams), + } default: return { toolUseId: value.toolUseId, diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 4467aa379f0..0df775cdc52 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -98,5 +98,65 @@ }, "required": ["path"] } + }, + "grepSearch": { + "name": "grepSearch", + "description": "Fast text-based regex search that finds exact pattern matches within files or directories, utilizing the ripgrep command for efficient searching.\\nResults will be formatted in the style of ripgrep and can be configured to include line numbers and content.\\nTo avoid overwhelming output, the results are capped at 50 matches.\\nUse the include or exclude patterns to filter the search scope by file type or specific paths.\\n\\nThis is best for finding exact text matches or regex patterns.\\nMore precise than semantic search for finding specific strings or patterns.\\nThis is preferred over semantic search when we know the exact symbol/function name/etc. to search in some set of directories/file types.", + "inputSchema": { + "type": "object", + "properties": { + "caseSensitive": { + "description": "Whether the search should be case sensitive", + "type": "boolean" + }, + "excludePattern": { + "description": "Glob pattern for files to exclude", + "type": "string" + }, + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, + "includePattern": { + "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", + "type": "string" + }, + "query": { + "description": "The regex pattern to search for", + "type": "string" + }, + "path": { + "description": "Absolute path to a directory, e.g., `/repo`.", + "type": "string" + } + }, + "required": ["query"] + } + }, + "fileSearch": { + "name": "fileSearch", + "description": "Search for files in a directory and its subdirectories that match a regex pattern.\n * This tool returns a list of files that match the specified regex pattern.\n * Use this tool when you need to find files with names matching a specific pattern.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "description": "Absolute path to a directory, e.g., `/repo`.", + "type": "string" + }, + "pattern": { + "description": "Regex pattern to match against file names.", + "type": "string" + }, + "maxDepth": { + "description": "Maximum depth to traverse when searching directories. Use `0` to search only the specified directory, `1` to include immediate subdirectories, etc. If it's not provided, it will search all subdirectories recursively.", + "type": "integer" + }, + "caseSensitive": { + "description": "Whether the pattern matching should be case sensitive. Defaults to false.", + "type": "boolean" + } + }, + "required": ["path", "pattern"] + } } } diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 5786799ae99..0986173fb6e 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -19,7 +19,9 @@ export type LogTopic = | 'fsRead' | 'fsWrite' | 'executeBash' + | 'grepSearch' | 'listDirectory' + | 'fileSearch' | 'chatStream' | 'chatHistoryDb' | 'unknown' diff --git a/packages/core/src/test/codewhispererChat/tools/fileSearch.test.ts b/packages/core/src/test/codewhispererChat/tools/fileSearch.test.ts new file mode 100644 index 00000000000..084c8c631e9 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/fileSearch.test.ts @@ -0,0 +1,351 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { FileSearch, FileSearchParams } from '../../../codewhispererChat/tools/fileSearch' +import { Writable } from 'stream' +import { OutputKind } from '../../../codewhispererChat/tools/toolShared' +import fs from '../../../shared/fs/fs' +import * as workspaceUtils from '../../../shared/utilities/workspaceUtils' +import * as filesystemUtilities from '../../../shared/filesystemUtilities' + +describe('FileSearch', function () { + let sandbox: sinon.SinonSandbox + let mockUpdates: Writable + const mockWorkspacePath = '/mock/workspace' + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Create a mock Writable stream for updates + mockUpdates = new Writable({ + write: (chunk, encoding, callback) => { + callback() + }, + }) + sandbox.spy(mockUpdates, 'write') + sandbox.spy(mockUpdates, 'end') + + // Mock workspace folders + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ + { + uri: { fsPath: mockWorkspacePath } as vscode.Uri, + name: 'mockWorkspace', + index: 0, + }, + ]) + + // Mock fs.existsDir to always return true + sandbox.stub(fs, 'existsDir').resolves(true) + + // Mock readDirectoryRecursively + sandbox + .stub(workspaceUtils, 'readDirectoryRecursively') + .resolves([ + 'F /mock/workspace/file1.ts', + 'F /mock/workspace/file2.js', + 'F /mock/workspace/subfolder/file3.ts', + 'F /mock/workspace/subfolder/file4.json', + 'D /mock/workspace/subfolder', + ]) + + // Mock isInDirectory + sandbox.stub(filesystemUtilities, 'isInDirectory').returns(true) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize with provided values', function () { + const params: FileSearchParams = { + path: '/test/path', + pattern: '.*\\.ts$', + maxDepth: 2, + caseSensitive: true, + } + + const fileSearch = new FileSearch(params) + + assert.strictEqual((fileSearch as any).fsPath, '/test/path') + assert.strictEqual((fileSearch as any).pattern.source, '.*\\.ts$') + assert.strictEqual((fileSearch as any).maxDepth, 2) + // Check that the RegExp was created with case sensitivity + assert.strictEqual((fileSearch as any).pattern.flags, '') + }) + + it('should initialize with case insensitive pattern by default', function () { + const params: FileSearchParams = { + path: '/test/path', + pattern: '.*\\.ts$', + } + + const fileSearch = new FileSearch(params) + + assert.strictEqual((fileSearch as any).pattern.flags, 'i') + }) + }) + + describe('validate', function () { + it('should throw an error if path is empty', async function () { + const fileSearch = new FileSearch({ path: '', pattern: '.*\\.ts$' }) + await assert.rejects(async () => await fileSearch.validate(), /Path cannot be empty/) + }) + + it('should throw an error if path is only whitespace', async function () { + const fileSearch = new FileSearch({ path: ' ', pattern: '.*\\.ts$' }) + await assert.rejects(async () => await fileSearch.validate(), /Path cannot be empty/) + }) + + it('should throw an error if maxDepth is negative', async function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + maxDepth: -1, + }) + await assert.rejects(async () => await fileSearch.validate(), /MaxDepth cannot be negative/) + }) + + it('should throw an error if path does not exist', async function () { + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(fs, 'existsDir').resolves(false) + + const fileSearch = new FileSearch({ + path: '/non/existent/path', + pattern: '.*\\.ts$', + }) + + await assert.rejects( + async () => await fileSearch.validate(), + /Path: "\/non\/existent\/path" does not exist or cannot be accessed/ + ) + }) + + it('should pass validation with valid path and pattern', async function () { + const fileSearch = new FileSearch({ + path: '/valid/path', + pattern: '.*\\.ts$', + }) + await assert.doesNotReject(async () => await fileSearch.validate()) + }) + }) + + describe('queueDescription', function () { + it('should write description for recursive search', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + + fileSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Searching for files matching pattern: /.*\\.ts$/i in path recursively` + ) + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledOnce(mockUpdates.end as sinon.SinonSpy) + }) + + it('should write description for current directory only', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + maxDepth: 0, + }) + + fileSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Searching for files matching pattern: /.*\\.ts$/i in path` + ) + }) + + it('should write description for limited depth search', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + maxDepth: 1, + }) + + fileSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Searching for files matching pattern: /.*\\.ts$/i in path limited to 1 subfolder level` + ) + }) + + it('should use plural form for multiple levels', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + maxDepth: 3, + }) + + fileSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Searching for files matching pattern: /.*\\.ts$/i in path limited to 3 subfolder levels` + ) + }) + }) + + describe('requiresAcceptance', function () { + it('should require acceptance when no workspace folders exist', function () { + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(vscode.workspace, 'workspaceFolders').value(undefined) + + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + + const result = fileSearch.requiresAcceptance() + assert.strictEqual(result.requiresAcceptance, true) + }) + + it('should require acceptance when path is outside workspace', function () { + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ + { + uri: { fsPath: '/workspace' } as vscode.Uri, + name: 'workspace', + index: 0, + }, + ]) + sandbox.stub(filesystemUtilities, 'isInDirectory').returns(false) + + const fileSearch = new FileSearch({ + path: '/outside/workspace', + pattern: '.*\\.ts$', + }) + + const result = fileSearch.requiresAcceptance() + assert.strictEqual(result.requiresAcceptance, true) + }) + + it('should not require acceptance when path is inside workspace', function () { + const fileSearch = new FileSearch({ + path: '/mock/workspace/subfolder', + pattern: '.*\\.ts$', + }) + + const result = fileSearch.requiresAcceptance() + assert.strictEqual(result.requiresAcceptance, false) + }) + }) + + describe('invoke', function () { + let fileSearch: FileSearch + + beforeEach(async function () { + fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + await fileSearch.validate() + }) + + it('should filter files by regex pattern', async function () { + const result = await fileSearch.invoke() + + // Should only include .ts files + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: 'F /mock/workspace/file1.ts\nF /mock/workspace/subfolder/file3.ts', + }, + }) + }) + + it('should handle case sensitivity correctly', async function () { + // Create a case-sensitive search for .TS (uppercase) + fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.TS$', + caseSensitive: true, + }) + await fileSearch.validate() + + // Should not match any files since our mock files use lowercase .ts + const result = await fileSearch.invoke() + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: '', + }, + }) + }) + + it('should throw an error if file search fails', async function () { + sandbox.restore() + sandbox = sinon.createSandbox() + // Make readDirectoryRecursively throw an error + sandbox.stub(workspaceUtils, 'readDirectoryRecursively').rejects(new Error('Access denied')) + sandbox.stub(fs, 'existsDir').resolves(true) + + fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + + await assert.rejects( + async () => await fileSearch.invoke(), + /Failed to search files in "\/test\/path": Access denied/ + ) + }) + }) + + describe('createOutput', function () { + it('should create output with content', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + + const output = (fileSearch as any).createOutput('test content') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'test content', + }, + }) + }) + + it('should create output with empty content', function () { + const fileSearch = new FileSearch({ + path: '/test/path', + pattern: '.*\\.ts$', + }) + + const output = (fileSearch as any).createOutput('') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: '', + }, + }) + }) + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts new file mode 100644 index 00000000000..3538b973f95 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts @@ -0,0 +1,242 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { GrepSearch, GrepSearchParams } from '../../../codewhispererChat/tools/grepSearch' +import { ChildProcess } from '../../../shared/utilities/processUtils' +import { Writable } from 'stream' +import { OutputKind } from '../../../codewhispererChat/tools/toolShared' +import fs from '../../../shared/fs/fs' + +describe('GrepSearch', function () { + let sandbox: sinon.SinonSandbox + let mockUpdates: Writable + const mockWorkspacePath = '/mock/workspace' + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Create a mock Writable stream for updates + mockUpdates = new Writable({ + write: (chunk, encoding, callback) => { + callback() + }, + }) + sandbox.spy(mockUpdates, 'write') + sandbox.spy(mockUpdates, 'end') + + // Mock workspace folders + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ + { + uri: { fsPath: mockWorkspacePath } as vscode.Uri, + name: 'mockWorkspace', + index: 0, + }, + ]) + + // Mock fs.existsDir to always return true + sandbox.stub(fs, 'existsDir').resolves(true) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize with default values', function () { + const params: GrepSearchParams = { + query: 'test-query', + } + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, false) + assert.strictEqual((grepSearch as any).excludePattern, undefined) + assert.strictEqual((grepSearch as any).includePattern, undefined) + assert.strictEqual((grepSearch as any).path, mockWorkspacePath) + }) + + it('should initialize with provided values', function () { + const params: GrepSearchParams = { + query: 'test-query', + caseSensitive: true, + excludePattern: '*.log', + includePattern: '*.ts', + path: '/custom/path', + } + + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, true) + assert.strictEqual((grepSearch as any).excludePattern, '*.log') + assert.strictEqual((grepSearch as any).includePattern, '*.ts') + assert.strictEqual((grepSearch as any).path, '/custom/path') + }) + }) + + describe('getSearchDirectory', function () { + it('should use provided path when available', function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/custom/path', + }) + + const result = (grepSearch as any).getSearchDirectory('/custom/path') + assert.strictEqual(result, '/custom/path') + }) + + it('should use workspace folder when path is not provided', function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const result = (grepSearch as any).getSearchDirectory() + assert.strictEqual(result, mockWorkspacePath) + }) + }) + + describe('validate', function () { + it('should throw an error if query is empty', async function () { + const grepSearch = new GrepSearch({ query: '' }) + await assert.rejects(async () => await grepSearch.validate(), /Grep search query cannot be empty/) + }) + + it('should throw an error if query is only whitespace', async function () { + const grepSearch = new GrepSearch({ query: ' ' }) + + await assert.rejects(async () => await grepSearch.validate(), /Grep search query cannot be empty/) + }) + + it('should throw an error if path does not exist', async function () { + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(fs, 'existsDir').resolves(false) + + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/non/existent/path', + }) + + await assert.rejects( + async () => await grepSearch.validate(), + /Path: "\/non\/existent\/path" does not exist or cannot be accessed/ + ) + }) + + it('should pass validation with valid query and path', async function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/valid/path', + }) + await assert.doesNotReject(async () => await grepSearch.validate()) + }) + }) + + describe('queueDescription', function () { + it('should write description to updates stream', function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + + grepSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Grepping for "test-query" in directory: /test/path` + ) + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledOnce(mockUpdates.end as sinon.SinonSpy) + }) + }) + + describe('invoke', function () { + let grepSearch: GrepSearch + + beforeEach(async function () { + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + await grepSearch.validate() + // Setup ChildProcess run method + const mockRun = sandbox.stub() + mockRun.resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + // Mock processRipgrepOutput + sandbox.stub(grepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + }) + + it('should execute ripgrep and return results', async function () { + const result = await grepSearch.invoke() + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: 'processed-results', + }, + }) + }) + + it('should write updates to the provided stream', async function () { + await grepSearch.invoke(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith(mockUpdates.write as sinon.SinonSpy, 'processed-results') + }) + + it('should throw an error if ripgrep execution fails', async function () { + sandbox.restore() + sandbox = sinon.createSandbox() + // Make ChildProcess.run throw an error + sandbox.stub(ChildProcess.prototype, 'run').rejects(new Error('Command failed')) + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + await assert.rejects(async () => await grepSearch.invoke(), /Failed to search/) + }) + }) + + describe('createOutput', function () { + it('should create output with content', function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const output = (grepSearch as any).createOutput('test content') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'test content', + }, + }) + }) + + it('should create output with default message when content is empty', function () { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const output = (grepSearch as any).createOutput('') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'No matches found.', + }, + }) + }) + }) +})