Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"pretty-quick": "^4.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"vscode-ripgrep": "^1.13.2",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.2",
Expand Down
5 changes: 5 additions & 0 deletions packages/amazonq/scripts/build/copyFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
214 changes: 214 additions & 0 deletions packages/core/src/codewhispererChat/tools/grepSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/*!
* 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 fsPath: string | undefined
private query: string
private caseSensitive: boolean
private excludePattern?: string
private includePattern?: string
private readonly logger = getLogger('grepSearch')

constructor(params: GrepSearchParams) {
this.fsPath = params.path
this.query = params.query
this.caseSensitive = params.caseSensitive ?? false
this.excludePattern = params.excludePattern
this.includePattern = params.includePattern
}

public async validate(): Promise<void> {
if (!this.query || this.query.trim().length === 0) {
throw new Error('Grep search query cannot be empty.')
}

// Handle optional path parameter
if (!this.fsPath || this.fsPath.trim().length === 0) {
// Use current workspace folder as default if path is not provided
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
throw new Error('Path cannot be empty and no workspace folder is available.')
}
this.fsPath = workspaceFolders[0].uri.fsPath
this.logger.debug(`Using default workspace folder: ${this.fsPath}`)
}

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 searchDirectory = this.getSearchDirectory(this.fsPath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we extract this outside, this is being called multiple times

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

updates.write(`Grepping for "${this.query}" in directory: ${searchDirectory}`)
updates.end()
}

public async invoke(updates?: Writable): Promise<InvokeOutput> {
const searchDirectory = this.getSearchDirectory(this.fsPath)
try {
const results = await this.executeRipgrep(updates)
return this.createOutput(results)
} catch (error: any) {
this.logger.error(`Failed to search in "${searchDirectory}": ${error.message || error}`)
throw new Error(`Failed to search in "${searchDirectory}": ${error.message || error}`)
}
}

private getSearchDirectory(fsPath?: string): string {
const workspaceFolders = vscode.workspace.workspaceFolders
const searchLocation = fsPath
? fsPath
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fsPath ??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

: !workspaceFolders || workspaceFolders.length === 0
? ''
: workspaceFolders[0].uri.fsPath
return searchLocation
}

private async executeRipgrep(updates?: Writable): Promise<string> {
const searchDirectory = this.getSearchDirectory(this.fsPath)
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, searchDirectory)

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}`)
}
return new Error()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some message for this error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't throw error when the code equal to 0 and 1(no matching), removed this 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 processedOutput = this.processRipgrepOutput(result.stdout)

// If updates is provided, write the processed output
if (updates) {
updates.write('\n\nGreped Results:\n\n')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we remove this? Or are we planning to show this in the UI?

updates.write(processedOutput)
}

this.logger.info(`Processed ripgrep result: ${processedOutput}`)
resolve(processedOutput)
} catch (err) {
reject(err)
}
})
}

/**
* Process ripgrep output to:
* 1. Remove matched content (keep only file:line)
* 2. Add file URLs for clickable links
*/
private processRipgrepOutput(output: string): string {
if (!output || output.trim() === '') {
return 'No matches found.'
}

const lines = output.split('\n')
const processedLines = lines
.map((line) => {
if (!line || line.trim() === '') {
return ''
}

// Extract file path and line number
const parts = line.split(':')
if (parts.length < 2) {
return line
}

const filePath = parts[0]
const lineNumber = parts[1]

const fileName = path.basename(filePath)
const fileUri = vscode.Uri.file(filePath)

// Format as a markdown link
return `[${fileName}:${lineNumber}](${fileUri}:${lineNumber})`
})
.filter(Boolean)

return processedLines.join('\n')
}

private createOutput(content: string): InvokeOutput {
return {
output: {
kind: OutputKind.Text,
content: content || 'No matches found.',
},
}
}
}
19 changes: 19 additions & 0 deletions packages/core/src/codewhispererChat/tools/toolUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ import { CommandValidation, ExecuteBash, ExecuteBashParams } from './executeBash
import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming'
import { InvokeOutput, maxToolResponseSize } from './toolShared'
import { ListDirectory, ListDirectoryParams } from './listDirectory'
import { GrepSearch, GrepSearchParams } from './grepSearch'

export enum ToolType {
FsRead = 'fsRead',
FsWrite = 'fsWrite',
ExecuteBash = 'executeBash',
ListDirectory = 'listDirectory',
GrepSearch = 'grepSearch',
}

export type Tool =
| { type: ToolType.FsRead; tool: FsRead }
| { 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 {
Expand All @@ -34,6 +37,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'
}
}

Expand All @@ -47,6 +52,8 @@ export class ToolUtils {
return tool.tool.requiresAcceptance()
case ToolType.ListDirectory:
return { requiresAcceptance: false }
case ToolType.GrepSearch:
return { requiresAcceptance: false }
}
}

Expand All @@ -60,6 +67,8 @@ export class ToolUtils {
return tool.tool.invoke(updates ?? undefined)
case ToolType.ListDirectory:
return tool.tool.invoke(updates)
case ToolType.GrepSearch:
return tool.tool.invoke(updates)
}
}

Expand All @@ -83,6 +92,9 @@ export class ToolUtils {
case ToolType.ListDirectory:
tool.tool.queueDescription(updates)
break
case ToolType.GrepSearch:
tool.tool.queueDescription(updates)
break
}
}

Expand All @@ -96,6 +108,8 @@ export class ToolUtils {
return tool.tool.validate()
case ToolType.ListDirectory:
return tool.tool.validate()
case ToolType.GrepSearch:
return tool.tool.validate()
}
}

Expand Down Expand Up @@ -133,6 +147,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,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/codewhispererChat/tools/tool_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,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"]
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/shared/logger/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type LogTopic =
| 'fsRead'
| 'fsWrite'
| 'executeBash'
| 'grepSearch'
| 'listDirectory'
| 'chatStream'
| 'chatHistoryDb'
Expand Down
Loading
Loading