on main help sign in
Merge pull request #22 from charmbracelet/perf-regex-improvements

Perf regex improvements

commit32284ff2595cb4667b1bd15ef45de0455f2b38f9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 14 Jun 2025, 09:02 (1 year ago) parent88a04200dd52ab
3 files changed+141−8
internal/diff/diff.go+8−4 internal/llm/tools/grep.go+56−4 internal/llm/tools/grep_test.go+77
internal/diff/diff.go+8−4
@@ -16,6 +16,12 @@ import (
1616 "github.com/sergi/go-diff/diffmatchpatch"
1717)
1818
19+// Pre-compiled regex patterns for better performance
20+var (
21+ hunkHeaderRegex = regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
22+ ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
23+)
24+
1925// -------------------------------------------------------------------------
2026// Core Types
2127// -------------------------------------------------------------------------
@@ -129,7 +135,6 @@ func ParseUnifiedDiff(diff string) (DiffResult, error) {
129135 var result DiffResult
130136 var currentHunk *Hunk
131137
132− hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
133138 lines := strings.Split(diff, "\n")
134139
135140 var oldLine, newLine int
@@ -150,7 +155,7 @@ func ParseUnifiedDiff(diff string) (DiffResult, error) {
150155 }
151156
152157 // Parse hunk headers
153− if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
158+ if matches := hunkHeaderRegex.FindStringSubmatch(line); matches != nil {
154159 if currentHunk != nil {
155160 result.Hunks = append(result.Hunks, *currentHunk)
156161 }
@@ -343,8 +348,7 @@ func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLin
343348
344349// applyHighlighting applies intra-line highlighting to a piece of text
345350func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
346− // Find all ANSI sequences in the content
347− ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
351+ // Find all ANSI sequences in the content using pre-compiled regex
348352 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
349353
350354 // Build a mapping of visible character positions to their actual indices
internal/llm/tools/grep.go+56−4
@@ -12,12 +12,63 @@ import (
1212 "sort"
1313 "strconv"
1414 "strings"
15+ "sync"
1516 "time"
1617
1718 "github.com/charmbracelet/crush/internal/config"
1819 "github.com/charmbracelet/crush/internal/fileutil"
1920)
2021
22+// regexCache provides thread-safe caching of compiled regex patterns
23+type regexCache struct {
24+ cache map[string]*regexp.Regexp
25+ mu sync.RWMutex
26+}
27+
28+// newRegexCache creates a new regex cache
29+func newRegexCache() *regexCache {
30+ return &regexCache{
31+ cache: make(map[string]*regexp.Regexp),
32+ }
33+}
34+
35+// get retrieves a compiled regex from cache or compiles and caches it
36+func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) {
37+ // Try to get from cache first (read lock)
38+ rc.mu.RLock()
39+ if regex, exists := rc.cache[pattern]; exists {
40+ rc.mu.RUnlock()
41+ return regex, nil
42+ }
43+ rc.mu.RUnlock()
44+
45+ // Compile the regex (write lock)
46+ rc.mu.Lock()
47+ defer rc.mu.Unlock()
48+
49+ // Double-check in case another goroutine compiled it while we waited
50+ if regex, exists := rc.cache[pattern]; exists {
51+ return regex, nil
52+ }
53+
54+ // Compile and cache the regex
55+ regex, err := regexp.Compile(pattern)
56+ if err != nil {
57+ return nil, err
58+ }
59+
60+ rc.cache[pattern] = regex
61+ return regex, nil
62+}
63+
64+// Global regex cache instances
65+var (
66+ searchRegexCache = newRegexCache()
67+ globRegexCache = newRegexCache()
68+ // Pre-compiled regex for glob conversion (used frequently)
69+ globBraceRegex = regexp.MustCompile(`\{([^}]+)\}`)
70+)
71+
2172type GrepParams struct {
2273 Pattern string `json:"pattern"`
2374 Path string `json:"path"`
@@ -266,7 +317,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
266317func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
267318 matches := []grepMatch{}
268319
269− regex, err := regexp.Compile(pattern)
320+ // Use cached regex compilation
321+ regex, err := searchRegexCache.get(pattern)
270322 if err != nil {
271323 return nil, fmt.Errorf("invalid regex pattern: %w", err)
272324 }
@@ -274,7 +326,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
274326 var includePattern *regexp.Regexp
275327 if include != "" {
276328 regexPattern := globToRegex(include)
277− includePattern, err = regexp.Compile(regexPattern)
329+ includePattern, err = globRegexCache.get(regexPattern)
278330 if err != nil {
279331 return nil, fmt.Errorf("invalid include pattern: %w", err)
280332 }
@@ -349,8 +401,8 @@ func globToRegex(glob string) string {
349401 regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
350402 regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
351403
352− re := regexp.MustCompile(`\{([^}]+)\}`)
353− regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
404+ // Use pre-compiled regex instead of compiling each time
405+ regexPattern = globBraceRegex.ReplaceAllStringFunc(regexPattern, func(match string) string {
354406 inner := match[1 : len(match)-1]
355407 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
356408 })
internal/llm/tools/grep_test.gonew file+77
@@ -0,0 +1,77 @@
1+package tools
2+
3+import (
4+ "regexp"
5+ "testing"
6+)
7+
8+func TestRegexCache(t *testing.T) {
9+ cache := newRegexCache()
10+
11+ // Test basic caching
12+ pattern := "test.*pattern"
13+ regex1, err := cache.get(pattern)
14+ if err != nil {
15+ t.Fatalf("Failed to compile regex: %v", err)
16+ }
17+
18+ regex2, err := cache.get(pattern)
19+ if err != nil {
20+ t.Fatalf("Failed to get cached regex: %v", err)
21+ }
22+
23+ // Should be the same instance (cached)
24+ if regex1 != regex2 {
25+ t.Error("Expected cached regex to be the same instance")
26+ }
27+
28+ // Test that it actually works
29+ if !regex1.MatchString("test123pattern") {
30+ t.Error("Regex should match test string")
31+ }
32+}
33+
34+func TestGlobToRegexCaching(t *testing.T) {
35+ // Test that globToRegex uses pre-compiled regex
36+ pattern1 := globToRegex("*.{js,ts}")
37+
38+ // Should not panic and should work correctly
39+ regex1, err := regexp.Compile(pattern1)
40+ if err != nil {
41+ t.Fatalf("Failed to compile glob regex: %v", err)
42+ }
43+
44+ if !regex1.MatchString("test.js") {
45+ t.Error("Glob regex should match .js files")
46+ }
47+ if !regex1.MatchString("test.ts") {
48+ t.Error("Glob regex should match .ts files")
49+ }
50+ if regex1.MatchString("test.go") {
51+ t.Error("Glob regex should not match .go files")
52+ }
53+}
54+
55+// Benchmark to show performance improvement
56+func BenchmarkRegexCacheVsCompile(b *testing.B) {
57+ cache := newRegexCache()
58+ pattern := "test.*pattern.*[0-9]+"
59+
60+ b.Run("WithCache", func(b *testing.B) {
61+ for b.Loop() {
62+ _, err := cache.get(pattern)
63+ if err != nil {
64+ b.Fatal(err)
65+ }
66+ }
67+ })
68+
69+ b.Run("WithoutCache", func(b *testing.B) {
70+ for b.Loop() {
71+ _, err := regexp.Compile(pattern)
72+ if err != nil {
73+ b.Fatal(err)
74+ }
75+ }
76+ })
77+}