Artifact b9dba9a8da19754c7fd857cb811237f41672c1f5f4f1a783cc37071ca89ef310:
- File
internal/highlight/highlight.go
— part of check-in
[c517ec9464]
at
2026-08-31 12:46:16
on branch trunk
— file pages draw a window, not the whole file
Chroma emits a span per token, so whole-file renders built DOMs nobody asked for: 198KB of build.c became 40,083 nodes and 137ms of parse. Lexing still starts at byte zero — a window opening inside a block comment cannot colour itself — but the iterator stops at the window's last line, so the tail is never tokenised. 103ms to 2.7ms for a first screen. Scroll fetches the next window; no-script gets a link. (user: quarry, size: 10949) [annotate] [blame] [check-ins using]
// Package highlight is quarry's thin chroma wrapper (0002 §2 lists the // Forgejo one as [COPY]; theirs is coupled to their settings and caches, // so this is a smaller fresh write). Output uses CSS classes, never // inline styles, so the two-theme story stays in one stylesheet. package highlight import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "regexp" "strings" "sync" "golang.org/x/sync/singleflight" "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" ) var formatter = chromahtml.New( chromahtml.WithClasses(true), chromahtml.PreventSurroundingPre(false), ) // MaxBytes is a SAFETY VALVE, not a routine limit: past it, content is // served as plain text rather than tokenised. // // It was briefly set at 256KB, which turned ten of sqlite's src files — // btree.c, select.c, vdbe.c, where.c, expr.c among them — permanently // grey. That is trading the feature for the fix. The real cost is not // file size but the DOM: chroma emits a span per token, so a 198KB // build.c becomes 40,083 spans and 137ms of DOMContentLoaded, against // 29ms for a plain 407KB btree.c. Highlighting AFTER load does not help // either — the same spans cost the same parse, just later. // // The answer is to highlight only what is on screen (windowing, the way // the diff pages already work), at which point no threshold is needed. // Until that lands this stays high enough to catch only content no // reader is really reading, and the per-blob cache below carries the // cost of everything else. const MaxBytes = 2 << 20 // TooBig reports whether Code will decline this content on size alone, // so a caller can say why rather than silently dropping the colour. func TooBig(code []byte) bool { return len(code) > MaxBytes } // cache memoises highlighted output. A blob is immutable, so its // rendering is too: the key is the content hash plus the lexer hints, // and a hit skips tokenising entirely. // // Bounded by TOTAL BYTES rather than entry count, because entries are // wildly uneven — one btree.c is worth thousands of small files, and a // count-based bound would hold a gigabyte or nothing depending on what // was browsed. On overflow the map is dropped wholesale: a generational // sweep with no bookkeeping, which for a read-through cache of // immutable content costs one recompute rather than correctness. var cache = struct { sync.Mutex m map[string]string bytes int }{m: map[string]string{}} // cacheBudget is how much highlighted HTML to retain. Roughly a dozen // large source files, or thousands of ordinary ones. // // Deliberately modest: quarry also runs embedded beside a zit install, // where a process helping itself to hundreds of megabytes to memoise // syntax colouring is rude. The memo is disposable — losing it costs // time, never correctness — so when in doubt it should hold less. const cacheBudget = 64 << 20 // group collapses concurrent misses on the SAME key into one call. // // Without it the cache does nothing for the case that matters. A // read-through memo only helps once a value is present, so N readers // arriving together on a cold key all miss, all tokenise the identical // immutable bytes, and the machine does N times the work at the moment // it is least able to afford it. Measured before this existed: one cold // request for sqlite's where.c took 0.42s, and ten concurrent ones took // 0.95s EACH — ten full tokenisations of the same file. // // Safe precisely because the value is a pure function of its key: // sharing one computation between callers cannot give any of them a // different answer than computing it themselves. var group singleflight.Group func cacheKey(code []byte, language, filename string) string { sum := sha256.Sum256(code) return hex.EncodeToString(sum[:16]) + "\x00" + language + "\x00" + filename } func cacheGet(key string) (string, bool) { cache.Lock() defer cache.Unlock() v, ok := cache.m[key] return v, ok } func cachePut(key, html string) { cache.Lock() defer cache.Unlock() if cache.bytes+len(html) > cacheBudget { cache.m = map[string]string{} cache.bytes = 0 } cache.m[key] = html cache.bytes += len(html) } // Code highlights source for the given language hint and/or filename; // either may be empty. ok is false when no lexer matched, or when the // content is past MaxBytes — callers fall back to an escaped <pre>. func Code(code []byte, language, filename string) (string, bool) { if TooBig(code) { return "", false } key := cacheKey(code, language, filename) if html, ok := cacheGet(key); ok { return html, html != "" } v, _, _ := group.Do(key, func() (any, error) { // Re-check inside the flight: the winner of a race may have // filled the entry while this caller waited for the group. if html, ok := cacheGet(key); ok { return html, nil } html, _ := code_(code, language, filename) // A miss is cached too, as the empty string: "no lexer matched" // is as expensive to discover as a match and just as stable. cachePut(key, html) return html, nil }) html, _ := v.(string) return html, html != "" } func code_(code []byte, language, filename string) (string, bool) { // Content analysis ONLY for a shebang. Chroma's Analyse is eager // enough to read an English README as SQL (is/and/or/in/where are // all keywords) and paint the prose red; a #! line is the one // content signal that names a language on purpose. lexer := lexerFor(code, language, filename) if lexer == nil { return "", false } it, err := lexer.Tokenise(nil, string(code)) if err != nil { return "", false } var buf bytes.Buffer if err := formatter.Format(&buf, styles.Get("github"), it); err != nil { return "", false } return buf.String(), true } // lexerFor resolves the lexer once, so the window path and the whole- // file path cannot disagree about what language a file is. func lexerFor(code []byte, language, filename string) chroma.Lexer { var lexer chroma.Lexer if language != "" { lexer = lexers.Get(language) } if lexer == nil && filename != "" { lexer = lexers.Match(filename) } if lexer == nil && bytes.HasPrefix(code, []byte("#!")) { lexer = lexers.Analyse(string(code)) } if lexer == nil { return nil } return chroma.Coalesce(lexer) } // Window highlights lines [from, from+count) of code, counting from // zero, and reports how many lines the content holds in total when it // reached the end. // // The point is what it does NOT do. Chroma emits a span per token, so // highlighting a whole large file builds a DOM nobody asked for: a // 198KB build.c becomes 40,083 nodes and 137ms of parse, and a 407KB // btree.c roughly twice that. Deferring the work does not help — the // same spans cost the same parse whenever they arrive. Only sending // fewer of them helps. // // Lexing still starts at byte zero, because it must: a window opening // inside a block comment or a string cannot be coloured correctly from // its own first byte. But chroma's iterator is pull-based, so this // consumes tokens only as far as the window's last line and then // stops — the tail of the file is never tokenised at all. func Window(code []byte, language, filename string, from, count int) (html string, total int, ok bool) { lexer := lexerFor(code, language, filename) if lexer == nil { return "", 0, false } it, err := lexer.Tokenise(nil, string(code)) if err != nil { return "", 0, false } end := from + count line := 0 var win []chroma.Token atEnd := true for tok := it(); tok != chroma.EOF; tok = it() { // A token may span lines (a block comment), so it is split at // newlines and each piece attributed to the line it sits on. text := tok.Value for text != "" { nl := strings.IndexByte(text, '\n') piece := text if nl >= 0 { piece = text[:nl+1] } if line >= from && line < end { win = append(win, chroma.Token{Type: tok.Type, Value: piece}) } if nl >= 0 { line++ text = text[nl+1:] if line >= end { // Everything the caller asked for is in hand; the // rest of the file is never tokenised. atEnd = false text = "" break } } else { text = "" } } if !atEnd { break } } if atEnd { total = line + 1 // the last line has no trailing newline } var buf bytes.Buffer if err := formatter.Format(&buf, styles.Get("github"), chroma.Literator(win...)); err != nil { return "", 0, false } return buf.String(), total, true } // CSS returns the chroma class rules for both themes: light rules bare, // dark rules inside a prefers-color-scheme block — matching the page // layout's theming (0002 §4 item 5: both themes). // // A chroma style only emits rules for token classes it colors // explicitly, so a class present in the light output but absent from // the dark output would fall through to its light (near-black) color on // the dark background — invisible text (ticket 030). Every such class // gets an explicit dark override at the base text color. func CSS() string { var light, dark strings.Builder formatter.WriteCSS(&light, styles.Get("github")) formatter.WriteCSS(&dark, styles.Get("github-dark")) darkCSS := dark.String() darkClasses := map[string]bool{} for _, m := range classRule.FindAllStringSubmatch(darkCSS, -1) { darkClasses[m[1]] = true } base := "#e6edf3" if m := baseColor.FindStringSubmatch(darkCSS); m != nil { base = m[1] } var missing strings.Builder for _, m := range classRule.FindAllStringSubmatch(light.String(), -1) { if !darkClasses[m[1]] { fmt.Fprintf(&missing, ".chroma .%s { color: %s }\n", m[1], base) darkClasses[m[1]] = true } } // Three theme states, matching the page: the media query is guarded // against an explicit light choice, and the same rules repeat under // an explicit dark one. Without both, a reader who picks light on a // dark-mode device gets dark code colours on a light page, and // picking dark on a light-mode device does nothing at all. darkRules := darkCSS + missing.String() return light.String() + "\n@media (prefers-color-scheme: dark) {\n" + prefixRules(darkRules, `:root:not([data-theme="light"]) `) + "\n}\n" + "\n" + prefixRules(darkRules, `:root[data-theme="dark"] `) + "\n" } var ( classRule = regexp.MustCompile(`\.chroma \.([a-zA-Z0-9]+) `) baseColor = regexp.MustCompile(`\.chroma \{[^}]*?color: (#[0-9a-fA-F]{3,8})`) ) // prefixRules qualifies every ".chroma" selector with a theme scope, so // one set of dark declarations can be emitted twice — once under the // media query and once under the explicit choice — without writing the // theme out by hand. A plain prefix rather than CSS nesting: same // result, and it asks nothing of the browser. func prefixRules(css, scope string) string { return strings.ReplaceAll(css, ".chroma ", scope+".chroma ") }