Skip to content

Commit b03ae03

Browse files
authored
fix: grep tool Windows support, case sensitivity and unit tests (#73)
* fix: fix rg lookup path on Windows * fix: fix Windows rg support * fix: fix Windows support * fix: ensure case insensitive search works correctly in rg wrapper * test: add unit tests for grep tool * fix: fix style
1 parent 38f04f9 commit b03ae03

3 files changed

Lines changed: 224 additions & 20 deletions

File tree

src/tools/grep/cli.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
DEFAULT_TIMEOUT_MS,
99
GREP_SAFETY_FLAGS,
1010
type GrepBackend,
11-
RG_SAFETY_FLAGS,
1211
resolveGrepCli,
12+
RG_SAFETY_FLAGS,
1313
} from './constants';
1414
import type { CountResult, GrepMatch, GrepOptions, GrepResult } from './types';
1515

@@ -26,7 +26,11 @@ function buildRgArgs(options: GrepOptions): string[] {
2626
args.push(`-C${Math.min(options.context, 10)}`);
2727
}
2828

29-
if (options.caseSensitive) args.push('--case-sensitive');
29+
if (options.caseSensitive) {
30+
args.push('--case-sensitive');
31+
} else {
32+
args.push('-i');
33+
}
3034
if (options.wholeWord) args.push('-w');
3135
if (options.fixedStrings) args.push('-F');
3236
if (options.multiline) args.push('-U');
@@ -90,7 +94,8 @@ function parseOutput(output: string): GrepMatch[] {
9094
if (!output.trim()) return [];
9195

9296
const matches: GrepMatch[] = [];
93-
const lines = output.split('\n');
97+
// Handle both Unix (\n) and Windows (\r\n) line endings
98+
const lines = output.trim().split(/\r?\n/);
9499

95100
for (const line of lines) {
96101
if (!line.trim()) continue;
@@ -112,7 +117,8 @@ function parseCountOutput(output: string): CountResult[] {
112117
if (!output.trim()) return [];
113118

114119
const results: CountResult[] = [];
115-
const lines = output.split('\n');
120+
// Handle both Unix (\n) and Windows (\r\n) line endings
121+
const lines = output.trim().split(/\r?\n/);
116122

117123
for (const line of lines) {
118124
if (!line.trim()) continue;

src/tools/grep/constants.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
downloadAndInstallRipgrep,
66
getInstalledRipgrepPath,
77
} from './downloader';
8+
import { homedir } from 'os';
9+
810

911
export type GrepBackend = 'rg' | 'grep';
1012

@@ -23,28 +25,15 @@ function findExecutable(name: string): string | null {
2325
try {
2426
const result = spawnSync(cmd, [name], { encoding: 'utf-8', timeout: 5000 });
2527
if (result.status === 0 && result.stdout.trim()) {
26-
return result.stdout.trim().split('\n')[0];
28+
// Handle both Unix (\n) and Windows (\r\n) line endings
29+
return result.stdout.trim().split(/\r?\n/)[0];
2730
}
2831
} catch {
2932
// Command execution failed
3033
}
3134
return null;
3235
}
3336

34-
function getDataDir(): string {
35-
if (process.platform === 'win32') {
36-
return (
37-
process.env.LOCALAPPDATA ||
38-
process.env.APPDATA ||
39-
join(process.env.USERPROFILE || '.', 'AppData', 'Local')
40-
);
41-
}
42-
return (
43-
process.env.XDG_DATA_HOME ||
44-
join(process.env.HOME || '.', '.local', 'share')
45-
);
46-
}
47-
4837
function getOpenCodeBundledRg(): string | null {
4938
const execPath = process.execPath;
5039
const execDir = dirname(execPath);
@@ -54,7 +43,7 @@ function getOpenCodeBundledRg(): string | null {
5443

5544
const candidates = [
5645
// OpenCode XDG data path (highest priority - where OpenCode installs rg)
57-
join(getDataDir(), 'opencode', 'bin', rgName),
46+
join(homedir(), '.opencode', 'bin', rgName),
5847
// Legacy paths relative to execPath
5948
join(execDir, rgName),
6049
join(execDir, 'bin', rgName),

src/tools/grep/grep.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2+
import { join } from 'node:path';
3+
import { mkdir, rm, writeFile } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { runRg, runRgCount } from './cli';
6+
import { formatGrepResult } from './utils';
7+
import { grep } from './tools';
8+
9+
describe('grep tool', () => {
10+
const testDir = join(tmpdir(), `grep-test-${Date.now()}`);
11+
const testFile1 = join(testDir, 'test1.txt');
12+
const testFile2 = join(testDir, 'test2.ts');
13+
14+
beforeAll(async () => {
15+
await mkdir(testDir, { recursive: true });
16+
await writeFile(testFile1, 'Hello world\nThis is a test file\nAnother line with match');
17+
await writeFile(testFile2, 'const x = \'Hello world\';\nconsole.log(\'test\');');
18+
});
19+
20+
afterAll(async () => {
21+
await rm(testDir, { recursive: true, force: true });
22+
});
23+
24+
describe('formatGrepResult', () => {
25+
test('formats empty results', () => {
26+
const result = {
27+
matches: [],
28+
totalMatches: 0,
29+
filesSearched: 10,
30+
truncated: false,
31+
};
32+
expect(formatGrepResult(result)).toBe('No matches found.');
33+
});
34+
35+
test('formats error results', () => {
36+
const result = {
37+
matches: [],
38+
totalMatches: 0,
39+
filesSearched: 0,
40+
truncated: false,
41+
error: 'Something went wrong',
42+
};
43+
expect(formatGrepResult(result)).toBe('Error: Something went wrong');
44+
});
45+
46+
test('formats matches correctly', () => {
47+
const result = {
48+
matches: [
49+
{ file: 'file1.ts', line: 10, text: 'const foo = \'bar\'' },
50+
{ file: 'file1.ts', line: 15, text: 'console.log(foo)' },
51+
{ file: 'file2.ts', line: 5, text: 'import { foo } from \'./file1\'' },
52+
],
53+
totalMatches: 3,
54+
filesSearched: 2,
55+
truncated: false,
56+
};
57+
58+
const output = formatGrepResult(result);
59+
expect(output).toContain('file1.ts:');
60+
expect(output).toContain(' 10: const foo = \'bar\'');
61+
expect(output).toContain(' 15: console.log(foo)');
62+
expect(output).toContain('file2.ts:');
63+
expect(output).toContain(' 5: import { foo } from \'./file1\'');
64+
expect(output).toContain('Found 3 matches in 2 files');
65+
});
66+
67+
test('indicates truncation', () => {
68+
const result = {
69+
matches: [{ file: 'foo.txt', line: 1, text: 'bar' }],
70+
totalMatches: 100,
71+
filesSearched: 50,
72+
truncated: true,
73+
};
74+
expect(formatGrepResult(result)).toContain('(output truncated)');
75+
});
76+
});
77+
78+
describe('runRg', () => {
79+
test('finds matches in files', async () => {
80+
const result = await runRg({
81+
pattern: 'Hello',
82+
paths: [testDir],
83+
});
84+
85+
expect(result.totalMatches).toBeGreaterThanOrEqual(2);
86+
expect(result.matches.some((m) => m.file.includes('test1.txt') && m.text.includes('Hello'))).toBe(true);
87+
expect(result.matches.some((m) => m.file.includes('test2.ts') && m.text.includes('Hello'))).toBe(true);
88+
});
89+
90+
test('respects file inclusion patterns', async () => {
91+
const result = await runRg({
92+
pattern: 'Hello',
93+
paths: [testDir],
94+
globs: ['*.txt'],
95+
});
96+
97+
expect(result.matches.some((m) => m.file.includes('test1.txt'))).toBe(true);
98+
expect(result.matches.some((m) => m.file.includes('test2.ts'))).toBe(false);
99+
});
100+
101+
test('handles no matches', async () => {
102+
const result = await runRg({
103+
pattern: 'NonExistentString12345',
104+
paths: [testDir],
105+
});
106+
107+
expect(result.totalMatches).toBe(0);
108+
expect(result.matches).toHaveLength(0);
109+
});
110+
111+
test('respects case sensitivity', async () => {
112+
// Default is case insensitive (smart case usually, but wrapper might default differently, let's check implementation)
113+
// Looking at cli.ts: if (!options.caseSensitive) args.push("-i")
114+
// So default is case insensitive.
115+
116+
const resultInsensitive = await runRg({
117+
pattern: 'hello',
118+
paths: [testDir],
119+
caseSensitive: false
120+
});
121+
expect(resultInsensitive.totalMatches).toBeGreaterThan(0);
122+
123+
const resultSensitive = await runRg({
124+
pattern: 'hello', // File has "Hello"
125+
paths: [testDir],
126+
caseSensitive: true
127+
});
128+
expect(resultSensitive.totalMatches).toBe(0);
129+
});
130+
131+
test('respects whole word match', async () => {
132+
const resultPartial = await runRg({
133+
pattern: 'Hell',
134+
paths: [testDir],
135+
wholeWord: false
136+
});
137+
expect(resultPartial.totalMatches).toBeGreaterThan(0);
138+
139+
const resultWhole = await runRg({
140+
pattern: 'Hell',
141+
paths: [testDir],
142+
wholeWord: true
143+
});
144+
expect(resultWhole.totalMatches).toBe(0);
145+
});
146+
147+
test('respects max count', async () => {
148+
const result = await runRg({
149+
pattern: 'Hello',
150+
paths: [testDir],
151+
maxCount: 1
152+
});
153+
// maxCount is per file
154+
expect(result.matches.filter(m => m.file.includes('test1.txt')).length).toBeLessThanOrEqual(1);
155+
});
156+
});
157+
158+
describe('runRgCount', () => {
159+
test('counts matches correctly', async () => {
160+
const results = await runRgCount({
161+
pattern: 'Hello',
162+
paths: [testDir]
163+
});
164+
165+
expect(results.length).toBeGreaterThan(0);
166+
const file1Result = results.find(r => r.file.includes('test1.txt'));
167+
expect(file1Result).toBeDefined();
168+
expect(file1Result?.count).toBe(1);
169+
});
170+
});
171+
172+
describe('grep tool execute', () => {
173+
test('executes successfully', async () => {
174+
// @ts-ignore
175+
const result = await grep.execute({
176+
pattern: 'Hello',
177+
path: testDir,
178+
});
179+
180+
expect(typeof result).toBe('string');
181+
expect(result).toContain('Found');
182+
expect(result).toContain('matches');
183+
});
184+
185+
test('handles errors gracefully', async () => {
186+
// @ts-ignore
187+
const result = await grep.execute({
188+
pattern: 'Hello',
189+
path: '/non/existent/path/12345',
190+
});
191+
192+
// Depending on implementation, it might return "No matches found" or an error string
193+
// But it should not throw
194+
expect(typeof result).toBe('string');
195+
});
196+
197+
test('respects include pattern in execute', async () => {
198+
// @ts-ignore
199+
const result = await grep.execute({
200+
pattern: 'Hello',
201+
path: testDir,
202+
include: '*.txt'
203+
});
204+
205+
expect(result).toContain('test1.txt');
206+
expect(result).not.toContain('test2.ts');
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)