markdown.go

  1package common
  2
  3import (
  4	"image/color"
  5	"sync"
  6
  7	"charm.land/glamour/v2"
  8	"git.secluded.site/crush/internal/ui/styles"
  9	"git.secluded.site/crush/internal/ui/xchroma"
 10	"github.com/alecthomas/chroma/v2/formatters"
 11)
 12
 13const formatterName = "crush"
 14
 15func init() {
 16	// NOTE: Glamour does not offer us an option to pass the formatter
 17	// implementation directly. We need to register and use by name.
 18	var zero color.Color
 19	formatters.Register(formatterName, xchroma.Formatter(zero, nil))
 20}
 21
 22// mdCacheMu guards mdCache and quietMDCache.
 23//
 24// Lock ordering: when both mdCacheMu and rendererLocksMu are
 25// needed (only in InvalidateMarkdownRendererCache), acquire
 26// mdCacheMu FIRST, then rendererLocksMu. No other call site may
 27// hold rendererLocksMu while acquiring mdCacheMu.
 28var (
 29	mdCacheMu    sync.Mutex
 30	mdCache      = map[int]*glamour.TermRenderer{}
 31	quietMDCache = map[int]*glamour.TermRenderer{}
 32)
 33
 34// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
 35// the given styles and width. Renderers are memoized per width and shared
 36// across callers; call InvalidateMarkdownRendererCache when the active
 37// styles change.
 38//
 39// The returned renderer is NOT safe for concurrent Render calls
 40// (goldmark's BlockStack carries state across the public Render
 41// API). Crush's TUI is single-threaded so production never
 42// contends, but parallel callers (most notably parallel tests)
 43// must serialize via [LockMarkdownRenderer]. Treat the renderer
 44// as effectively pinned to one goroutine at a time.
 45func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
 46	mdCacheMu.Lock()
 47	defer mdCacheMu.Unlock()
 48	if r, ok := mdCache[width]; ok {
 49		return r
 50	}
 51	r, _ := glamour.NewTermRenderer(
 52		glamour.WithStyles(sty.Markdown),
 53		glamour.WithWordWrap(width),
 54		glamour.WithChromaFormatter(formatterName),
 55	)
 56	mdCache[width] = r
 57	return r
 58}
 59
 60// QuietMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
 61// (plain text with structure) and the given width. Renderers are memoized per
 62// width and shared across callers. Same concurrency contract as
 63// [MarkdownRenderer]: serialize via [LockMarkdownRenderer].
 64func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
 65	mdCacheMu.Lock()
 66	defer mdCacheMu.Unlock()
 67	if r, ok := quietMDCache[width]; ok {
 68		return r
 69	}
 70	r, _ := glamour.NewTermRenderer(
 71		glamour.WithStyles(sty.QuietMarkdown),
 72		glamour.WithWordWrap(width),
 73		glamour.WithChromaFormatter(formatterName),
 74	)
 75	quietMDCache[width] = r
 76	return r
 77}
 78
 79// InvalidateMarkdownRendererCache drops every cached renderer
 80// AND every per-renderer mutex in a single atomic critical
 81// section so the two maps cannot disagree mid-toggle. Call this
 82// whenever the active styles change so subsequent renderers
 83// pick up the new ansi.StyleConfig.
 84//
 85// Existing holders of an old mutex (mid-Render goroutines) keep
 86// their reference safely; new renderers minted after the
 87// invalidation get freshly minted mutexes.
 88//
 89// Lock ordering: mdCacheMu is acquired first, then
 90// rendererLocksMu — see the comments on each mutex.
 91func InvalidateMarkdownRendererCache() {
 92	mdCacheMu.Lock()
 93	defer mdCacheMu.Unlock()
 94	rendererLocksMu.Lock()
 95	defer rendererLocksMu.Unlock()
 96
 97	mdCache = map[int]*glamour.TermRenderer{}
 98	quietMDCache = map[int]*glamour.TermRenderer{}
 99	rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
100}
101
102// rendererLocksMu guards rendererLocks. We key per-renderer
103// mutexes by pointer so the lock granularity matches the
104// renderer cache granularity (one mutex per (width, palette)
105// renderer instance, not one mutex for the entire cache).
106//
107// Lock ordering: when both mdCacheMu and rendererLocksMu are
108// needed (only in InvalidateMarkdownRendererCache), acquire
109// mdCacheMu FIRST, then rendererLocksMu.
110var (
111	rendererLocksMu sync.Mutex
112	rendererLocks   = map[*glamour.TermRenderer]*sync.Mutex{}
113)
114
115// LockMarkdownRenderer returns the per-renderer mutex used to
116// serialize concurrent Render calls on a shared
117// [glamour.TermRenderer] instance. The returned [*sync.Mutex] is
118// stable for the lifetime of the renderer (i.e. until
119// [InvalidateMarkdownRendererCache] is called).
120//
121// Callers that issue more than one Render call in the same
122// logical operation should hold the mutex for the entire
123// sequence so other goroutines do not interleave their own
124// Render calls and corrupt the renderer state. F8's
125// streamingMarkdown is the immediate consumer; other call
126// sites that today issue exactly one Render call per item
127// render are safe without locking under the single-threaded
128// TUI Update loop, but should adopt this lock if they ever run
129// in parallel (e.g. background prerender workers).
130func LockMarkdownRenderer(r *glamour.TermRenderer) *sync.Mutex {
131	rendererLocksMu.Lock()
132	defer rendererLocksMu.Unlock()
133	if mu, ok := rendererLocks[r]; ok {
134		return mu
135	}
136	mu := &sync.Mutex{}
137	rendererLocks[r] = mu
138	return mu
139}