// render.go — source to HTML: tree-sitter highlighting, coverage classes and
// note anchors baked into one
per chunk.
package main
import (
"bytes"
"html/template"
"path/filepath"
"sort"
"strconv"
"strings"
"unsafe"
sitter "github.com/tree-sitter/go-tree-sitter"
tsbash "github.com/tree-sitter/tree-sitter-bash/bindings/go"
tsc "github.com/tree-sitter/tree-sitter-c/bindings/go"
tscpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go"
tsgo "github.com/tree-sitter/tree-sitter-go/bindings/go"
tsjs "github.com/tree-sitter/tree-sitter-javascript/bindings/go"
tsjson "github.com/tree-sitter/tree-sitter-json/bindings/go"
tspython "github.com/tree-sitter/tree-sitter-python/bindings/go"
tsrust "github.com/tree-sitter/tree-sitter-rust/bindings/go"
tsts "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
)
// Lines per content-visibility chunk: the browser skips layout and paint of
// offscreen chunks wholesale, instead of managing one containment context
// per line.
const chunkLines = 800
// approximate rendered line height, for contain-intrinsic-size placeholders
const lineHeightPx = 19
// render builds the code HTML: chunks of chunkLines lines, each line an
// - with syntax spans, coverage classes and note anchors. One pre-sized
// builder, no fmt, minimal escaping — large files are browser-bound, so the
// output is kept as small and flat as possible.
func (f *File) render(src []byte, hl *highlighter) {
classes := hl.classify(f.Path, src)
f.Total = bytes.Count(src, []byte{'\n'})
if len(src) > 0 && src[len(src)-1] != '\n' {
f.Total++
}
f.Covered = 0
for i := 1; i < len(f.Cov) && i <= f.Total; i++ {
if f.Cov[i] != 0 {
f.Covered++
}
}
if f.Total > 0 {
f.Pct = f.Covered * 100 / f.Total
}
sort.Slice(f.Notes, func(i, j int) bool { return f.Notes[i].Start < f.Notes[j].Start })
for ni, n := range f.Notes {
for l := n.Start; l <= n.End && l > 0; l++ {
if _, taken := f.NoteAt[l]; !taken {
f.NoteAt[l] = ni
}
}
}
var b strings.Builder
b.Grow(len(src) + len(src)/2 + f.Total*48)
var num []byte
writeInt := func(n int) { num = strconv.AppendInt(num[:0], int64(n), 10); b.Write(num) }
start := 0
for ln := 1; ln <= f.Total; ln++ {
end := start
for end < len(src) && src[end] != '\n' {
end++
}
if (ln-1)%chunkLines == 0 {
if ln > 1 {
b.WriteString("
")
}
rem := f.Total - (ln - 1)
if rem > chunkLines {
rem = chunkLines
}
b.WriteString(``)
}
b.WriteString(`- ')
emitLine(&b, src[start:end], classes[start:end], hl.classNames)
b.WriteString("
")
start = end + 1
}
if f.Total > 0 {
b.WriteString("
")
}
f.Body = template.HTML(b.String())
}
// emitLine writes one line's spans, merging runs of the same class across
// whitespace-only gaps ("pub fn" is one span, not two) to keep the DOM flat.
func emitLine(b *strings.Builder, line []byte, cls []int16, names []string) {
open := int16(0)
i := 0
for i < len(line) {
j := i
c := cls[i]
for j < len(line) && cls[j] == c {
j++
}
if c == 0 {
allSpace := true
for k := i; k < j; k++ {
if line[k] != ' ' && line[k] != '\t' {
allSpace = false
break
}
}
if !(allSpace && open != 0 && j < len(line) && cls[j] == open) {
if open != 0 {
b.WriteString("")
open = 0
}
}
escapeTo(b, line[i:j])
} else {
if open != c {
if open != 0 {
b.WriteString("")
}
b.WriteString(``)
open = c
}
escapeTo(b, line[i:j])
}
i = j
}
if open != 0 {
b.WriteString("")
}
}
// escapeTo escapes the three characters that matter in text content.
func escapeTo(b *strings.Builder, s []byte) {
last := 0
for i, c := range s {
var rep string
switch c {
case '&':
rep = "&"
case '<':
rep = "<"
case '>':
rep = ">"
default:
continue
}
b.Write(s[last:i])
b.WriteString(rep)
last = i + 1
}
b.Write(s[last:])
}
// ── highlighting ──────────────────────────────────────────────────────────
// grammars lists every language the site can highlight: the extensions it
// claims, the vendored queries that paint it, and the cgo grammar. Query files
// concatenate the way upstream composes them — C's patterns underpin C++,
// JavaScript's underpin TypeScript — and the concatenation keeps the
// later-patterns-win order, so the supplement wins where both match.
var grammars = []struct {
exts []string
scms []string
lang func() unsafe.Pointer
}{
{[]string{".rs"}, []string{"rust"}, tsrust.Language},
{[]string{".go"}, []string{"go"}, tsgo.Language},
{[]string{".py", ".pyi"}, []string{"python"}, tspython.Language},
{[]string{".js", ".mjs", ".cjs", ".jsx"}, []string{"javascript", "javascript-params", "jsx"}, tsjs.Language},
{[]string{".ts", ".mts", ".cts"}, []string{"javascript", "typescript"}, tsts.LanguageTypescript},
{[]string{".tsx"}, []string{"javascript", "typescript", "jsx"}, tsts.LanguageTSX},
{[]string{".c"}, []string{"c"}, tsc.Language},
// .h is C++'s upstream claim, and the C++ grammar is a superset: C headers
// parse the same under it, C++ headers only parse under it
{[]string{".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"}, []string{"c", "cpp"}, tscpp.Language},
{[]string{".json"}, []string{"json"}, tsjson.Language},
{[]string{".sh", ".bash"}, []string{"bash"}, tsbash.Language},
}
// language is one grammar's share of the highlighter. Everything here is built
// at startup and never written again, which is what lets classify run from any
// number of goroutines at once.
type language struct {
lang *sitter.Language
query *sitter.Query
captureCls []int16 // capture index -> class id
}
type highlighter struct {
byExt map[string]*language
classNames []string
}
// resolveKey finds the theme syntax key for a capture name by stripping dot
// segments; "" when the theme has no entry at all.
func resolveKey(t *Theme, name string) string {
for key := name; key != ""; {
if s, ok := t.Syntax[key]; ok && s.Color != "" {
return key
}
if i := strings.LastIndex(key, "."); i >= 0 {
key = key[:i]
} else {
break
}
}
return ""
}
// nearFg reports whether a capture would render indistinguishably from plain
// foreground text in the theme. Spans for such captures are pure DOM weight.
func nearFg(t *Theme, name string) bool {
key := resolveKey(t, name)
if key == "" {
return true // no color -> falls through to fg anyway
}
return colorDist(hex6(t.Syntax[key].Color), hex6(t.Fg)) < 2000
}
func newHighlighter(dark, light *Theme) *highlighter {
h := &highlighter{byExt: map[string]*language{}, classNames: []string{""}}
for _, g := range grammars {
var scm strings.Builder
for _, name := range g.scms {
b, err := tfs.ReadFile("assets/" + name + ".scm")
if err != nil {
fatal("%v", err)
}
scm.Write(b)
}
l := &language{lang: sitter.NewLanguage(g.lang())}
q, qerr := sitter.NewQuery(l.lang, scm.String())
if qerr != nil {
fatal("%s query: %v", strings.Join(g.scms, "+"), qerr)
}
l.query = q
for _, name := range q.CaptureNames() {
l.captureCls = append(l.captureCls, h.classFor(dark, light, name))
}
for _, ext := range g.exts {
h.byExt[ext] = l
}
}
return h
}
// classFor interns the class id of a capture name, which doubles as a zed
// theme syntax key. Captures both themes paint (nearly) as foreground get id 0
// and no span at all — punctuation and friends are the bulk of all spans.
func (h *highlighter) classFor(dark, light *Theme, name string) int16 {
if nearFg(dark, name) && nearFg(light, name) {
return 0
}
key := resolveKey(dark, name)
if key == "" {
key = resolveKey(light, name)
}
cls := strings.ReplaceAll(key, ".", "-")
for i, n := range h.classNames {
if n == cls {
return int16(i)
}
}
h.classNames = append(h.classNames, cls)
return int16(len(h.classNames) - 1)
}
type paint struct {
start, end uint
pattern uint
cls int16
}
// classify is safe for concurrent use: the shared per-language query is
// immutable, and parser and cursor are per-call. Extensions no grammar claims
// come back unclassified rather than guessed at.
//
// Matches arrive already filtered by their #eq?/#match?/#any-of? predicates —
// go-tree-sitter evaluates those in QueryMatches.Next — so every capture here
// is one the query really meant.
func (h *highlighter) classify(path string, src []byte) []int16 {
classes := make([]int16, len(src))
l := h.byExt[strings.ToLower(filepath.Ext(path))]
if l == nil {
return classes
}
parser := sitter.NewParser()
defer parser.Close()
parser.SetLanguage(l.lang)
tree := parser.Parse(src, nil)
defer tree.Close()
qc := sitter.NewQueryCursor()
defer qc.Close()
matches := qc.Matches(l.query, tree.RootNode(), src)
var paints []paint
for m := matches.Next(); m != nil; m = matches.Next() {
for _, c := range m.Captures {
if cls := l.captureCls[c.Index]; cls != 0 {
paints = append(paints, paint{c.Node.StartByte(), c.Node.EndByte(), m.PatternIndex, cls})
}
}
}
// later patterns in the query paint over earlier ones; two captures of one
// pattern must not overlap, since this sort leaves their order arbitrary
sort.Slice(paints, func(i, j int) bool { return paints[i].pattern < paints[j].pattern })
for _, p := range paints {
for i := p.start; i < p.end && int(i) < len(classes); i++ {
classes[i] = p.cls
}
}
return classes
}