grep_test.go

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