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}
393
394func TestColumnMatch(t *testing.T) {
395	t.Parallel()
396
397	// Test both implementations
398	for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
399		"regex": searchFilesWithRegex,
400		"rg": func(pattern, path, include string) ([]grepMatch, error) {
401			return searchWithRipgrep(t.Context(), pattern, path, include)
402		},
403	} {
404		t.Run(name, func(t *testing.T) {
405			t.Parallel()
406
407			if name == "rg" && getRg() == "" {
408				t.Skip("rg is not in $PATH")
409			}
410
411			matches, err := fn("THIS", "./testdata/", "")
412			require.NoError(t, err)
413			require.Len(t, matches, 1)
414			match := matches[0]
415			require.Equal(t, 2, match.lineNum)
416			require.Equal(t, 14, match.charNum)
417			require.Equal(t, "I wanna grep THIS particular word", match.lineText)
418			require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
419		})
420	}
421}