// Package highlight renders syntax-highlighted file and diff HTML with chroma. // // Chroma runs in-process and is safe for concurrent use, so there is no // worker pool. package highlight import ( "bytes" "fmt" "html" "path/filepath" "strings" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" "github.com/gabriel-vasile/mimetype" "hearthforge/internal/util" ) // BinaryDetectBytes is how much of a file is scanned for a NUL byte. const BinaryDetectBytes = 8000 const ( maxFileCache = 500 maxDiffCache = 500 maxFileCacheBytes = 64 << 20 maxDiffCacheBytes = 64 << 20 ) // ClassPrefix keeps chroma token classes out of the app's own class namespace. const ClassPrefix = "ch-" // Highlighter holds the bounded render caches. Create one at startup. type Highlighter struct { inlineMaxBytes int64 files *util.Cache[string, FileView] diffs *util.Cache[string, []RenderedDiffFile] } // New returns a Highlighter. inlineMaxBytes is config.InlineMaxBytes. func New(inlineMaxBytes int64) *Highlighter { return &Highlighter{ inlineMaxBytes: inlineMaxBytes, files: util.NewSizedCache[string](maxFileCache, maxFileCacheBytes, 0, fileViewSize), diffs: util.NewSizedCache[string](maxDiffCache, maxDiffCacheBytes, 0, diffSize), } } func fileViewSize(v FileView) int64 { return int64(len(v.HTML)) } // diffSize approximates the memory of a rendered diff by its string bytes. func diffSize(files []RenderedDiffFile) int64 { var n int for _, f := range files { n += len(f.OldPath) + len(f.NewPath) for _, h := range f.Hunks { n += len(h.Header) for _, r := range h.Rows { n += len(r.HTML) + len(r.Type) } } } return int64(n) } // HasBinaryContent reports whether the first BinaryDetectBytes contain a NUL. // Git uses the same rule. Other control characters stay text. func HasBinaryContent(content []byte) bool { if len(content) > BinaryDetectBytes { content = content[:BinaryDetectBytes] } return bytes.IndexByte(content, 0) >= 0 } // DetectLang returns the chroma lexer name for a path, or "" when none matches. // Chroma matches on filename globs, so Dockerfile and Makefile work too. func DetectLang(path string) string { lexer := lexers.Match(filepath.Base(path)) if lexer == nil { return "" } return lexer.Config().Name } // FileView describes how a blob should be shown. // Type is one of "inline", "download", "binary" or "media". type FileView struct { Type string HTML string Lines int Size int64 MimeType string } // ServeFile classifies a blob and renders inline files as a line-numbered table. // cacheKey may be empty to skip caching. func (h *Highlighter) ServeFile(content []byte, filename, cacheKey string) FileView { if v, ok := h.files.Get(cacheKey); ok { return v } size := int64(len(content)) mt := mimetype.Detect(content) switch strings.SplitN(mt.String(), "/", 2)[0] { case "image", "audio", "video": return FileView{Type: "media", MimeType: mt.String(), Size: size} } if HasBinaryContent(content) { return FileView{Type: "binary", Size: size} } if size > h.inlineMaxBytes { return FileView{Type: "download", Size: size} } text := string(content) view := FileView{ Type: "inline", HTML: blobTable(text, DetectLang(filename)), Lines: strings.Count(text, "\n") + 1, Size: size, } if cacheKey != "" { h.files.Set(cacheKey, view) } return view } // splitLines splits source text into display lines, dropping the trailing // empty entry a final newline produces. func splitLines(text string) []string { lines := strings.Split(text, "\n") if n := len(lines); n > 0 && lines[n-1] == "" { lines = lines[:n-1] } return lines } func blobTable(text, lang string) string { // One row per split entry, so a file ending in a newline gets a final // empty row. The previous highlighter did the same, so the line numbers // still match. // Chroma rewrites CRLF and a lone CR to LF before tokenising. Split the // same way, or a file with a bare CR gets shifted line numbers. src := strings.Split(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n"), "\n") rendered := highlightLines(text, lang, html.EscapeString) var b strings.Builder b.WriteString(`
| %d | %s |