Skip to content

Commit 6dd9f26

Browse files
authored
feat: introduce a LRU compiled style cache for the HTML formatter (#938)
``` 🐚 ~/dev/chroma $ benchcmp before.txt after.txt benchmark old ns/op new ns/op delta BenchmarkHTMLFormatter-8 160560 77797 -51.55% benchmark old allocs new allocs delta BenchmarkHTMLFormatter-8 1267 459 -63.77% benchmark old bytes new bytes delta BenchmarkHTMLFormatter-8 52568 25067 -52.32% ```
1 parent 898d467 commit 6dd9f26

File tree

2 files changed

+78
-10
lines changed

2 files changed

+78
-10
lines changed

formatters/html/html.go

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"sort"
88
"strconv"
99
"strings"
10+
"sync"
1011

1112
"github.com/alecthomas/chroma/v2"
1213
)
@@ -133,6 +134,7 @@ func New(options ...Option) *Formatter {
133134
baseLineNumber: 1,
134135
preWrapper: defaultPreWrapper,
135136
}
137+
f.styleCache = newStyleCache(f)
136138
for _, option := range options {
137139
option(f)
138140
}
@@ -189,6 +191,7 @@ var (
189191

190192
// Formatter that generates HTML.
191193
type Formatter struct {
194+
styleCache *styleCache
192195
standalone bool
193196
prefix string
194197
Classes bool // Exported field to detect when classes are being used
@@ -221,12 +224,7 @@ func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Ite
221224
//
222225
// OTOH we need to be super careful about correct escaping...
223226
func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) { // nolint: gocyclo
224-
css := f.styleToCSS(style)
225-
if !f.Classes {
226-
for t, style := range css {
227-
css[t] = compressStyle(style)
228-
}
229-
}
227+
css := f.styleCache.get(style)
230228
if f.standalone {
231229
fmt.Fprint(w, "<html>\n")
232230
if f.Classes {
@@ -420,7 +418,7 @@ func (f *Formatter) tabWidthStyle() string {
420418

421419
// WriteCSS writes CSS style definitions (without any surrounding HTML).
422420
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
423-
css := f.styleToCSS(style)
421+
css := f.styleCache.get(style)
424422
// Special-case background as it is mapped to the outer ".chroma" class.
425423
if _, err := fmt.Fprintf(w, "/* %s */ .%sbg { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
426424
return err
@@ -563,3 +561,60 @@ func compressStyle(s string) string {
563561
}
564562
return strings.Join(out, ";")
565563
}
564+
565+
const styleCacheLimit = 16
566+
567+
type styleCacheEntry struct {
568+
style *chroma.Style
569+
cache map[chroma.TokenType]string
570+
}
571+
572+
type styleCache struct {
573+
mu sync.Mutex
574+
// LRU cache of compiled (and possibly compressed) styles. This is a slice
575+
// because the cache size is small, and a slice is sufficiently fast for
576+
// small N.
577+
cache []styleCacheEntry
578+
f *Formatter
579+
}
580+
581+
func newStyleCache(f *Formatter) *styleCache {
582+
return &styleCache{f: f}
583+
}
584+
585+
func (l *styleCache) get(style *chroma.Style) map[chroma.TokenType]string {
586+
l.mu.Lock()
587+
defer l.mu.Unlock()
588+
589+
// Look for an existing entry.
590+
for i := len(l.cache) - 1; i >= 0; i-- {
591+
entry := l.cache[i]
592+
if entry.style == style {
593+
// Top of the cache, no need to adjust the order.
594+
if i == len(l.cache)-1 {
595+
return entry.cache
596+
}
597+
// Move this entry to the end of the LRU
598+
copy(l.cache[i:], l.cache[i+1:])
599+
l.cache[len(l.cache)-1] = entry
600+
return entry.cache
601+
}
602+
}
603+
604+
// No entry, create one.
605+
cached := l.f.styleToCSS(style)
606+
if !l.f.Classes {
607+
for t, style := range cached {
608+
cached[t] = compressStyle(style)
609+
}
610+
}
611+
for t, style := range cached {
612+
cached[t] = compressStyle(style)
613+
}
614+
// Evict the oldest entry.
615+
if len(l.cache) >= styleCacheLimit {
616+
l.cache = l.cache[0:copy(l.cache, l.cache[1:])]
617+
}
618+
l.cache = append(l.cache, styleCacheEntry{style: style, cache: cached})
619+
return cached
620+
}

formatters/html/html_test.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ func TestTableLinkeableLineNumbers(t *testing.T) {
222222

223223
assert.Contains(t, buf.String(), `id="line1"><a class="lnlinks" href="#line1">1</a>`)
224224
assert.Contains(t, buf.String(), `id="line5"><a class="lnlinks" href="#line5">5</a>`)
225-
assert.Contains(t, buf.String(), `/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }`, buf.String())
225+
assert.Contains(t, buf.String(), `/* LineLink */ .chroma .lnlinks { outline:none;text-decoration:none;color:inherit }`, buf.String())
226226
}
227227

228228
func TestTableLineNumberSpacing(t *testing.T) {
@@ -351,12 +351,25 @@ func TestReconfigureOptions(t *testing.T) {
351351
}
352352

353353
func TestWriteCssWithAllClasses(t *testing.T) {
354-
formatter := New()
355-
formatter.allClasses = true
354+
formatter := New(WithAllClasses(true))
356355

357356
var buf bytes.Buffer
358357
err := formatter.WriteCSS(&buf, styles.Fallback)
359358

360359
assert.NoError(t, err)
361360
assert.NotContains(t, buf.String(), ".chroma . {", "Generated css doesn't contain invalid css")
362361
}
362+
363+
func TestStyleCache(t *testing.T) {
364+
f := New()
365+
366+
assert.True(t, len(styles.Registry) > styleCacheLimit)
367+
368+
for _, style := range styles.Registry {
369+
var buf bytes.Buffer
370+
err := f.WriteCSS(&buf, style)
371+
assert.NoError(t, err)
372+
}
373+
374+
assert.Equal(t, styleCacheLimit, len(f.styleCache.cache))
375+
}

0 commit comments

Comments
 (0)