1package tools
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10)
11
12func TestRegexCache(t *testing.T) {
13 cache := newRegexCache()
14
15 // Test basic caching
16 pattern := "test.*pattern"
17 regex1, err := cache.get(pattern)
18 if err != nil {
19 t.Fatalf("Failed to compile regex: %v", err)
20 }
21
22 regex2, err := cache.get(pattern)
23 if err != nil {
24 t.Fatalf("Failed to get cached regex: %v", err)
25 }
26
27 // Should be the same instance (cached)
28 if regex1 != regex2 {
29 t.Error("Expected cached regex to be the same instance")
30 }
31
32 // Test that it actually works
33 if !regex1.MatchString("test123pattern") {
34 t.Error("Regex should match test string")
35 }
36}
37
38func TestGlobToRegexCaching(t *testing.T) {
39 // Test that globToRegex uses pre-compiled regex
40 pattern1 := globToRegex("*.{js,ts}")
41
42 // Should not panic and should work correctly
43 regex1, err := regexp.Compile(pattern1)
44 if err != nil {
45 t.Fatalf("Failed to compile glob regex: %v", err)
46 }
47
48 if !regex1.MatchString("test.js") {
49 t.Error("Glob regex should match .js files")
50 }
51 if !regex1.MatchString("test.ts") {
52 t.Error("Glob regex should match .ts files")
53 }
54 if regex1.MatchString("test.go") {
55 t.Error("Glob regex should not match .go files")
56 }
57}
58
59func TestGrepWithIgnoreFiles(t *testing.T) {
60 t.Parallel()
61 tempDir := t.TempDir()
62
63 // Create test files
64 testFiles := map[string]string{
65 "file1.txt": "hello world",
66 "file2.txt": "hello world",
67 "ignored/file3.txt": "hello world",
68 "node_modules/lib.js": "hello world",
69 "secret.key": "hello world",
70 }
71
72 for path, content := range testFiles {
73 fullPath := filepath.Join(tempDir, path)
74 require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
75 require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
76 }
77
78 // Create .gitignore file
79 gitignoreContent := "ignored/\n*.key\n"
80 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
81
82 // Create .crushignore file
83 crushignoreContent := "node_modules/\n"
84 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
85
86 // Test both implementations
87 for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
88 "regex": searchFilesWithRegex,
89 "rg": func(pattern, path, include string) ([]grepMatch, error) {
90 return searchWithRipgrep(t.Context(), pattern, path, include)
91 },
92 } {
93 t.Run(name, func(t *testing.T) {
94 t.Parallel()
95
96 if name == "rg" && getRg() == "" {
97 t.Skip("rg is not in $PATH")
98 }
99
100 matches, err := fn("hello world", tempDir, "")
101 require.NoError(t, err)
102
103 // Convert matches to a set of file paths for easier testing
104 foundFiles := make(map[string]bool)
105 for _, match := range matches {
106 foundFiles[filepath.Base(match.path)] = true
107 }
108
109 // Should find file1.txt and file2.txt
110 require.True(t, foundFiles["file1.txt"], "Should find file1.txt")
111 require.True(t, foundFiles["file2.txt"], "Should find file2.txt")
112
113 // Should NOT find ignored files
114 require.False(t, foundFiles["file3.txt"], "Should not find file3.txt (ignored by .gitignore)")
115 require.False(t, foundFiles["lib.js"], "Should not find lib.js (ignored by .crushignore)")
116 require.False(t, foundFiles["secret.key"], "Should not find secret.key (ignored by .gitignore)")
117
118 // Should find exactly 2 matches
119 require.Equal(t, 2, len(matches), "Should find exactly 2 matches")
120 })
121 }
122}
123
124func TestSearchImplementations(t *testing.T) {
125 t.Parallel()
126 tempDir := t.TempDir()
127
128 for path, content := range map[string]string{
129 "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
130 "file2.js": "console.log('hello world');",
131 "file3.txt": "hello world from text file",
132 "binary.exe": "\x00\x01\x02\x03",
133 "empty.txt": "",
134 "subdir/nested.go": "package nested\n// hello world comment",
135 ".hidden.txt": "hello world in hidden file",
136 "file4.txt": "hello world from a banana",
137 "file5.txt": "hello world from a grape",
138 } {
139 fullPath := filepath.Join(tempDir, path)
140 require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
141 require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
142 }
143
144 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
145 require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
146
147 for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
148 "regex": searchFilesWithRegex,
149 "rg": func(pattern, path, include string) ([]grepMatch, error) {
150 return searchWithRipgrep(t.Context(), pattern, path, include)
151 },
152 } {
153 t.Run(name, func(t *testing.T) {
154 t.Parallel()
155
156 if name == "rg" && getRg() == "" {
157 t.Skip("rg is not in $PATH")
158 }
159
160 matches, err := fn("hello world", tempDir, "")
161 require.NoError(t, err)
162
163 require.Equal(t, len(matches), 4)
164 for _, match := range matches {
165 require.NotEmpty(t, match.path)
166 require.NotZero(t, match.lineNum)
167 require.NotEmpty(t, match.lineText)
168 require.NotZero(t, match.modTime)
169 require.NotContains(t, match.path, ".hidden.txt")
170 require.NotContains(t, match.path, "file4.txt")
171 require.NotContains(t, match.path, "file5.txt")
172 require.NotContains(t, match.path, "binary.exe")
173 }
174 })
175 }
176}
177
178// Benchmark to show performance improvement
179func BenchmarkRegexCacheVsCompile(b *testing.B) {
180 cache := newRegexCache()
181 pattern := "test.*pattern.*[0-9]+"
182
183 b.Run("WithCache", func(b *testing.B) {
184 for b.Loop() {
185 _, err := cache.get(pattern)
186 if err != nil {
187 b.Fatal(err)
188 }
189 }
190 })
191
192 b.Run("WithoutCache", func(b *testing.B) {
193 for b.Loop() {
194 _, err := regexp.Compile(pattern)
195 if err != nil {
196 b.Fatal(err)
197 }
198 }
199 })
200}
201
202func TestIsTextFile(t *testing.T) {
203 t.Parallel()
204 tempDir := t.TempDir()
205
206 tests := []struct {
207 name string
208 filename string
209 content []byte
210 wantText bool
211 }{
212 {
213 name: "go file",
214 filename: "test.go",
215 content: []byte("package main\n\nfunc main() {}\n"),
216 wantText: true,
217 },
218 {
219 name: "yaml file",
220 filename: "config.yaml",
221 content: []byte("key: value\nlist:\n - item1\n - item2\n"),
222 wantText: true,
223 },
224 {
225 name: "yml file",
226 filename: "config.yml",
227 content: []byte("key: value\n"),
228 wantText: true,
229 },
230 {
231 name: "json file",
232 filename: "data.json",
233 content: []byte(`{"key": "value"}`),
234 wantText: true,
235 },
236 {
237 name: "javascript file",
238 filename: "script.js",
239 content: []byte("console.log('hello');\n"),
240 wantText: true,
241 },
242 {
243 name: "typescript file",
244 filename: "script.ts",
245 content: []byte("const x: string = 'hello';\n"),
246 wantText: true,
247 },
248 {
249 name: "markdown file",
250 filename: "README.md",
251 content: []byte("# Title\n\nSome content\n"),
252 wantText: true,
253 },
254 {
255 name: "shell script",
256 filename: "script.sh",
257 content: []byte("#!/bin/bash\necho 'hello'\n"),
258 wantText: true,
259 },
260 {
261 name: "python file",
262 filename: "script.py",
263 content: []byte("print('hello')\n"),
264 wantText: true,
265 },
266 {
267 name: "xml file",
268 filename: "data.xml",
269 content: []byte("<?xml version=\"1.0\"?>\n<root></root>\n"),
270 wantText: true,
271 },
272 {
273 name: "plain text",
274 filename: "file.txt",
275 content: []byte("plain text content\n"),
276 wantText: true,
277 },
278 {
279 name: "css file",
280 filename: "style.css",
281 content: []byte("body { color: red; }\n"),
282 wantText: true,
283 },
284 {
285 name: "scss file",
286 filename: "style.scss",
287 content: []byte("$primary: blue;\nbody { color: $primary; }\n"),
288 wantText: true,
289 },
290 {
291 name: "sass file",
292 filename: "style.sass",
293 content: []byte("$primary: blue\nbody\n color: $primary\n"),
294 wantText: true,
295 },
296 {
297 name: "rust file",
298 filename: "main.rs",
299 content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"),
300 wantText: true,
301 },
302 {
303 name: "zig file",
304 filename: "main.zig",
305 content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"),
306 wantText: true,
307 },
308 {
309 name: "java file",
310 filename: "Main.java",
311 content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"),
312 wantText: true,
313 },
314 {
315 name: "c file",
316 filename: "main.c",
317 content: []byte("#include <stdio.h>\nint main() { return 0; }\n"),
318 wantText: true,
319 },
320 {
321 name: "cpp file",
322 filename: "main.cpp",
323 content: []byte("#include <iostream>\nint main() { return 0; }\n"),
324 wantText: true,
325 },
326 {
327 name: "fish shell",
328 filename: "script.fish",
329 content: []byte("#!/usr/bin/env fish\necho 'hello'\n"),
330 wantText: true,
331 },
332 {
333 name: "powershell file",
334 filename: "script.ps1",
335 content: []byte("Write-Host 'Hello, World!'\n"),
336 wantText: true,
337 },
338 {
339 name: "cmd batch file",
340 filename: "script.bat",
341 content: []byte("@echo off\necho Hello, World!\n"),
342 wantText: true,
343 },
344 {
345 name: "cmd file",
346 filename: "script.cmd",
347 content: []byte("@echo off\necho Hello, World!\n"),
348 wantText: true,
349 },
350 {
351 name: "binary exe",
352 filename: "binary.exe",
353 content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00},
354 wantText: false,
355 },
356 {
357 name: "png image",
358 filename: "image.png",
359 content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
360 wantText: false,
361 },
362 {
363 name: "jpeg image",
364 filename: "image.jpg",
365 content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46},
366 wantText: false,
367 },
368 {
369 name: "zip archive",
370 filename: "archive.zip",
371 content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00},
372 wantText: false,
373 },
374 {
375 name: "pdf file",
376 filename: "document.pdf",
377 content: []byte("%PDF-1.4\n%รขรฃรร\n"),
378 wantText: false,
379 },
380 }
381
382 for _, tt := range tests {
383 t.Run(tt.name, func(t *testing.T) {
384 t.Parallel()
385 filePath := filepath.Join(tempDir, tt.filename)
386 require.NoError(t, os.WriteFile(filePath, tt.content, 0o644))
387
388 got := isTextFile(filePath)
389 require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText)
390 })
391 }
392}