diff options
Diffstat (limited to 'vrsite/render.go')
| -rw-r--r-- | vrsite/render.go | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/vrsite/render.go b/vrsite/render.go new file mode 100644 index 0000000..de21fc5 --- /dev/null +++ b/vrsite/render.go @@ -0,0 +1,289 @@ +// render.go — source to HTML: tree-sitter highlighting, coverage classes and +// note anchors baked into one <ol> per chunk. +package main + +import ( + "bytes" + "html/template" + "sort" + "strconv" + "strings" + + sitter "github.com/tree-sitter/go-tree-sitter" + tsrust "github.com/tree-sitter/tree-sitter-rust/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: <ol> chunks of chunkLines lines, each line an +// <li> 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("</ol>") + } + rem := f.Total - (ln - 1) + if rem > chunkLines { + rem = chunkLines + } + b.WriteString(`<ol class="code" style="counter-reset: ln `) + writeInt(ln - 1) + b.WriteString(`; --h: `) + writeInt(rem * lineHeightPx) + b.WriteString(`px">`) + } + b.WriteString(`<li id="L`) + writeInt(ln) + b.WriteByte('"') + mask := uint8(0) + if ln < len(f.Cov) { + mask = f.Cov[ln] + } + ni, noted := f.NoteAt[ln] + if mask != 0 || noted { + b.WriteString(` class="`) + if mask != 0 { + b.WriteString("cov am") + writeInt(int(mask)) + if noted { + b.WriteString(" noted") + } + } else { + b.WriteString("noted") + } + b.WriteByte('"') + } + if noted { + b.WriteString(` data-note="note-`) + writeInt(f.Notes[ni].ID) + b.WriteByte('"') + } + b.WriteByte('>') + emitLine(&b, src[start:end], classes[start:end], hl.classNames) + b.WriteString("</li>") + start = end + 1 + } + if f.Total > 0 { + b.WriteString("</ol>") + } + 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("</span>") + open = 0 + } + } + escapeTo(b, line[i:j]) + } else { + if open != c { + if open != 0 { + b.WriteString("</span>") + } + b.WriteString(`<span class="s-`) + b.WriteString(names[c]) + b.WriteString(`">`) + open = c + } + escapeTo(b, line[i:j]) + } + i = j + } + if open != 0 { + b.WriteString("</span>") + } +} + +// 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 ────────────────────────────────────────────────────────── + +type highlighter struct { + lang *sitter.Language + query *sitter.Query + captureCls []int16 // capture index -> class id + 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{classNames: []string{""}} + h.lang = sitter.NewLanguage(tsrust.Language()) + scm, _ := tfs.ReadFile("assets/rust.scm") + q, qerr := sitter.NewQuery(h.lang, string(scm)) + if qerr != nil { + fatal("rust query: %v", qerr) + } + h.query = q + for _, name := range q.CaptureNames() { + // skip captures that both themes paint (nearly) as foreground — + // punctuation and friends are the bulk of all spans + if nearFg(dark, name) && nearFg(light, name) { + h.captureCls = append(h.captureCls, 0) + continue + } + key := resolveKey(dark, name) + if key == "" { + key = resolveKey(light, name) + } + cls := strings.ReplaceAll(key, ".", "-") + id := int16(0) + for i, n := range h.classNames { + if n == cls { + id = int16(i) + } + } + if id == 0 { + h.classNames = append(h.classNames, cls) + id = int16(len(h.classNames) - 1) + } + h.captureCls = append(h.captureCls, id) + } + return h +} + +type paint struct { + start, end uint + pattern uint + cls int16 +} + +// classify is safe for concurrent use: the shared query is immutable, and +// parser and cursor are per-call. +func (h *highlighter) classify(path string, src []byte) []int16 { + classes := make([]int16, len(src)) + if !strings.HasSuffix(path, ".rs") { + return classes + } + parser := sitter.NewParser() + defer parser.Close() + parser.SetLanguage(h.lang) + tree := parser.Parse(src, nil) + defer tree.Close() + + qc := sitter.NewQueryCursor() + defer qc.Close() + matches := qc.Matches(h.query, tree.RootNode(), src) + var paints []paint + for m := matches.Next(); m != nil; m = matches.Next() { + for _, c := range m.Captures { + if cls := h.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 + 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 +} |
