-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathcode-search.ts
More file actions
489 lines (428 loc) · 16 KB
/
Copy pathcode-search.ts
File metadata and controls
489 lines (428 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
import { spawn } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import { formatCodeSearchOutput } from '../../../common/src/util/format-code-search'
import { getBundledRgPath } from '../native/ripgrep'
import type { CodebuffToolOutput } from '../../../common/src/tools/list'
import { Logger } from '@codebuff/common/types/contracts/logger'
// Hidden directories to include in code search by default.
// These are searched in addition to '.' to ensure important config/workflow files are discoverable.
const INCLUDED_HIDDEN_DIRS = [
'.agents', // Codebuff agent definitions
'.claude', // Claude settings
'.github', // GitHub Actions, workflows, issue templates
'.gitlab', // GitLab CI configuration
'.circleci', // CircleCI configuration
'.husky', // Git hooks
]
const HIDDEN_DIRS_CACHE_TTL_MS = 1_000 // 1 second TTL (burst de-duplication window)
const MAX_CACHE_SIZE = 100
const hiddenDirsCache = new Map<string, { dirs: string[]; timestamp: number }>()
export function getExistingHiddenDirs(
searchCwd: string,
now = Date.now(),
): string[] {
const normalizedCwd = path.resolve(searchCwd)
const cached = hiddenDirsCache.get(normalizedCwd)
if (cached && now - cached.timestamp < HIDDEN_DIRS_CACHE_TTL_MS) {
// Refresh LRU recency
hiddenDirsCache.delete(normalizedCwd)
hiddenDirsCache.set(normalizedCwd, cached)
return cached.dirs
}
const existingHiddenDirs = INCLUDED_HIDDEN_DIRS.filter((dir) => {
try {
return fs.statSync(path.join(normalizedCwd, dir)).isDirectory()
} catch {
return false
}
})
if (hiddenDirsCache.has(normalizedCwd)) {
hiddenDirsCache.delete(normalizedCwd)
} else if (hiddenDirsCache.size >= MAX_CACHE_SIZE) {
const oldestKey = hiddenDirsCache.keys().next().value
if (oldestKey !== undefined) {
hiddenDirsCache.delete(oldestKey)
}
}
hiddenDirsCache.set(normalizedCwd, {
dirs: existingHiddenDirs,
timestamp: now,
})
return existingHiddenDirs
}
export function clearHiddenDirsCache(dir?: string): void {
if (dir) {
hiddenDirsCache.delete(path.resolve(dir))
} else {
hiddenDirsCache.clear()
}
}
export function codeSearch({
projectPath,
pattern,
flags,
cwd,
maxResults = 15,
globalMaxResults = 250,
maxOutputStringLength = 20_000,
timeoutSeconds = 10,
logger,
signal,
}: {
projectPath: string
pattern: string
flags?: string
cwd?: string
maxResults?: number
globalMaxResults?: number
maxOutputStringLength?: number
timeoutSeconds?: number
logger?: Logger
/** External abort (e.g. user interrupt); kills the ripgrep process. */
signal?: AbortSignal
}): Promise<CodebuffToolOutput<'code_search'>> {
return new Promise((resolve) => {
let isResolved = false
// Resolve the search directory: absolute `cwd` is honored as-is, relative
// `cwd` is resolved against the project root. Searches may target any
// directory on the system.
const projectRoot = path.resolve(projectPath)
const searchCwd = cwd ? path.resolve(projectRoot, cwd) : projectRoot
// Parse flags - do NOT deduplicate to preserve flag-argument pairs like '-g *.ts'
// Deduplicating would break up these pairs and cause errors
// Strip surrounding quotes from each token since spawn() passes args directly
// without shell interpretation (e.g. "'foo.md'" → "foo.md")
const flagsArray = (flags || '')
.split(' ')
.filter(Boolean)
.map((token) => token.replace(/^['"]|['"]$/g, ''))
// Use JSON output for robust parsing and early stopping
// --no-config prevents user/system .ripgreprc from interfering
// -n shows line numbers
// --json outputs in JSON format, which streams in and allows us to cut off the output if it grows too long
// "--"" prevents pattern from being misparsed as a flag (e.g., pattern starting with '-')
// Search paths: '.' plus blessed hidden directories that actually exist
// Filter out non-existent directories to avoid ripgrep stderr errors
const existingHiddenDirs = getExistingHiddenDirs(searchCwd)
const searchPaths = ['.', ...existingHiddenDirs]
const args = [
'--no-config',
'-n',
'--json',
...flagsArray,
'--',
pattern,
...searchPaths,
]
if (signal?.aborted) {
return resolve([
{
type: 'json',
value: {
stdout: '',
message: 'Code search cancelled: the run was aborted by the user.',
},
},
])
}
const rgPath = getBundledRgPath(import.meta.url)
if (logger) {
logger.info(
{ rgPath, args, searchCwd },
'code-search: Spawning ripgrep process',
)
}
const childProcess = spawn(rgPath, args, {
cwd: searchCwd,
stdio: ['ignore', 'pipe', 'pipe'],
})
let jsonRemainder = ''
let stderrBuf = ''
// Track matches by file for grouping and limiting
const fileGroups = new Map<string, string[]>()
// Track match count per file separately from total lines
const fileMatchCounts = new Map<string, number>()
const filesLimitedByMaxResults = new Set<string>()
let matchesGlobal = 0
let estimatedOutputLen = 0
let killedForLimit = false
// Guard to prevent double-settlement from concurrent timeout and process close events
let killTimeoutId: ReturnType<typeof setTimeout> | null = null
const settle = (payload: any) => {
if (isResolved) return
isResolved = true
// Clean up listeners immediately to prevent further events
childProcess.stdout.removeAllListeners()
childProcess.stderr.removeAllListeners()
childProcess.removeAllListeners()
signal?.removeEventListener('abort', onAbort)
// Clear both the main timeout and the kill timeout to prevent late callbacks
clearTimeout(timeoutId)
if (killTimeoutId) {
clearTimeout(killTimeoutId)
killTimeoutId = null
}
resolve([{ type: 'json', value: payload }])
}
const hardKill = () => {
try {
childProcess.kill('SIGTERM')
} catch {}
// Store timeout reference so it can be cleared if process closes normally
killTimeoutId = setTimeout(() => {
try {
childProcess.kill('SIGKILL')
} catch {
try {
childProcess.kill()
} catch {}
}
killTimeoutId = null
}, 1000)
}
const formatCollectedOutput = (rawOutput: string) =>
formatCodeSearchOutput(rawOutput, {
matchCount: matchesGlobal,
})
const truncateOutput = (output: string, maxLength: number) =>
output.length > maxLength
? output.substring(0, maxLength) + '\n\n[Output truncated]'
: output
const onAbort = () => {
if (isResolved) return
hardKill()
const collectedLines: string[] = []
for (const fileLines of fileGroups.values()) {
collectedLines.push(...fileLines)
}
const partialOutput = collectedLines.join('\n')
settle({
stdout: truncateOutput(formatCollectedOutput(partialOutput), 1000),
message: 'Code search cancelled: the run was aborted by the user.',
})
}
signal?.addEventListener('abort', onAbort, { once: true })
const timeoutId = setTimeout(() => {
if (isResolved) return
hardKill()
// Build output from collected matches
const collectedLines: string[] = []
for (const fileLines of fileGroups.values()) {
collectedLines.push(...fileLines)
}
const partialOutput = collectedLines.join('\n')
const truncatedStdout = truncateOutput(
formatCollectedOutput(partialOutput),
1000,
)
const truncatedStderr =
stderrBuf.length > 1000
? stderrBuf.substring(0, 1000) + '\n\n[Error output truncated]'
: stderrBuf
settle({
errorMessage: `Code search timed out after ${timeoutSeconds} seconds. The search may be too broad or the pattern too complex. Try narrowing your search with more specific flags or a more specific pattern.`,
stdout: truncatedStdout,
stderr: truncatedStderr,
})
}, timeoutSeconds * 1000)
// Parse ripgrep JSON for early stopping
childProcess.stdout.on('data', (chunk: Buffer | string) => {
if (isResolved) return
const chunkStr =
typeof chunk === 'string' ? chunk : chunk.toString('utf8')
jsonRemainder += chunkStr
// Split by lines; last line might be partial
const lines = jsonRemainder.split('\n')
jsonRemainder = lines.pop() || ''
for (const line of lines) {
if (!line) continue
let evt: any
try {
evt = JSON.parse(line)
} catch {
continue
}
// Process both match and context events
if (evt.type === 'match' || evt.type === 'context') {
// Handle both text and bytes for non-UTF8 paths
const filePath = evt.data.path?.text ?? evt.data.path?.bytes ?? ''
const lineNumber = evt.data.line_number ?? 0
// Strip trailing newlines to prevent blank lines in output
const rawText = evt.data.lines?.text ?? ''
const lineText = rawText.replace(/\r?\n$/, '')
// Format as ripgrep output: filename:line_number:content
const formattedLine = `${filePath}:${lineNumber}:${lineText}`
// Group by file
if (!fileGroups.has(filePath)) {
fileGroups.set(filePath, [])
fileMatchCounts.set(filePath, 0)
}
const fileLines = fileGroups.get(filePath)!
const fileMatchCount = fileMatchCounts.get(filePath)!
// Only count matches toward limits, not context lines
const isMatch = evt.type === 'match'
// Check if we should include this line
// For matches: only if we haven't hit the per-file limit
// For context: always include (they don't count toward limit)
const shouldInclude = !isMatch || fileMatchCount < maxResults
if (isMatch && !shouldInclude) {
filesLimitedByMaxResults.add(filePath)
}
if (shouldInclude) {
// Add the line to output
fileLines.push(formattedLine)
estimatedOutputLen += formattedLine.length + 1
// Only increment match counters for actual matches
if (isMatch) {
fileMatchCounts.set(filePath, fileMatchCount + 1)
matchesGlobal++
// Check global limit or output size limit
if (
matchesGlobal >= globalMaxResults ||
estimatedOutputLen >= maxOutputStringLength
) {
killedForLimit = true
hardKill()
// Build final output from collected matches
const limitedLines: string[] = []
for (const lines of fileGroups.values()) {
limitedLines.push(...lines)
}
const rawOutput = limitedLines.join('\n')
const finalOutput = truncateOutput(
formatCollectedOutput(rawOutput),
maxOutputStringLength,
)
const limitReason =
matchesGlobal >= globalMaxResults
? `[Global limit of ${globalMaxResults} results reached.]`
: '[Output size limit reached.]'
return settle({
stdout: finalOutput + '\n\n' + limitReason,
message: `Stopped early after ${matchesGlobal} match(es).`,
})
}
}
}
}
}
})
childProcess.stderr.on('data', (chunk: Buffer | string) => {
if (isResolved) return
const chunkStr =
typeof chunk === 'string' ? chunk : chunk.toString('utf8')
// Keep stderr bounded during streaming
const limit = Math.floor(maxOutputStringLength / 5)
if (stderrBuf.length < limit) {
const space = limit - stderrBuf.length
stderrBuf += chunkStr.slice(0, space)
}
})
childProcess.once('close', (code) => {
if (isResolved) return
// Flush any remaining JSON - handle multiple complete lines
try {
if (jsonRemainder) {
// Ensure we have a trailing newline for split to work correctly
const maybeMany = jsonRemainder.endsWith('\n')
? jsonRemainder
: jsonRemainder + '\n'
for (const ln of maybeMany.split('\n')) {
if (!ln) continue
try {
const evt = JSON.parse(ln)
if (evt?.type === 'match' || evt?.type === 'context') {
const filePath =
evt.data.path?.text ?? evt.data.path?.bytes ?? ''
const lineNumber = evt.data.line_number ?? 0
const rawText = evt.data.lines?.text ?? ''
const lineText = rawText.replace(/\r?\n$/, '')
const formattedLine = `${filePath}:${lineNumber}:${lineText}`
if (!fileGroups.has(filePath)) {
fileGroups.set(filePath, [])
fileMatchCounts.set(filePath, 0)
}
const fileLines = fileGroups.get(filePath)!
const fileMatchCount = fileMatchCounts.get(filePath)!
const isMatch = evt.type === 'match'
// Check if we should include this line
const shouldInclude =
!isMatch ||
(fileMatchCount < maxResults &&
matchesGlobal < globalMaxResults)
if (
isMatch &&
fileMatchCount >= maxResults &&
matchesGlobal < globalMaxResults
) {
filesLimitedByMaxResults.add(filePath)
}
if (shouldInclude) {
fileLines.push(formattedLine)
// Only increment match counter for actual matches
if (isMatch) {
fileMatchCounts.set(filePath, fileMatchCount + 1)
matchesGlobal++
}
}
}
} catch {}
}
}
} catch {}
// Build final output from collected matches
const limitedLines: string[] = []
const truncatedFiles: string[] = []
for (const [filename, fileLines] of fileGroups) {
limitedLines.push(...fileLines)
if (filesLimitedByMaxResults.has(filename)) {
truncatedFiles.push(
`${filename}: limited to ${maxResults} results per file`,
)
}
}
let rawOutput = limitedLines.join('\n')
// Add truncation messages
const truncationMessages: string[] = []
if (truncatedFiles.length > 0) {
truncationMessages.push(
`Results limited to ${maxResults} per file. Truncated files:\n${truncatedFiles.join('\n')}`,
)
}
if (killedForLimit) {
truncationMessages.push(
`Global limit of ${globalMaxResults} results reached.`,
)
}
if (truncationMessages.length > 0) {
rawOutput += `\n\n[${truncationMessages.join('\n\n')}]`
}
// Truncate output to prevent memory issues
const truncatedStdout = truncateOutput(
formatCollectedOutput(rawOutput),
maxOutputStringLength,
)
const truncatedStderr = stderrBuf
? stderrBuf +
(stderrBuf.length >= Math.floor(maxOutputStringLength / 5)
? '\n\n[Error output truncated]'
: '')
: ''
settle({
stdout: truncatedStdout,
...(truncatedStderr && { stderr: truncatedStderr }),
message:
code !== null
? `Exit code: ${code}${killedForLimit ? ' (early stop)' : ''}`
: '',
})
})
childProcess.once('error', (error) => {
if (isResolved) return
settle({
errorMessage: `Failed to execute ripgrep: ${error.message}. Vendored ripgrep not found; ensure @codebuff/sdk is up-to-date or set CODEBUFF_RG_PATH.`,
})
})
})
}