Skip to content

Commit 2bcc168

Browse files
committed
Merge branch 'chat-1' into agentic-chat
2 parents b5cd5c3 + 7e7c567 commit 2bcc168

File tree

8 files changed

+593
-0
lines changed

8 files changed

+593
-0
lines changed

package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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 path: string
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.path = this.getSearchDirectory(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+
if (this.path.trim().length === 0) {
45+
throw new Error('Path cannot be empty and no workspace folder is available.')
46+
}
47+
48+
const sanitized = sanitizePath(this.path)
49+
this.path = sanitized
50+
51+
const pathUri = vscode.Uri.file(this.path)
52+
let pathExists: boolean
53+
try {
54+
pathExists = await fs.existsDir(pathUri)
55+
if (!pathExists) {
56+
throw new Error(`Path: "${this.path}" does not exist or cannot be accessed.`)
57+
}
58+
} catch (err) {
59+
throw new Error(`Path: "${this.path}" does not exist or cannot be accessed. (${err})`)
60+
}
61+
}
62+
63+
public queueDescription(updates: Writable): void {
64+
updates.write(`Grepping for "${this.query}" in directory: ${this.path}`)
65+
updates.end()
66+
}
67+
68+
public async invoke(updates?: Writable): Promise<InvokeOutput> {
69+
try {
70+
const results = await this.executeRipgrep(updates)
71+
return this.createOutput(results)
72+
} catch (error: any) {
73+
this.logger.error(`Failed to search in "${this.path}": ${error.message || error}`)
74+
throw new Error(`Failed to search in "${this.path}": ${error.message || error}`)
75+
}
76+
}
77+
78+
private getSearchDirectory(path?: string): string {
79+
let searchLocation = ''
80+
if (path && path.trim().length !== 0) {
81+
searchLocation = path
82+
} else {
83+
// Handle optional path parameter
84+
// Use current workspace folder as default if path is not provided
85+
const workspaceFolders = vscode.workspace.workspaceFolders
86+
this.logger.info(`Using default workspace folder: ${workspaceFolders?.length}`)
87+
if (workspaceFolders && workspaceFolders.length !== 0) {
88+
searchLocation = workspaceFolders[0].uri.fsPath
89+
this.logger.debug(`Using default workspace folder: ${searchLocation}`)
90+
}
91+
}
92+
return searchLocation
93+
}
94+
95+
private async executeRipgrep(updates?: Writable): Promise<string> {
96+
return new Promise(async (resolve, reject) => {
97+
const args: string[] = []
98+
99+
// Add search options
100+
if (!this.caseSensitive) {
101+
args.push('-i') // Case insensitive search
102+
}
103+
args.push('--line-number') // Show line numbers
104+
105+
// No heading (don't group matches by file)
106+
args.push('--no-heading')
107+
108+
// Don't use color in output
109+
args.push('--color', 'never')
110+
111+
// Add include/exclude patterns
112+
if (this.includePattern) {
113+
// Support multiple include patterns
114+
const patterns = this.includePattern.split(',')
115+
for (const pattern of patterns) {
116+
args.push('--glob', pattern.trim())
117+
}
118+
}
119+
120+
if (this.excludePattern) {
121+
// Support multiple exclude patterns
122+
const patterns = this.excludePattern.split(',')
123+
for (const pattern of patterns) {
124+
args.push('--glob', `!${pattern.trim()}`)
125+
}
126+
}
127+
128+
// Add search pattern and path
129+
args.push(this.query, this.path)
130+
131+
this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`)
132+
133+
const options: ChildProcessOptions = {
134+
collect: true,
135+
logging: 'yes',
136+
rejectOnErrorCode: (code) => {
137+
if (code !== 0 && code !== 1) {
138+
this.logger.error(`Ripgrep process exited with code ${code}`)
139+
return new Error(`Ripgrep process exited with code ${code}`)
140+
}
141+
return new Error()
142+
},
143+
}
144+
145+
try {
146+
const rg = new ChildProcess(rgPath, args, options)
147+
const result = await rg.run()
148+
this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`)
149+
// Process the output to format with file URLs and remove matched content
150+
const { sanitizedOutput, totalMatchCount } = this.processRipgrepOutput(result.stdout)
151+
152+
// If updates is provided, write the processed output
153+
if (updates) {
154+
updates.write(`\n\n(${totalMatchCount} matches found):\n\n`)
155+
updates.write(sanitizedOutput)
156+
}
157+
158+
this.logger.info(`Processed ripgrep result: ${totalMatchCount} matches found`)
159+
resolve(sanitizedOutput)
160+
} catch (err) {
161+
reject(err)
162+
}
163+
})
164+
}
165+
166+
/**
167+
* Process ripgrep output to:
168+
* 1. Group results by file
169+
* 2. Format as collapsible sections
170+
* 3. Add file URLs for clickable links
171+
* @returns An object containing the processed output and total match count
172+
*/
173+
private processRipgrepOutput(output: string): { sanitizedOutput: string; totalMatchCount: number } {
174+
if (!output || output.trim() === '') {
175+
return { sanitizedOutput: 'No matches found.', totalMatchCount: 0 }
176+
}
177+
178+
const lines = output.split('\n')
179+
180+
// Group by file path
181+
const fileGroups: Record<string, string[]> = {}
182+
let totalMatchCount = 0
183+
184+
for (const line of lines) {
185+
if (!line || line.trim() === '') {
186+
continue
187+
}
188+
189+
// Extract file path and line number
190+
const parts = line.split(':')
191+
if (parts.length < 2) {
192+
continue
193+
}
194+
195+
const filePath = parts[0]
196+
const lineNumber = parts[1]
197+
// Don't include match content
198+
199+
if (!fileGroups[filePath]) {
200+
fileGroups[filePath] = []
201+
}
202+
203+
// Create a clickable link with line number using VS Code's Uri.with() method
204+
const uri = vscode.Uri.file(filePath)
205+
// Use the with() method to add the line number as a fragment
206+
const uriWithLine = uri.with({ fragment: `L${lineNumber}` })
207+
fileGroups[filePath].push(`- [Line ${lineNumber}](${uriWithLine.toString(true)})`)
208+
totalMatchCount++
209+
}
210+
211+
// Sort files by match count (most matches first)
212+
const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].length - a[1].length)
213+
214+
// Format as collapsible sections
215+
const sanitizedOutput = sortedFiles
216+
.map(([filePath, matches]) => {
217+
const fileName = path.basename(filePath)
218+
const matchCount = matches.length
219+
220+
return `<details>
221+
<summary><strong>${fileName} - match count: (${matchCount})</strong></summary>
222+
223+
${matches.join('\n')}
224+
</details>`
225+
})
226+
.join('\n\n')
227+
228+
return { sanitizedOutput, totalMatchCount }
229+
}
230+
231+
private createOutput(content: string): InvokeOutput {
232+
return {
233+
output: {
234+
kind: OutputKind.Text,
235+
content: content || 'No matches found.',
236+
},
237+
}
238+
}
239+
}

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 tool.tool.requiresAcceptance()
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,

0 commit comments

Comments
 (0)