diff --git a/filter/bench_test.go b/filter/bench_test.go index 9e8659e..88da7f1 100644 --- a/filter/bench_test.go +++ b/filter/bench_test.go @@ -111,3 +111,33 @@ func BenchmarkRegexpFilterMultiCycle(b *testing.B) { } } } + +// BenchmarkRegexpFilterOverlapping benchmarks the regexp filter with a query +// that produces overlapping match ranges, exercising the mergeMatches path. +// Each line contains repeating "aabb" patterns; the query terms "aab" and "abb" +// produce match ranges that overlap (e.g. [0,3] and [1,4]), forcing mergeMatches +// to be called on every line. With 10k lines the aggregate allocation difference +// from in-place vs make([]int,2) becomes measurable. +func BenchmarkRegexpFilterOverlapping(b *testing.B) { + // Build a line with many "aabb" repeats so that "aab" and "abb" each match + // many times with overlapping ranges between the two terms. + base := strings.Repeat("aabb", 20) // 80 chars + lines := make([]line.Line, 10_000) + for i := range lines { + lines[i] = line.NewRaw(uint64(i), base, false, false) + } + + f := NewIgnoreCase() + // "aab" and "abb" share the middle "ab" in each "aabb" group, so their + // match ranges overlap after sorting by start position. + query := "aab abb" + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second) + ch := make(chan line.Line, len(lines)) + _ = f.Apply(ctx, lines, pipeline.ChanOutput(ch)) + cancel() + } +} diff --git a/filter/filter.go b/filter/filter.go index 46adc9f..f51aa7b 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -93,15 +93,9 @@ func matchOverlaps(a []int, b []int) bool { } // mergeMatches combines two overlapping match ranges into a single range -// spanning both. +// spanning both. It mutates and returns a to avoid a heap allocation. func mergeMatches(a []int, b []int) []int { - ret := make([]int, 2) - - // Note: In practice this should never happen - // because we're sorting by N[0] before calling - // this routine, but for completeness' sake... - ret[0] = min(a[0], b[0]) - - ret[1] = max(a[1], b[1]) - return ret + a[0] = min(a[0], b[0]) + a[1] = max(a[1], b[1]) + return a } diff --git a/filter/filter_test.go b/filter/filter_test.go index 5886bfd..2cae457 100644 --- a/filter/filter_test.go +++ b/filter/filter_test.go @@ -634,6 +634,31 @@ func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) { } } +func TestMergeMatches(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a []int + b []int + want []int + }{ + {"a before b overlapping", []int{1, 5}, []int{3, 7}, []int{1, 7}}, + {"b before a overlapping", []int{3, 7}, []int{1, 5}, []int{1, 7}}, + {"identical ranges", []int{2, 4}, []int{2, 4}, []int{2, 4}}, + {"a contains b", []int{0, 10}, []int{3, 7}, []int{0, 10}}, + {"adjacent ranges", []int{0, 3}, []int{3, 6}, []int{0, 6}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mergeMatches(tt.a, tt.b) + require.Equal(t, tt.want, got) + // Verify in-place mutation: got should be the same slice as a + require.Same(t, &tt.a[0], &got[0], "mergeMatches should mutate a in place") + }) + } +} + // TestMatchAcrossANSIColorBoundary verifies that filter queries match // against the ANSI-stripped text, so a pattern spanning characters // rendered in different colors still produces a match. diff --git a/internal/util/bench_test.go b/internal/util/bench_test.go new file mode 100644 index 0000000..bf621a6 --- /dev/null +++ b/internal/util/bench_test.go @@ -0,0 +1,40 @@ +package util + +import ( + "fmt" + "testing" +) + +func BenchmarkStripANSISequenceBulk(b *testing.B) { + b.Run("10k_plain_lines", func(b *testing.B) { + lines := make([]string, 10_000) + for i := range lines { + lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i) + } + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + for _, l := range lines { + StripANSISequence(l) + } + } + }) + + b.Run("10k_mixed_95pct_plain", func(b *testing.B) { + lines := make([]string, 10_000) + for i := range lines { + if i%20 == 0 { + lines[i] = fmt.Sprintf("\x1b[31mline %d\x1b[0m: this has ansi codes in it for color", i) + } else { + lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i) + } + } + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + for _, l := range lines { + StripANSISequence(l) + } + } + }) +} diff --git a/internal/util/util.go b/internal/util/util.go index 61e34da..3576e18 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -3,6 +3,7 @@ package util import ( "errors" "regexp" + "strings" "unicode" ) @@ -44,8 +45,13 @@ func ContainsUpper(query string) bool { // Global var used to strips ansi sequences var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})?)*[a-zA-Z]") -// StripANSISequence strips ANSI escape sequences from the given string +// StripANSISequence strips ANSI escape sequences from the given string. +// Fast-path: if the string contains no ESC byte, return it unchanged +// to avoid a regexp allocation. func StripANSISequence(s string) string { + if !strings.Contains(s, "\x1b") { + return s + } return reANSIEscapeChars.ReplaceAllString(s, "") } diff --git a/line/bench_test.go b/line/bench_test.go index 8b86738..ceabf2c 100644 --- a/line/bench_test.go +++ b/line/bench_test.go @@ -45,3 +45,32 @@ func BenchmarkMatchedLifecycle(b *testing.B) { } }) } + +// BenchmarkNewRawAndDisplay benchmarks creating Raw lines and calling +// DisplayString(), simulating peco's startup + render path for 10k lines. +func BenchmarkNewRawAndDisplay(b *testing.B) { + texts := make([]string, 10_000) + for i := range texts { + texts[i] = fmt.Sprintf("line %d: this is a typical plain text line without any ansi codes", i) + } + + b.Run("enableANSI_true_plain_text", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + for i, t := range texts { + r := NewRaw(uint64(i), t, false, true) + _ = r.DisplayString() + } + } + }) + + b.Run("enableANSI_false_plain_text", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + for i, t := range texts { + r := NewRaw(uint64(i), t, false, false) + _ = r.DisplayString() + } + } + }) +} diff --git a/line/raw.go b/line/raw.go index 5124bc9..74ad146 100644 --- a/line/raw.go +++ b/line/raw.go @@ -77,8 +77,12 @@ func NewRaw(id uint64, v string, enableSep bool, enableANSI bool) *Raw { src = rl.buf[:rl.sepLoc] } r := ansi.Parse(src) - rl.displayString = r.Stripped rl.ansiAttrs = r.Attrs + // Only store displayString when it actually differs from buf + // (i.e. when there's a separator or ANSI attributes) + if rl.sepLoc > -1 || r.Attrs != nil { + rl.displayString = r.Stripped + } } return rl @@ -121,10 +125,10 @@ func (rl *Raw) DisplayString() string { if i := rl.sepLoc; i > -1 { rl.displayString = util.StripANSISequence(rl.buf[:i]) - } else { - rl.displayString = util.StripANSISequence(rl.buf) + return rl.displayString } - return rl.displayString + // No separator: strip ANSI (fast-path returns buf unchanged when no ESC present) + return util.StripANSISequence(rl.buf) } // ANSIAttrs returns the run-length encoded ANSI attributes for this line.