Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: introduce a LRU compiled style cache for the HTML formatter #938

Merged
merged 1 commit into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions formatters/html/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"sort"
"strconv"
"strings"
"sync"

"github.com/alecthomas/chroma/v2"
)
Expand Down Expand Up @@ -133,6 +134,7 @@ func New(options ...Option) *Formatter {
baseLineNumber: 1,
preWrapper: defaultPreWrapper,
}
f.styleCache = newStyleCache(f)
for _, option := range options {
option(f)
}
Expand Down Expand Up @@ -189,6 +191,7 @@ var (

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

// WriteCSS writes CSS style definitions (without any surrounding HTML).
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
css := f.styleToCSS(style)
css := f.styleCache.get(style)
// Special-case background as it is mapped to the outer ".chroma" class.
if _, err := fmt.Fprintf(w, "/* %s */ .%sbg { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
return err
Expand Down Expand Up @@ -563,3 +561,60 @@ func compressStyle(s string) string {
}
return strings.Join(out, ";")
}

const styleCacheLimit = 16

type styleCacheEntry struct {
style *chroma.Style
cache map[chroma.TokenType]string
}

type styleCache struct {
mu sync.Mutex
// LRU cache of compiled (and possibly compressed) styles. This is a slice
// because the cache size is small, and a slice is sufficiently fast for
// small N.
cache []styleCacheEntry
f *Formatter
}

func newStyleCache(f *Formatter) *styleCache {
return &styleCache{f: f}
}

func (l *styleCache) get(style *chroma.Style) map[chroma.TokenType]string {
l.mu.Lock()
defer l.mu.Unlock()
Copy link

@gkaikoponen gkaikoponen Feb 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be turned into a sync.RWLock? Most cases will probably only write to this cache once so it'd be a shame to introduce potential lock-contention if there are many reads.

Something like https://stackoverflow.com/a/70236841 should dodge the gotchas with finishing the read-unlock-defer before grabbing the write lock

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly, send a PR through if you're interested.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I started with an RWMutex but it simplifies the code being a plain Mutex.


// Look for an existing entry.
for i := len(l.cache) - 1; i >= 0; i-- {
entry := l.cache[i]
if entry.style == style {
// Top of the cache, no need to adjust the order.
if i == len(l.cache)-1 {
return entry.cache
}
// Move this entry to the end of the LRU
copy(l.cache[i:], l.cache[i+1:])
l.cache[len(l.cache)-1] = entry
return entry.cache
}
}

// No entry, create one.
cached := l.f.styleToCSS(style)
if !l.f.Classes {
for t, style := range cached {
cached[t] = compressStyle(style)
}
}
for t, style := range cached {
cached[t] = compressStyle(style)
}
// Evict the oldest entry.
if len(l.cache) >= styleCacheLimit {
l.cache = l.cache[0:copy(l.cache, l.cache[1:])]
}
l.cache = append(l.cache, styleCacheEntry{style: style, cache: cached})
return cached
}
19 changes: 16 additions & 3 deletions formatters/html/html_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ func TestTableLinkeableLineNumbers(t *testing.T) {

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

func TestTableLineNumberSpacing(t *testing.T) {
Expand Down Expand Up @@ -351,12 +351,25 @@ func TestReconfigureOptions(t *testing.T) {
}

func TestWriteCssWithAllClasses(t *testing.T) {
formatter := New()
formatter.allClasses = true
formatter := New(WithAllClasses(true))

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

assert.NoError(t, err)
assert.NotContains(t, buf.String(), ".chroma . {", "Generated css doesn't contain invalid css")
}

func TestStyleCache(t *testing.T) {
f := New()

assert.True(t, len(styles.Registry) > styleCacheLimit)

for _, style := range styles.Registry {
var buf bytes.Buffer
err := f.WriteCSS(&buf, style)
assert.NoError(t, err)
}

assert.Equal(t, styleCacheLimit, len(f.styleCache.cache))
}