Skip to content

Commit 24c4e65

Browse files
committed
Adding the grep search tool.
1. Implemented the tool using https://github.com/microsoft/vscode-ripgrep. 2. The grepped result show as clickable file path. 3. The tool currently is disable in current pr, once grepSearch tool tested, will update the tool_index.json to enable this tool.
1 parent 8b53de4 commit 24c4e65

File tree

7 files changed

+530
-0
lines changed

7 files changed

+530
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"pretty-quick": "^4.1.1",
6868
"ts-node": "^10.9.1",
6969
"typescript": "^5.0.4",
70+
"vscode-ripgrep": "^1.13.2",
7071
"webpack": "^5.95.0",
7172
"webpack-cli": "^5.1.4",
7273
"webpack-dev-server": "^4.15.2",

packages/amazonq/scripts/build/copyFiles.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ const tasks: CopyTask[] = [
6060
target: path.join('../../node_modules', 'web-tree-sitter', 'tree-sitter.wasm'),
6161
destination: path.join('src', 'tree-sitter.wasm'),
6262
},
63+
// ripgrep binary
64+
{
65+
target: path.join('../../node_modules', 'vscode-ripgrep', 'bin'),
66+
destination: 'bin/',
67+
},
6368
]
6469

6570
function copy(task: CopyTask): void {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
import { getLogger } from '../../shared/logger/logger'
7+
import { sanitizePath, InvokeOutput, OutputKind } from './toolShared'
8+
import fs from '../../shared/fs/fs'
9+
import { Writable } from 'stream'
10+
import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils'
11+
import { rgPath } from 'vscode-ripgrep'
12+
import path from 'path'
13+
14+
export interface GrepSearchParams {
15+
path?: string
16+
query: string
17+
caseSensitive?: boolean
18+
excludePattern?: string
19+
includePattern?: string
20+
explanation?: string
21+
}
22+
23+
export class GrepSearch {
24+
private fsPath: string | undefined
25+
private query: string
26+
private caseSensitive: boolean
27+
private excludePattern?: string
28+
private includePattern?: string
29+
private readonly logger = getLogger('grepSearch')
30+
31+
constructor(params: GrepSearchParams) {
32+
this.fsPath = params.path
33+
this.query = params.query
34+
this.caseSensitive = params.caseSensitive ?? false
35+
this.excludePattern = params.excludePattern
36+
this.includePattern = params.includePattern
37+
}
38+
39+
public async validate(): Promise<void> {
40+
if (!this.query || this.query.trim().length === 0) {
41+
throw new Error('Grep search query cannot be empty.')
42+
}
43+
44+
// Handle optional path parameter
45+
if (!this.fsPath || this.fsPath.trim().length === 0) {
46+
// Use current workspace folder as default if path is not provided
47+
const workspaceFolders = vscode.workspace.workspaceFolders
48+
if (!workspaceFolders || workspaceFolders.length === 0) {
49+
throw new Error('Path cannot be empty and no workspace folder is available.')
50+
}
51+
this.fsPath = workspaceFolders[0].uri.fsPath
52+
this.logger.debug(`Using default workspace folder: ${this.fsPath}`)
53+
}
54+
55+
const sanitized = sanitizePath(this.fsPath)
56+
this.fsPath = sanitized
57+
58+
const pathUri = vscode.Uri.file(this.fsPath)
59+
let pathExists: boolean
60+
try {
61+
pathExists = await fs.existsDir(pathUri)
62+
if (!pathExists) {
63+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`)
64+
}
65+
} catch (err) {
66+
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`)
67+
}
68+
}
69+
70+
public queueDescription(updates: Writable): void {
71+
const searchDirectory = this.getSearchDirectory(this.fsPath)
72+
updates.write(`Grepping for "${this.query}" in directory: ${searchDirectory}`)
73+
updates.end()
74+
}
75+
76+
public async invoke(updates?: Writable): Promise<InvokeOutput> {
77+
const searchDirectory = this.getSearchDirectory(this.fsPath)
78+
try {
79+
const results = await this.executeRipgrep(updates)
80+
return this.createOutput(results)
81+
} catch (error: any) {
82+
this.logger.error(`Failed to search in "${searchDirectory}": ${error.message || error}`)
83+
throw new Error(`Failed to search in "${searchDirectory}": ${error.message || error}`)
84+
}
85+
}
86+
87+
private getSearchDirectory(fsPath?: string): string {
88+
const workspaceFolders = vscode.workspace.workspaceFolders
89+
const searchLocation = fsPath
90+
? fsPath
91+
: !workspaceFolders || workspaceFolders.length === 0
92+
? ''
93+
: workspaceFolders[0].uri.fsPath
94+
return searchLocation
95+
}
96+
97+
private async executeRipgrep(updates?: Writable): Promise<string> {
98+
const searchDirectory = this.getSearchDirectory(this.fsPath)
99+
return new Promise(async (resolve, reject) => {
100+
const args: string[] = []
101+
102+
// Add search options
103+
if (!this.caseSensitive) {
104+
args.push('-i') // Case insensitive search
105+
}
106+
args.push('--line-number') // Show line numbers
107+
108+
// No heading (don't group matches by file)
109+
args.push('--no-heading')
110+
111+
// Don't use color in output
112+
args.push('--color', 'never')
113+
114+
// Add include/exclude patterns
115+
if (this.includePattern) {
116+
// Support multiple include patterns
117+
const patterns = this.includePattern.split(',')
118+
for (const pattern of patterns) {
119+
args.push('--glob', pattern.trim())
120+
}
121+
}
122+
123+
if (this.excludePattern) {
124+
// Support multiple exclude patterns
125+
const patterns = this.excludePattern.split(',')
126+
for (const pattern of patterns) {
127+
args.push('--glob', `!${pattern.trim()}`)
128+
}
129+
}
130+
131+
// Add search pattern and path
132+
args.push(this.query, searchDirectory)
133+
134+
this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`)
135+
136+
const options: ChildProcessOptions = {
137+
collect: true,
138+
logging: 'yes',
139+
rejectOnErrorCode: (code) => {
140+
if (code !== 0 && code !== 1) {
141+
this.logger.error(`Ripgrep process exited with code ${code}`)
142+
return new Error(`Ripgrep process exited with code ${code}`)
143+
}
144+
return new Error()
145+
},
146+
}
147+
148+
try {
149+
const rg = new ChildProcess(rgPath, args, options)
150+
const result = await rg.run()
151+
this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`)
152+
// Process the output to format with file URLs and remove matched content
153+
const processedOutput = this.processRipgrepOutput(result.stdout)
154+
155+
// If updates is provided, write the processed output
156+
if (updates) {
157+
updates.write('\n\nGreped Results:\n\n')
158+
updates.write(processedOutput)
159+
}
160+
161+
this.logger.info(`Processed ripgrep result: ${processedOutput}`)
162+
resolve(processedOutput)
163+
} catch (err) {
164+
reject(err)
165+
}
166+
})
167+
}
168+
169+
/**
170+
* Process ripgrep output to:
171+
* 1. Remove matched content (keep only file:line)
172+
* 2. Add file URLs for clickable links
173+
*/
174+
private processRipgrepOutput(output: string): string {
175+
if (!output || output.trim() === '') {
176+
return 'No matches found.'
177+
}
178+
179+
const lines = output.split('\n')
180+
const processedLines = lines
181+
.map((line) => {
182+
if (!line || line.trim() === '') {
183+
return ''
184+
}
185+
186+
// Extract file path and line number
187+
const parts = line.split(':')
188+
if (parts.length < 2) {
189+
return line
190+
}
191+
192+
const filePath = parts[0]
193+
const lineNumber = parts[1]
194+
195+
const fileName = path.basename(filePath)
196+
const fileUri = vscode.Uri.file(filePath)
197+
198+
// Format as a markdown link
199+
return `[${fileName}:${lineNumber}](${fileUri}:${lineNumber})`
200+
})
201+
.filter(Boolean)
202+
203+
return processedLines.join('\n')
204+
}
205+
206+
private createOutput(content: string): InvokeOutput {
207+
return {
208+
output: {
209+
kind: OutputKind.Text,
210+
content: content || 'No matches found.',
211+
},
212+
}
213+
}
214+
}

packages/core/src/codewhispererChat/tools/toolUtils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@ import { CommandValidation, ExecuteBash, ExecuteBashParams } from './executeBash
99
import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming'
1010
import { InvokeOutput, maxToolResponseSize } from './toolShared'
1111
import { ListDirectory, ListDirectoryParams } from './listDirectory'
12+
import { GrepSearch, GrepSearchParams } from './grepSearch'
1213

1314
export enum ToolType {
1415
FsRead = 'fsRead',
1516
FsWrite = 'fsWrite',
1617
ExecuteBash = 'executeBash',
1718
ListDirectory = 'listDirectory',
19+
GrepSearch = 'grepSearch',
1820
}
1921

2022
export type Tool =
2123
| { type: ToolType.FsRead; tool: FsRead }
2224
| { type: ToolType.FsWrite; tool: FsWrite }
2325
| { type: ToolType.ExecuteBash; tool: ExecuteBash }
2426
| { type: ToolType.ListDirectory; tool: ListDirectory }
27+
| { type: ToolType.GrepSearch; tool: GrepSearch }
2528

2629
export class ToolUtils {
2730
static displayName(tool: Tool): string {
@@ -34,6 +37,8 @@ export class ToolUtils {
3437
return 'Execute shell command'
3538
case ToolType.ListDirectory:
3639
return 'List directory from filesystem'
40+
case ToolType.GrepSearch:
41+
return 'Run Fast text-based regex search'
3742
}
3843
}
3944

@@ -47,6 +52,8 @@ export class ToolUtils {
4752
return tool.tool.requiresAcceptance()
4853
case ToolType.ListDirectory:
4954
return { requiresAcceptance: false }
55+
case ToolType.GrepSearch:
56+
return { requiresAcceptance: false }
5057
}
5158
}
5259

@@ -60,6 +67,8 @@ export class ToolUtils {
6067
return tool.tool.invoke(updates ?? undefined)
6168
case ToolType.ListDirectory:
6269
return tool.tool.invoke(updates)
70+
case ToolType.GrepSearch:
71+
return tool.tool.invoke(updates)
6372
}
6473
}
6574

@@ -83,6 +92,9 @@ export class ToolUtils {
8392
case ToolType.ListDirectory:
8493
tool.tool.queueDescription(updates)
8594
break
95+
case ToolType.GrepSearch:
96+
tool.tool.queueDescription(updates)
97+
break
8698
}
8799
}
88100

@@ -96,6 +108,8 @@ export class ToolUtils {
96108
return tool.tool.validate()
97109
case ToolType.ListDirectory:
98110
return tool.tool.validate()
111+
case ToolType.GrepSearch:
112+
return tool.tool.validate()
99113
}
100114
}
101115

@@ -133,6 +147,11 @@ export class ToolUtils {
133147
type: ToolType.ListDirectory,
134148
tool: new ListDirectory(value.input as unknown as ListDirectoryParams),
135149
}
150+
case ToolType.GrepSearch:
151+
return {
152+
type: ToolType.GrepSearch,
153+
tool: new GrepSearch(value.input as unknown as GrepSearchParams),
154+
}
136155
default:
137156
return {
138157
toolUseId: value.toolUseId,

packages/core/src/codewhispererChat/tools/tool_index.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,39 @@
9494
},
9595
"required": ["path"]
9696
}
97+
},
98+
"grepSearch": {
99+
"name": "grepSearch",
100+
"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.",
101+
"inputSchema": {
102+
"type": "object",
103+
"properties": {
104+
"caseSensitive": {
105+
"description": "Whether the search should be case sensitive",
106+
"type": "boolean"
107+
},
108+
"excludePattern": {
109+
"description": "Glob pattern for files to exclude",
110+
"type": "string"
111+
},
112+
"explanation": {
113+
"description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
114+
"type": "string"
115+
},
116+
"includePattern": {
117+
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
118+
"type": "string"
119+
},
120+
"query": {
121+
"description": "The regex pattern to search for",
122+
"type": "string"
123+
},
124+
"path": {
125+
"description": "Absolute path to a directory, e.g., `/repo`.",
126+
"type": "string"
127+
}
128+
},
129+
"required": ["query"]
130+
}
97131
}
98132
}

packages/core/src/shared/logger/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type LogTopic =
1919
| 'fsRead'
2020
| 'fsWrite'
2121
| 'executeBash'
22+
| 'grepSearch'
2223
| 'listDirectory'
2324
| 'chatStream'
2425
| 'chatHistoryDb'

0 commit comments

Comments
 (0)