Skip to content

Commit 71b65a1

Browse files
authored
Improve code_search output formatting (#595)
1 parent f43b59e commit 71b65a1

4 files changed

Lines changed: 226 additions & 75 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { formatCodeSearchOutput } from '../format-code-search'
4+
5+
describe('formatCodeSearchOutput', () => {
6+
it('adds a match count and line labels', () => {
7+
const output = formatCodeSearchOutput(
8+
[
9+
'src/a.ts:12:const alpha = true',
10+
'src/a.ts:18:return alpha',
11+
'src/b.ts:3:export const beta = false',
12+
].join('\n'),
13+
{ matchCount: 3 },
14+
)
15+
16+
expect(output).toBe(
17+
[
18+
'Found 3 matches',
19+
'src/a.ts:',
20+
' Line 12: const alpha = true',
21+
' Line 18: return alpha',
22+
'',
23+
'src/b.ts:',
24+
' Line 3: export const beta = false',
25+
].join('\n'),
26+
)
27+
})
28+
29+
it('uses the provided match count instead of counting context lines', () => {
30+
const output = formatCodeSearchOutput(
31+
[
32+
'src/a.ts:10:const before = true',
33+
'src/a.ts:11:const match = true',
34+
'src/a.ts:12:const after = true',
35+
].join('\n'),
36+
{ matchCount: 1 },
37+
)
38+
39+
expect(output).toContain('Found 1 matches')
40+
expect(output).toContain(' Line 10: const before = true')
41+
expect(output).toContain(' Line 11: const match = true')
42+
expect(output).toContain(' Line 12: const after = true')
43+
})
44+
45+
it('does not count native ripgrep context lines as matches', () => {
46+
const output = formatCodeSearchOutput(
47+
[
48+
'src/a.ts-10-const before = true',
49+
'src/a.ts:11:const match = true',
50+
'src/a.ts-12-const after = true',
51+
].join('\n'),
52+
)
53+
54+
expect(output).toContain('Found 1 matches')
55+
})
56+
57+
it('reports zero matches for empty output', () => {
58+
expect(formatCodeSearchOutput('')).toBe('Found 0 matches')
59+
})
60+
})
Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
/**
22
* Formats code search output to group matches by file.
33
*
4-
* Input format: ./file.ts:line content
4+
* Input format: ./file.ts:line:content
55
* Output format:
6+
* Found 3 matches
67
* ./file.ts:
7-
* line content
8-
* another line content
9-
* yet another line content
8+
* Line 1: content
9+
* Line 2: another line content
10+
* Line 3: yet another line content
1011
*
1112
* (double newline between distinct files)
1213
*
1314
* @param stdout The raw stdout from ripgrep
15+
* @param options.matchCount The number of actual matches, excluding context lines
1416
* @returns Formatted output with matches grouped by file
1517
*/
16-
export function formatCodeSearchOutput(stdout: string): string {
18+
export function formatCodeSearchOutput(
19+
stdout: string,
20+
options: { matchCount?: number } = {},
21+
): string {
1722
if (!stdout) {
18-
return 'No results'
23+
return 'Found 0 matches'
1924
}
2025
const lines = stdout.split('\n')
21-
const formatted: string[] = []
26+
const formatted: string[] = [
27+
`Found ${options.matchCount ?? countFormattedMatches(lines)} matches`,
28+
]
2229
let currentFile: string | null = null
2330

2431
for (const line of lines) {
@@ -38,30 +45,13 @@ export function formatCodeSearchOutput(stdout: string): string {
3845

3946
// Use regex to find the pattern: separator + digits + separator
4047
// This handles filenames with hyphens/colons by matching the line number pattern
41-
let separatorIndex = -1
42-
let filePath = ''
48+
const parsedLine = parseRipgrepLine(line)
4349

44-
// Try match line pattern: filename:digits:content
45-
const matchLinePattern = /(.*?):(\d+):(.*)$/
46-
const matchLineMatch = line.match(matchLinePattern)
47-
if (matchLineMatch) {
48-
filePath = matchLineMatch[1]
49-
separatorIndex = matchLineMatch[1].length
50-
} else {
51-
// Try context line pattern: filename-digits-content
52-
const contextLinePattern = /(.*?)-(\d+)-(.*)$/
53-
const contextLineMatch = line.match(contextLinePattern)
54-
if (contextLineMatch) {
55-
filePath = contextLineMatch[1]
56-
separatorIndex = contextLineMatch[1].length
57-
}
58-
}
59-
60-
if (separatorIndex === -1) {
50+
if (!parsedLine) {
6151
formatted.push(line)
6252
continue
6353
}
64-
const content = line.substring(separatorIndex)
54+
const { filePath, lineNumber, content } = parsedLine
6555

6656
// Check if this is a new file (file paths don't start with whitespace)
6757
if (filePath && !filePath.startsWith(' ') && !filePath.startsWith('\t')) {
@@ -73,11 +63,9 @@ export function formatCodeSearchOutput(stdout: string): string {
7363
currentFile = filePath
7464
// Show file path with colon on its own line
7565
formatted.push(filePath + ':')
76-
// Show content without leading separator on next line
77-
formatted.push(content.substring(1))
66+
formatted.push(` Line ${lineNumber}: ${content}`)
7867
} else {
79-
// Same file - just show content without leading separator
80-
formatted.push(content.substring(1))
68+
formatted.push(` Line ${lineNumber}: ${content}`)
8169
}
8270
} else {
8371
// Line doesn't match expected format, keep as-is
@@ -87,3 +75,41 @@ export function formatCodeSearchOutput(stdout: string): string {
8775

8876
return formatted.join('\n')
8977
}
78+
79+
function parseRipgrepLine(line: string): {
80+
filePath: string
81+
lineNumber: string
82+
content: string
83+
isContext: boolean
84+
} | null {
85+
// Try match line pattern: filename:digits:content
86+
const matchLineMatch = line.match(/(.*?):(\d+):(.*)$/)
87+
if (matchLineMatch) {
88+
return {
89+
filePath: matchLineMatch[1],
90+
lineNumber: matchLineMatch[2],
91+
content: matchLineMatch[3],
92+
isContext: false,
93+
}
94+
}
95+
96+
// Try context line pattern: filename-digits-content
97+
const contextLineMatch = line.match(/(.*?)-(\d+)-(.*)$/)
98+
if (contextLineMatch) {
99+
return {
100+
filePath: contextLineMatch[1],
101+
lineNumber: contextLineMatch[2],
102+
content: contextLineMatch[3],
103+
isContext: true,
104+
}
105+
}
106+
107+
return null
108+
}
109+
110+
function countFormattedMatches(lines: string[]): number {
111+
return lines.filter((line) => {
112+
const parsedLine = parseRipgrepLine(line)
113+
return parsedLine && !parsedLine.isContext
114+
}).length
115+
}

sdk/src/__tests__/code-search.test.ts

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ describe('codeSearch', () => {
5151
const result = await searchPromise
5252
expect(result[0].type).toBe('json')
5353
const value = asCodeSearchResult(result[0])
54+
expect(value.stdout).toContain('Found 3 matches')
5455
expect(value.stdout).toContain('file1.ts:')
56+
expect(value.stdout).toContain(' Line 1: import foo from "bar"')
5557
expect(value.stdout).toContain('file2.ts:')
5658
})
5759
})
@@ -81,6 +83,8 @@ describe('codeSearch', () => {
8183
expect(result[0].type).toBe('json')
8284
const value = asCodeSearchResult(result[0])
8385

86+
expect(value.stdout).toContain('Found 2 matches')
87+
8488
// Should contain match lines
8589
expect(value.stdout).toContain('import { env } from "./config"')
8690
expect(value.stdout).toContain('import env from "process"')
@@ -104,7 +108,11 @@ describe('codeSearch', () => {
104108
createRgJsonContext('app.ts', 1, 'import React from "react"'),
105109
createRgJsonContext('app.ts', 2, ''),
106110
createRgJsonMatch('app.ts', 3, 'export const main = () => {}'),
107-
createRgJsonContext('utils.ts', 8, 'function validateInput(x: string) {'),
111+
createRgJsonContext(
112+
'utils.ts',
113+
8,
114+
'function validateInput(x: string) {',
115+
),
108116
createRgJsonContext('utils.ts', 9, ' return x.length > 0'),
109117
createRgJsonMatch('utils.ts', 10, 'export function helper() {}'),
110118
].join('\n')
@@ -343,6 +351,28 @@ describe('codeSearch', () => {
343351
}
344352
})
345353

354+
it('should not report truncation when matches exactly equal maxResults', async () => {
355+
const searchPromise = codeSearch({
356+
projectPath: '/test/project',
357+
pattern: 'test',
358+
maxResults: 2,
359+
})
360+
361+
const output = [
362+
createRgJsonMatch('file.ts', 1, 'test 1'),
363+
createRgJsonMatch('file.ts', 2, 'test 2'),
364+
].join('\n')
365+
366+
mockProcess.stdout.emit('data', Buffer.from(output))
367+
mockProcess.emit('close', 0)
368+
369+
const result = await searchPromise
370+
const value = asCodeSearchResult(result[0])
371+
372+
expect(value.stdout).toContain('Found 2 matches')
373+
expect(value.stdout).not.toContain('Results limited')
374+
})
375+
346376
it('should respect globalMaxResults with context lines', async () => {
347377
const searchPromise = codeSearch({
348378
projectPath: '/test/project',
@@ -447,8 +477,7 @@ describe('codeSearch', () => {
447477
const result = await searchPromise
448478
const value = asCodeSearchResult(result[0])
449479

450-
// formatCodeSearchOutput returns 'No results' for empty input
451-
expect(value.stdout).toBe('No results')
480+
expect(value.stdout).toBe('Found 0 matches')
452481
})
453482
})
454483

@@ -544,7 +573,13 @@ describe('codeSearch', () => {
544573
// Generate matches with long content to quickly exceed output size
545574
const matches: string[] = []
546575
for (let i = 0; i < 20; i++) {
547-
matches.push(createRgJsonMatch('file.ts', i, `test line ${i} with some content that is quite long to fill up the buffer quickly`))
576+
matches.push(
577+
createRgJsonMatch(
578+
'file.ts',
579+
i,
580+
`test line ${i} with some content that is quite long to fill up the buffer quickly`,
581+
),
582+
)
548583
}
549584
const output = matches.join('\n')
550585

@@ -559,8 +594,8 @@ describe('codeSearch', () => {
559594
const matchCount = (value.stdout!.match(/test line \d+/g) || []).length
560595
expect(matchCount).toBeLessThan(20)
561596
// Should indicate truncation happened
562-
const hasTruncationMessage =
563-
value.stdout!.includes('truncated') ||
597+
const hasTruncationMessage =
598+
value.stdout!.includes('truncated') ||
564599
value.stdout!.includes('limit reached') ||
565600
value.stdout!.includes('Output size limit')
566601
expect(hasTruncationMessage).toBe(true)
@@ -616,7 +651,7 @@ describe('codeSearch', () => {
616651
expect(result[0].type).toBe('json')
617652
const value = asCodeSearchResult(result[0])
618653
expect(value.stdout).toContain('file.ts:')
619-
654+
620655
// Verify the args passed to spawn include the glob flag correctly
621656
expect(mockSpawn).toHaveBeenCalled()
622657
const spawnArgs = mockSpawn.mock.calls[0]![1] as string[]
@@ -631,7 +666,11 @@ describe('codeSearch', () => {
631666
flags: '-g *.ts -g *.tsx',
632667
})
633668

634-
const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"')
669+
const output = createRgJsonMatch(
670+
'file.tsx',
671+
1,
672+
'import React from "react"',
673+
)
635674

636675
mockProcess.stdout.emit('data', Buffer.from(output))
637676
mockProcess.emit('close', 0)
@@ -640,11 +679,13 @@ describe('codeSearch', () => {
640679
expect(result[0].type).toBe('json')
641680
const value = asCodeSearchResult(result[0])
642681
expect(value.stdout).toContain('file.tsx:')
643-
682+
644683
// Verify both glob patterns are passed correctly
645684
const spawnArgs = mockSpawn.mock.calls[0]![1] as string[]
646685
// Should have two -g flags, each followed by its pattern
647-
const gFlagIndices = spawnArgs.map((arg, i) => arg === '-g' ? i : -1).filter(i => i !== -1)
686+
const gFlagIndices = spawnArgs
687+
.map((arg, i) => (arg === '-g' ? i : -1))
688+
.filter((i) => i !== -1)
648689
expect(gFlagIndices.length).toBe(2)
649690
expect(spawnArgs[gFlagIndices[0]! + 1]).toBe('*.ts')
650691
expect(spawnArgs[gFlagIndices[1]! + 1]).toBe('*.tsx')
@@ -657,7 +698,11 @@ describe('codeSearch', () => {
657698
flags: "-g 'authentication.knowledge.md'",
658699
})
659700

660-
const output = createRgJsonMatch('authentication.knowledge.md', 5, 'auth content')
701+
const output = createRgJsonMatch(
702+
'authentication.knowledge.md',
703+
5,
704+
'auth content',
705+
)
661706

662707
mockProcess.stdout.emit('data', Buffer.from(output))
663708
mockProcess.emit('close', 0)
@@ -721,23 +766,27 @@ describe('codeSearch', () => {
721766
flags: '-g *.ts -i -g *.tsx',
722767
})
723768

724-
const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"')
769+
const output = createRgJsonMatch(
770+
'file.tsx',
771+
1,
772+
'import React from "react"',
773+
)
725774

726775
mockProcess.stdout.emit('data', Buffer.from(output))
727776
mockProcess.emit('close', 0)
728777

729778
const result = await searchPromise
730-
779+
731780
// Verify flags are preserved in order without deduplication
732781
const spawnArgs = mockSpawn.mock.calls[0]![1] as string[]
733782
const flagsSection = spawnArgs.slice(0, spawnArgs.indexOf('--'))
734783
expect(flagsSection).toContain('-g')
735784
expect(flagsSection).toContain('*.ts')
736785
expect(flagsSection).toContain('-i')
737786
expect(flagsSection).toContain('*.tsx')
738-
787+
739788
// Count -g flags - should be 2, not deduplicated to 1
740-
const gCount = flagsSection.filter(arg => arg === '-g').length
789+
const gCount = flagsSection.filter((arg) => arg === '-g').length
741790
expect(gCount).toBe(2)
742791
})
743792
})

0 commit comments

Comments
 (0)