1package tools
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "regexp"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14)
15
16func TestRegexCache(t *testing.T) {
17 cache := newRegexCache()
18
19 // Test basic caching
20 pattern := "test.*pattern"
21 regex1, err := cache.get(pattern)
22 if err != nil {
23 t.Fatalf("Failed to compile regex: %v", err)
24 }
25
26 regex2, err := cache.get(pattern)
27 if err != nil {
28 t.Fatalf("Failed to get cached regex: %v", err)
29 }
30
31 // Should be the same instance (cached)
32 if regex1 != regex2 {
33 t.Error("Expected cached regex to be the same instance")
34 }
35
36 // Test that it actually works
37 if !regex1.MatchString("test123pattern") {
38 t.Error("Regex should match test string")
39 }
40}
41
42func TestGlobToRegexCaching(t *testing.T) {
43 // Test that globToRegex uses pre-compiled regex
44 pattern1 := globToRegex("*.{js,ts}")
45
46 // Should not panic and should work correctly
47 regex1, err := regexp.Compile(pattern1)
48 if err != nil {
49 t.Fatalf("Failed to compile glob regex: %v", err)
50 }
51
52 if !regex1.MatchString("test.js") {
53 t.Error("Glob regex should match .js files")
54 }
55 if !regex1.MatchString("test.ts") {
56 t.Error("Glob regex should match .ts files")
57 }
58 if regex1.MatchString("test.go") {
59 t.Error("Glob regex should not match .go files")
60 }
61}
62
63func TestGrepWithIgnoreFiles(t *testing.T) {
64 t.Parallel()
65 tempDir := t.TempDir()
66
67 // Create test files
68 testFiles := map[string]string{
69 "file1.txt": "hello world",
70 "file2.txt": "hello world",
71 "ignored/file3.txt": "hello world",
72 "node_modules/lib.js": "hello world",
73 "secret.key": "hello world",
74 }
75
76 for path, content := range testFiles {
77 fullPath := filepath.Join(tempDir, path)
78 require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
79 require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
80 }
81
82 // Create .gitignore file
83 gitignoreContent := "ignored/\n*.key\n"
84 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
85
86 // Create .crushignore file
87 crushignoreContent := "node_modules/\n"
88 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
89
90 // Test both implementations
91 for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
92 "regex": searchFilesWithRegex,
93 "rg": func(pattern, path, include string) ([]grepMatch, error) {
94 return searchWithRipgrep(t.Context(), getRgSearchCmd, pattern, path, include)
95 },
96 } {
97 t.Run(name, func(t *testing.T) {
98 t.Parallel()
99
100 if name == "rg" && getRg() == "" {
101 t.Skip("rg is not in $PATH")
102 }
103
104 matches, err := fn("hello world", tempDir, "")
105 require.NoError(t, err)
106
107 // Convert matches to a set of file paths for easier testing
108 foundFiles := make(map[string]bool)
109 for _, match := range matches {
110 foundFiles[filepath.Base(match.path)] = true
111 }
112
113 // Should find file1.txt and file2.txt
114 require.True(t, foundFiles["file1.txt"], "Should find file1.txt")
115 require.True(t, foundFiles["file2.txt"], "Should find file2.txt")
116
117 // Should NOT find ignored files
118 require.False(t, foundFiles["file3.txt"], "Should not find file3.txt (ignored by .gitignore)")
119 require.False(t, foundFiles["lib.js"], "Should not find lib.js (ignored by .crushignore)")
120 require.False(t, foundFiles["secret.key"], "Should not find secret.key (ignored by .gitignore)")
121
122 // Should find exactly 2 matches
123 require.Equal(t, 2, len(matches), "Should find exactly 2 matches")
124 })
125 }
126}
127
128func TestSearchImplementations(t *testing.T) {
129 t.Parallel()
130 tempDir := t.TempDir()
131
132 for path, content := range map[string]string{
133 "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
134 "file2.js": "console.log('hello world');",
135 "file3.txt": "hello world from text file",
136 "binary.exe": "\x00\x01\x02\x03",
137 "empty.txt": "",
138 "subdir/nested.go": "package nested\n// hello world comment",
139 ".hidden.txt": "hello world in hidden file",
140 "file4.txt": "hello world from a banana",
141 "file5.txt": "hello world from a grape",
142 } {
143 fullPath := filepath.Join(tempDir, path)
144 require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
145 require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
146 }
147
148 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
149 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
150
151 for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
152 "regex": searchFilesWithRegex,
153 "rg": func(pattern, path, include string) ([]grepMatch, error) {
154 return searchWithRipgrep(t.Context(), getRgSearchCmd, pattern, path, include)
155 },
156 } {
157 t.Run(name, func(t *testing.T) {
158 t.Parallel()
159
160 if name == "rg" && getRg() == "" {
161 t.Skip("rg is not in $PATH")
162 }
163
164 matches, err := fn("hello world", tempDir, "")
165 require.NoError(t, err)
166
167 require.Equal(t, len(matches), 4)
168 for _, match := range matches {
169 require.NotEmpty(t, match.path)
170 require.NotZero(t, match.lineNum)
171 require.NotEmpty(t, match.lineText)
172 require.NotZero(t, match.modTime)
173 require.NotContains(t, match.path, ".hidden.txt")
174 require.NotContains(t, match.path, "file4.txt")
175 require.NotContains(t, match.path, "file5.txt")
176 require.NotContains(t, match.path, "binary.exe")
177 }
178 })
179 }
180}
181
182type mockRgExecCmd struct {
183 args []string
184 err error
185}
186
187func (m *mockRgExecCmd) AddArgs(args ...string) {
188 m.args = append(m.args, args...)
189}
190
191func (m *mockRgExecCmd) Output() ([]byte, error) {
192 if m.err != nil {
193 return nil, m.err
194 }
195 return []byte{}, nil
196}
197
198func TestSearchWithRipGrepButItFailsToRunHandleError(t *testing.T) {
199 tests := []struct {
200 name string
201 err error
202 expectMatches bool
203 expectError bool
204 }{
205 {
206 name: "exit code 1 returns no matches and no error",
207 err: func() error {
208 cmd := exec.Command("sh", "-c", "exit 1")
209 err := cmd.Run()
210 require.Error(t, err)
211 exitErr, ok := err.(*exec.ExitError)
212 require.True(t, ok)
213 require.Equal(t, 1, exitErr.ExitCode())
214 return exitErr
215 }(),
216 expectMatches: false,
217 expectError: false,
218 },
219 {
220 name: "non-exit error returns error",
221 err: os.ErrPermission,
222 expectMatches: false,
223 expectError: true,
224 },
225 }
226
227 for _, tt := range tests {
228 t.Run(tt.name, func(t *testing.T) {
229 mockRgCmd := mockRgExecCmd{
230 err: tt.err,
231 }
232
233 matches, err := searchWithRipgrep(t.Context(), func(ctx context.Context, pattern, path, include string) execCmd {
234 return &mockRgCmd
235 }, "", "", "")
236
237 if tt.expectMatches {
238 require.NotEmpty(t, matches)
239 } else {
240 require.Empty(t, matches)
241 }
242
243 if tt.expectError {
244 require.Error(t, err)
245 } else {
246 require.NoError(t, err)
247 }
248 })
249 }
250}
251
252func TestSearchFilesWithLimit(t *testing.T) {
253 t.Parallel()
254
255 tests := []struct {
256 name string
257 limit int
258 numMatches int
259 expectedMatches int
260 expectedTruncated bool
261 }{
262 {
263 name: "limit of 100 truncates 150 results",
264 limit: 100,
265 numMatches: 150,
266 expectedMatches: 100,
267 expectedTruncated: true,
268 },
269 {
270 name: "limit of 200 does not truncate 150 results",
271 limit: 200,
272 numMatches: 150,
273 expectedMatches: 150,
274 expectedTruncated: false,
275 },
276 {
277 name: "limit of 150 exactly matches all files",
278 limit: 150,
279 numMatches: 150,
280 expectedMatches: 150,
281 expectedTruncated: false,
282 },
283 }
284
285 for _, tt := range tests {
286 t.Run(tt.name, func(t *testing.T) {
287 t.Parallel()
288
289 // Create mock ripgrep search that returns fake matches.
290 mockRipgrepSearch := func(ctx context.Context, rgSearchCmd resolveRgSearchCmd, pattern, path, include string) ([]grepMatch, error) {
291 matches := make([]grepMatch, tt.numMatches)
292 for i := 0; i < tt.numMatches; i++ {
293 matches[i] = grepMatch{
294 path: fmt.Sprintf("/fake/path/file%03d.txt", i),
295 modTime: time.Now().Add(-time.Duration(i) * time.Minute),
296 lineNum: 1,
297 lineText: "test pattern",
298 }
299 }
300 return matches, nil
301 }
302
303 matches, truncated, err := searchFiles(t.Context(), mockRipgrepSearch, "test pattern", "/fake/path", "", tt.limit)
304 require.NoError(t, err)
305 require.Equal(t, tt.expectedMatches, len(matches))
306 require.Equal(t, tt.expectedTruncated, truncated)
307 })
308 }
309}
310
311// Benchmark to show performance improvement
312func BenchmarkRegexCacheVsCompile(b *testing.B) {
313 cache := newRegexCache()
314 pattern := "test.*pattern.*[0-9]+"
315
316 b.Run("WithCache", func(b *testing.B) {
317 for b.Loop() {
318 _, err := cache.get(pattern)
319 if err != nil {
320 b.Fatal(err)
321 }
322 }
323 })
324
325 b.Run("WithoutCache", func(b *testing.B) {
326 for b.Loop() {
327 _, err := regexp.Compile(pattern)
328 if err != nil {
329 b.Fatal(err)
330 }
331 }
332 })
333}