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..8c0a712225c 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -13,6 +13,8 @@ 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/' + # GITHUB_TOKEN should be set in the CodeBuild project environment variables + # or passed as a parameter to the build phases: install: @@ -25,6 +27,8 @@ phases: pre_build: commands: - export HOME=/home/codebuild-user + # produce an API key, set the GITHUB_TOKEN environment var, and vscode-ripgrep will pick it up + - export GITHUB_TOKEN=${GITHUB_TOKEN} - bash buildspec/shared/linux-pre_build.sh build: diff --git a/buildspec/packageTestVsix.yml b/buildspec/packageTestVsix.yml index 73f15c0ba10..07a9572fa66 100644 --- a/buildspec/packageTestVsix.yml +++ b/buildspec/packageTestVsix.yml @@ -20,6 +20,7 @@ phases: pre_build: commands: - export HOME=/home/codebuild-user + - export GITHUB_TOKEN=${GITHUB_READONLY_TOKEN} - bash buildspec/shared/linux-pre_build.sh build: diff --git a/buildspec/shared/linux-pre_build.sh b/buildspec/shared/linux-pre_build.sh index 102103ff30c..1cfd208471c 100644 --- a/buildspec/shared/linux-pre_build.sh +++ b/buildspec/shared/linux-pre_build.sh @@ -11,6 +11,13 @@ _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Include common functions. . "${_SCRIPT_DIR}/common.sh" +# Set up GitHub token for vscode-ripgrep to avoid rate limiting +if [ -n "$GITHUB_TOKEN" ]; then + echo "GITHUB_TOKEN is set. vscode-ripgrep will use this for GitHub API authentication." +else + echo "WARNING: GITHUB_TOKEN is not set. GitHub API requests may be rate-limited." +fi + # If present, log into CodeArtifact. Provides a fallback in case NPM is down. # Should only affect tests run through Toolkits-hosted CodeBuild. if [ "$TOOLKITS_CODEARTIFACT_DOMAIN" ] && [ "$TOOLKITS_CODEARTIFACT_REPO" ] && [ "$TOOLKITS_ACCOUNT_ID" ]; then diff --git a/package-lock.json b/package-lock.json index ee2c9263304..7f20f83c89b 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 944c3d0f926..e1e18b25ebe 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/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts new file mode 100644 index 00000000000..21f0f2502c8 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -0,0 +1,240 @@ +/*! + * 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 +} + +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(`Grepping for "${this.query}" in directory: ${this.path}`) + updates.end() + } + + 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') + + // 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 remove matched content + const { sanitizedOutput, totalMatchCount } = this.processRipgrepOutput(result.stdout) + + // If updates is provided, write the processed output + if (updates) { + updates.write(`\n\n${totalMatchCount} matches found:\n\n`) + updates.write(sanitizedOutput) + } + + this.logger.info(`Processed ripgrep result: ${totalMatchCount} matches found`) + resolve(sanitizedOutput) + } catch (err) { + reject(err) + } + }) + } + + /** + * Process ripgrep output to: + * 1. Group results by file + * 2. Format as collapsible sections + * 3. Add file URLs for clickable links + * @returns An object containing the processed output and total match count + */ + private processRipgrepOutput(output: string): { sanitizedOutput: string; totalMatchCount: number } { + if (!output || output.trim() === '') { + return { sanitizedOutput: 'No matches found.', totalMatchCount: 0 } + } + + 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 and line number + const parts = line.split(':') + if (parts.length < 2) { + continue + } + + const filePath = parts[0] + const lineNumber = parts[1] + // Don't include match content + + if (!fileGroups[filePath]) { + fileGroups[filePath] = [] + } + + // Create a clickable link with line number using VS Code's Uri.with() method + const uri = vscode.Uri.file(filePath) + // Use the with() method to add the line number as a fragment + const uriWithLine = uri.with({ fragment: `L${lineNumber}` }) + fileGroups[filePath].push(`- [Line ${lineNumber}](${uriWithLine.toString(true)})`) + totalMatchCount++ + } + + // Sort files by match count (most matches first) + const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].length - a[1].length) + + // Format as collapsible sections + const sanitizedOutput = sortedFiles + .map(([filePath, matches]) => { + const fileName = path.basename(filePath) + const matchCount = matches.length + + return `
+ ${fileName} - match count: (${matchCount}) + +${matches.join('\n')} +
` + }) + .join('\n\n') + + return { sanitizedOutput, totalMatchCount } + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content || 'No matches found.', + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index 300d357a9fa..cfb6f5db162 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,15 @@ import { fsReadToolResponseSize, } from './toolShared' import { ListDirectory, ListDirectoryParams } from './listDirectory' +import { GrepSearch, GrepSearchParams } from './grepSearch' +import * as vscode from 'vscode' export enum ToolType { FsRead = 'fsRead', FsWrite = 'fsWrite', ExecuteBash = 'executeBash', ListDirectory = 'listDirectory', + GrepSearch = 'grepSearch', } export type Tool = @@ -28,6 +30,7 @@ export type Tool = | { type: ToolType.FsWrite; tool: FsWrite } | { type: ToolType.ExecuteBash; tool: ExecuteBash } | { type: ToolType.ListDirectory; tool: ListDirectory } + | { type: ToolType.GrepSearch; tool: GrepSearch } export class ToolUtils { static displayName(tool: Tool): string { @@ -40,6 +43,8 @@ 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' } } @@ -53,6 +58,8 @@ export class ToolUtils { return tool.tool.requiresAcceptance() case ToolType.ListDirectory: return tool.tool.requiresAcceptance() + case ToolType.GrepSearch: + return { requiresAcceptance: false } } } @@ -75,6 +82,8 @@ 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) } } @@ -109,6 +118,9 @@ export class ToolUtils { case ToolType.ListDirectory: tool.tool.queueDescription(updates, requiresAcceptance) break + case ToolType.GrepSearch: + tool.tool.queueDescription(updates) + break } } @@ -122,6 +134,8 @@ export class ToolUtils { return tool.tool.validate() case ToolType.ListDirectory: return tool.tool.validate() + case ToolType.GrepSearch: + return tool.tool.validate() } } @@ -159,6 +173,11 @@ 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), + } 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..0cd6481604d 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -98,5 +98,39 @@ }, "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"] + } } } diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 5786799ae99..336e0a80978 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -19,6 +19,7 @@ export type LogTopic = | 'fsRead' | 'fsWrite' | 'executeBash' + | 'grepSearch' | 'listDirectory' | 'chatStream' | 'chatHistoryDb' 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..bd865692301 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts @@ -0,0 +1,324 @@ +/*! + * 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', () => { + let sandbox: sinon.SinonSandbox + let mockUpdates: Writable + const mockWorkspacePath = '/mock/workspace' + + beforeEach(() => { + 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(() => { + sandbox.restore() + }) + + describe('constructor', () => { + it('should initialize with default values', () => { + 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', () => { + 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', () => { + it('should use provided path when available', () => { + 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', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const result = (grepSearch as any).getSearchDirectory() + assert.strictEqual(result, mockWorkspacePath) + }) + }) + + describe('validate', () => { + it('should throw an error if query is empty', async () => { + 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 () => { + 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 () => { + 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 () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/valid/path', + }) + await assert.doesNotReject(async () => await grepSearch.validate()) + }) + }) + + describe('queueDescription', () => { + it('should write description to updates stream', () => { + 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', () => { + let grepSearch: GrepSearch + beforeEach(async () => { + 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 () => { + const result = await grepSearch.invoke() + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: 'processed-results', + }, + }) + }) + + it('should write updates to the provided stream', async () => { + await grepSearch.invoke(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith(mockUpdates.write as sinon.SinonSpy, '\n\n5 matches found:\n\n') + // 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 () => { + 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('processRipgrepOutput', () => { + let grepSearch: GrepSearch + + beforeEach(() => { + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + + // Mock vscode.Uri.file and with + sandbox.stub(vscode.Uri, 'file').callsFake((filePath) => { + return { + with: (options: any) => { + return { + toString: () => `file://${filePath}#${options.fragment}`, + } + }, + toString: () => `file://${filePath}`, + } as any + }) + }) + + it('should handle empty output', () => { + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput('') + + assert.strictEqual(sanitizedOutput, 'No matches found.') + assert.strictEqual(totalMatchCount, 0) + }) + + it('should process ripgrep output and group by file', () => { + const mockOutput = + '/test/file1.ts:10:some match content\n' + + '/test/file1.ts:20:another match\n' + + '/test/file2.ts:5:match in another file' + + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput(mockOutput) + + assert.strictEqual(totalMatchCount, 3) + + // Check that output contains details tags + assert.ok(sanitizedOutput.includes('
')) + assert.ok(sanitizedOutput.includes('
')) + + // Check that output contains file names + assert.ok(sanitizedOutput.includes('file1.ts - match count: (2)')) + assert.ok(sanitizedOutput.includes('file2.ts - match count: (1)')) + + // Check that output contains line numbers as links + assert.ok(sanitizedOutput.includes('[Line 10]')) + assert.ok(sanitizedOutput.includes('[Line 20]')) + assert.ok(sanitizedOutput.includes('[Line 5]')) + + // Check that files are sorted by match count (most matches first) + const file1Index = sanitizedOutput.indexOf('file1.ts') + const file2Index = sanitizedOutput.indexOf('file2.ts') + assert.ok(file1Index < file2Index, 'Files should be sorted by match count') + }) + + it('should handle malformed output lines', () => { + const mockOutput = + '/test/file1.ts:10:some match content\n' + + 'malformed line without colon\n' + + '/test/file2.ts:5:match in another file' + + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput(mockOutput) + + assert.strictEqual(totalMatchCount, 2) + + // Check that output contains details tags + assert.ok(sanitizedOutput.includes('
')) + assert.ok(sanitizedOutput.includes('
')) + + // Check that output contains file names + assert.ok(sanitizedOutput.includes('file1.ts - match count: (1)')) + assert.ok(sanitizedOutput.includes('file2.ts - match count: (1)')) + + // Check that malformed line was skipped + assert.ok(!sanitizedOutput.includes('malformed')) + }) + }) + + describe('createOutput', () => { + it('should create output with content', () => { + 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', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const output = (grepSearch as any).createOutput('') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'No matches found.', + }, + }) + }) + }) +})