streaming_markdown.go

  1package chat
  2
  3import (
  4	"strings"
  5
  6	"charm.land/glamour/v2"
  7	"git.secluded.site/crush/internal/ui/common"
  8)
  9
 10// streamingMarkdown caches a "stable prefix" glamour render so each
 11// streaming flush only re-renders the trailing portion of the
 12// document. F8 of docs/notes/2026-05-12-chat-rendering-perf.md.
 13//
 14// The boundary between "stable" and "trailing" is detected by
 15// [findSafeMarkdownBoundary]: a position immediately after a blank
 16// line at which we can prove no markdown construct is open
 17// (fenced code block, list, table, block quote, setext header).
 18//
 19// Two renders concatenated are NOT generally equal to a single
 20// render of the whole document โ€” glamour's wrap state is reset
 21// between calls. The boundary check is therefore deliberately
 22// conservative; whenever it has the slightest doubt the call
 23// falls back to a full render and the cache is left untouched.
 24//
 25// Invariants:
 26//
 27//   - stablePrefix is always a literal byte prefix of the most
 28//     recently rendered content. If a new content does not have
 29//     stablePrefix as its prefix the cache is dropped.
 30//   - stablePrefixRender is the glamour render of stablePrefix
 31//     alone, with surrounding whitespace trimmed for clean
 32//     concatenation.
 33//   - width is the glamour wrap width that produced
 34//     stablePrefixRender. A width change drops the cache.
 35type streamingMarkdown struct {
 36	width              int
 37	stablePrefix       string
 38	stablePrefixRender string
 39}
 40
 41// Reset drops every cached field. After Reset the next Render call
 42// is guaranteed to be a full render.
 43func (s *streamingMarkdown) Reset() {
 44	s.width = 0
 45	s.stablePrefix = ""
 46	s.stablePrefixRender = ""
 47}
 48
 49// Render returns the glamour render of content at the given width,
 50// reusing the cached stable-prefix render when it is safe to do so.
 51// On any uncertainty the call falls back to a full render via
 52// renderer and leaves the cache untouched (or drops it).
 53//
 54// The returned string has its trailing newline trimmed to match
 55// the existing renderMarkdown contract on AssistantMessageItem.
 56//
 57// Concurrency: glamour's Render is stateful and not safe for
 58// concurrent invocation on a shared renderer. Crush's TUI is
 59// single-threaded so production never contends, but parallel
 60// callers (most notably the test suite) must serialize. We hold
 61// [common.LockMarkdownRenderer] for the entire prefix +
 62// trailing render sequence so other goroutines cannot interleave
 63// their own Render calls and corrupt goldmark's BlockStack.
 64func (s *streamingMarkdown) Render(content string, width int, renderer *glamour.TermRenderer) string {
 65	mu := common.LockMarkdownRenderer(renderer)
 66	mu.Lock()
 67	defer mu.Unlock()
 68	full := func() string {
 69		out, err := renderer.Render(content)
 70		if err != nil {
 71			return content
 72		}
 73		return strings.TrimSuffix(out, "\n")
 74	}
 75
 76	// Width change OR content not a prefix-extension: drop cache,
 77	// full render, optionally try to seed a fresh boundary on this
 78	// call (step "f" in the design note).
 79	if width != s.width || !strings.HasPrefix(content, s.stablePrefix) {
 80		s.Reset()
 81		s.width = width
 82		out := full()
 83		s.tryAdvanceFromEmpty(content, width, renderer)
 84		return out
 85	}
 86
 87	boundary := findSafeMarkdownBoundary(content)
 88	if boundary < 0 {
 89		// No safe boundary anywhere yet. Full render; do not
 90		// modify the cache (a future flush may find one).
 91		return full()
 92	}
 93
 94	if boundary <= len(s.stablePrefix) {
 95		// Cached prefix already covers an at-least-as-late
 96		// boundary. Render the trailing partial fresh and glue.
 97		trail := content[len(s.stablePrefix):]
 98		return glueRenders(s.stablePrefixRender, s.renderTrailing(trail, renderer))
 99	}
100
101	// boundary > len(stablePrefix): we have a NEW chunk of safe
102	// content. Render the new chunk, append to stablePrefixRender,
103	// promote the boundary, then render the remaining trail.
104	newChunk := content[len(s.stablePrefix):boundary]
105	newChunkRender := s.renderTrailing(newChunk, renderer)
106	s.stablePrefixRender = glueRenders(s.stablePrefixRender, newChunkRender)
107	s.stablePrefix = content[:boundary]
108
109	trail := content[boundary:]
110	if trail == "" {
111		// boundary == len(content): no trailing content. Returning
112		// the cached prefix render directly is correct.
113		return s.stablePrefixRender
114	}
115	return glueRenders(s.stablePrefixRender, s.renderTrailing(trail, renderer))
116}
117
118// tryAdvanceFromEmpty seeds the cache from a fresh state. We've
119// already paid the cost of a full render of `content`; if there is
120// a safe boundary inside it, render the prefix once more (cheap
121// relative to the full render we just did) and cache it so the
122// next flush can avoid the full work.
123//
124// This is the optional optimisation step "f" from the design
125// note. We render the prefix separately rather than try to
126// recover it from the full render output because two renders
127// concatenated โ‰  a single render of the whole, and we prefer the
128// cached prefix render to be byte-for-byte what we'd produce on a
129// future cached call.
130func (s *streamingMarkdown) tryAdvanceFromEmpty(content string, width int, renderer *glamour.TermRenderer) {
131	boundary := findSafeMarkdownBoundary(content)
132	if boundary <= 0 {
133		return
134	}
135	prefix := content[:boundary]
136	out, err := renderer.Render(prefix)
137	if err != nil {
138		return
139	}
140	s.stablePrefix = prefix
141	s.stablePrefixRender = trimGlamourMargins(out)
142	s.width = width
143}
144
145// renderTrailing renders a trailing partial as a fresh glamour
146// document and trims the surrounding whitespace so it can be
147// concatenated to a cached prefix render without doubled blank
148// lines.
149func (s *streamingMarkdown) renderTrailing(text string, renderer *glamour.TermRenderer) string {
150	if text == "" {
151		return ""
152	}
153	out, err := renderer.Render(text)
154	if err != nil {
155		return text
156	}
157	return trimGlamourMargins(out)
158}
159
160// glueRenders concatenates two glamour-rendered fragments with a
161// single blank line separator. Glamour outputs typically carry
162// their own surrounding margins; trimming on both sides and
163// gluing with "\n\n" prevents the visible double-margin seam.
164//
165// Empty fragments are tolerated so the same helper works for the
166// "boundary == len(content)" path where there is no trailing
167// segment.
168func glueRenders(prefix, trail string) string {
169	prefix = trimGlamourMargins(prefix)
170	trail = trimGlamourMargins(trail)
171	switch {
172	case prefix == "" && trail == "":
173		return ""
174	case prefix == "":
175		return trail
176	case trail == "":
177		return prefix
178	default:
179		return prefix + "\n\n" + trail
180	}
181}
182
183// trimGlamourMargins strips leading and trailing whitespace
184// (including newlines) from a glamour-rendered fragment.
185// Glamour adds a leading blank line for documents that open with
186// a heading or paragraph, plus a trailing newline; both must be
187// removed before concatenation.
188func trimGlamourMargins(s string) string {
189	return strings.Trim(s, " \t\n")
190}
191
192// findSafeMarkdownBoundary returns the byte offset of the END of
193// the latest safe boundary in content, i.e. the offset such that
194// content[:boundary] is a valid stable-prefix candidate. The
195// returned offset always points immediately after a blank-line
196// separator, so concatenating a fresh render of content[boundary:]
197// to a cached render of content[:boundary] does not require glamour
198// to share state across the cut.
199//
200// Returns -1 when no safe boundary exists. SAFETY FIRST: any time
201// we have the slightest doubt we return -1 and let the caller fall
202// back to a full render.
203//
204// Decision tree, in order of preference (latest boundary wins):
205//
206//  1. Walk backward through every "blank line" position p such that
207//     content[:p] ends with "\n\n" (or "\n[ \t]*\n").
208//  2. For each candidate, check that content[:p] has an even
209//     number of triple-backtick fence lines (no open fenced
210//     block). Any odd count means we'd be cutting inside a fence
211//     and mis-syntax-highlighting the trailing partial.
212//     2b. Reject if any line in content[:p] (outside fenced blocks)
213//     is a list-marker line, an HTML-block opener, or a link
214//     reference definition. See [prefixHasOpenHazard] for the
215//     reasoning behind these "anywhere in prefix" rejects.
216//  3. Reject if the last non-blank line of content[:p] is:
217//     - a list item marker line ("^\s*([-*+]|\d+\.)\s")
218//     - a table line (contains "|")
219//     - a block quote ("^\s*>")
220//     - a setext header underline ("^=+\s*$" or "^-+\s*$")
221//     - an indented code line (4+ leading spaces or a tab)
222//  4. Reject if the line immediately AFTER the boundary (skipping
223//     leading blank lines) looks like a setext underline (a line
224//     of '=' or '-' only). Rendering the prefix as a paragraph
225//     would change once the underline arrived; that's exactly the
226//     "splitting changes the prefix render" hazard ยง4.4 calls out.
227//
228// Returns the byte offset of the first character AFTER the blank
229// line, i.e. the start of the trailing segment.
230func findSafeMarkdownBoundary(content string) int {
231	if len(content) == 0 {
232		return -1
233	}
234
235	// Iterate every blank-line position from latest to earliest.
236	for p := blankLineBefore(content, len(content)); p > 0; p = blankLineBefore(content, p-1) {
237		if !isSafeBoundaryAt(content, p) {
238			continue
239		}
240		return p
241	}
242	return -1
243}
244
245// blankLineBefore returns the byte offset of the first character
246// AFTER the latest blank-line separator that ends strictly before
247// `until`. A blank-line separator is a sequence "\n([ \t]*\n)+"
248// โ€” one newline, then one or more lines containing only spaces or
249// tabs and terminated by another newline. The returned offset is
250// the start of the first non-blank line that follows the
251// separator (or the position immediately after the final newline,
252// if no further content remains).
253//
254// Returns -1 when no blank-line separator exists before `until`.
255func blankLineBefore(content string, until int) int {
256	if until <= 0 {
257		return -1
258	}
259	// Walk backward looking for a newline followed (after optional
260	// blank-line content) by another newline. We track the latest
261	// newline we've seen; if the next earlier newline has only
262	// blank chars between them, we have a blank-line separator
263	// and the boundary sits immediately after the latest newline.
264	end := until
265	for end > 0 {
266		nl := strings.LastIndexByte(content[:end], '\n')
267		if nl < 0 {
268			return -1
269		}
270		// Look for an earlier newline whose gap to nl is empty
271		// or whitespace only.
272		prev := strings.LastIndexByte(content[:nl], '\n')
273		for prev >= 0 {
274			gap := content[prev+1 : nl]
275			if isBlankOrSpaces(gap) {
276				return nl + 1
277			}
278			// Gap had non-whitespace; nl is not a blank-line
279			// separator. Move up: try with the earlier newline as
280			// the new "nl" candidate.
281			break
282		}
283		end = nl
284	}
285	return -1
286}
287
288// isBlankOrSpaces reports whether s consists entirely of spaces
289// and tabs (or is empty).
290func isBlankOrSpaces(s string) bool {
291	for i := range len(s) {
292		if s[i] != ' ' && s[i] != '\t' {
293			return false
294		}
295	}
296	return true
297}
298
299// isSafeBoundaryAt reports whether content[:p] is a safe stable
300// prefix. p must be a blank-line boundary (start of a line, with a
301// blank line immediately preceding).
302//
303// Beyond the last-line checks, three "anywhere in the prefix"
304// hazards force a reject because they cannot be reliably reasoned
305// about by inspecting the trailing line alone. For each of these
306// the simplest, safest rule was chosen โ€” see prefixHasOpenHazard.
307func isSafeBoundaryAt(content string, p int) bool {
308	prefix := content[:p]
309
310	// (2) Even number of triple-backtick fence lines.
311	if countFenceLines(prefix)%2 != 0 {
312		return false
313	}
314
315	// (2b) Anywhere-in-prefix hazards: open list (B1), HTML block
316	// opener (B2), reference link definition (B3). Any of these
317	// anywhere in the prefix forces a fallback.
318	if prefixHasOpenHazard(prefix) {
319		return false
320	}
321
322	// (3) Inspect the last non-blank line of the prefix.
323	lastLine := lastNonBlankLine(prefix)
324	if lastLine != "" && lineOpensConstruct(lastLine) {
325		return false
326	}
327
328	// (4) If anything follows, make sure it doesn't look like a
329	// setext underline that would retroactively turn the last
330	// paragraph of the prefix into a header.
331	if rest := content[p:]; rest != "" {
332		first := firstNonBlankLine(rest)
333		if isSetextUnderlineCandidate(first) {
334			return false
335		}
336	}
337
338	return true
339}
340
341// prefixHasOpenHazard reports whether prefix contains any of three
342// constructs that cannot be safely cut at a blank-line boundary
343// even when the immediately preceding line looks fine. Each check
344// uses the SIMPLEST viable conservative rule per the F8 round-2
345// review:
346//
347//	B1 (loose lists). A loose list has a blank line between an item
348//	   and a continuation paragraph that begins with indentation
349//	   but no list marker. If a candidate boundary lands on that
350//	   blank line, the prefix's trailing non-blank line is the
351//	   continuation paragraph, NOT a list marker, so the last-line
352//	   check would accept it even though the list is still open.
353//
354//	   Rule chosen: any list-marker line ANYWHERE in the prefix
355//	   forces -1. This is overly conservative โ€” it forfeits
356//	   boundary advancement past a closed list โ€” but it eliminates
357//	   the entire bug class with zero parsing of CommonMark's
358//	   loose-list closure semantics. We retain the most useful
359//	   boundary in practice: the one BEFORE the list opens (no
360//	   marker has appeared in the prefix yet).
361//
362//	B2 (HTML blocks). CommonMark defines seven HTML-block opener
363//	   patterns (script/pre/style/textarea, comments, processing
364//	   instructions, CDATA, declarations, recognised tag names).
365//	   If the prefix opens an HTML block that the suffix closes,
366//	   splitting renders the prefix as raw HTML and the suffix as
367//	   prose.
368//
369//	   Rule chosen: any HTML-block opener anywhere in the prefix
370//	   forces -1. Same trade-off as B1 โ€” the typical assistant
371//	   output contains no raw HTML, so the perf cost is zero in
372//	   the common case.
373//
374//	B3 (reference link definitions). A line of the form
375//	   "[label]: <url>" defines a link reference that the suffix
376//	   may later use as "[text][label]". Splitting the document
377//	   loses the definition because each half is rendered as an
378//	   independent glamour document.
379//
380//	   Rule chosen: any reference link definition line anywhere in
381//	   the prefix forces -1. Suffix-side reference detection is
382//	   fragile (three syntaxes: [text][label], [label][], [label]),
383//	   so the prefix-side check is the simpler safe choice.
384//
385// All three rules accept the perf hit of "no boundary after a
386// list / HTML block / link def" in exchange for guaranteed
387// soundness. If profiling shows this kills the F8 win on real
388// streaming traces, the next iteration can promote each rule to
389// its less-conservative variant (closure-aware list tracking,
390// per-tag HTML close detection, suffix-aware ref tracking).
391func prefixHasOpenHazard(prefix string) bool {
392	inFence := false
393	for line := range splitLines(prefix) {
394		// Track fenced state so list/html/ref patterns inside a
395		// fenced code block do not falsely trigger the hazards.
396		if isFenceLine(line) {
397			inFence = !inFence
398			continue
399		}
400		if inFence {
401			continue
402		}
403		trimmed := strings.TrimLeft(line, " \t")
404		if trimmed == "" {
405			continue
406		}
407		// B1: any list-item marker.
408		if isListItemMarker(trimmed) {
409			return true
410		}
411		// B2: HTML block opener.
412		if isHTMLBlockOpener(line) {
413			return true
414		}
415		// B3: link reference definition.
416		if isLinkRefDefinition(line) {
417			return true
418		}
419	}
420	return false
421}
422
423// countFenceLines counts lines that begin a fenced code block in
424// the CommonMark sense: a line whose first non-whitespace run is
425// at least three consecutive backticks (or tildes). Each such
426// line toggles the fenced state, so an even count means every
427// opened fence has been closed.
428//
429// We accept up to three leading spaces of indentation (CommonMark
430// rule) and require the fence characters to be the FIRST
431// non-whitespace content of the line. We deliberately do NOT
432// attempt to parse info-strings or differentiate opener from
433// closer beyond toggling โ€” a closing fence is just any line
434// whose first non-whitespace run is >=3 of the same fence char.
435func countFenceLines(s string) int {
436	n := 0
437	for line := range splitLines(s) {
438		if isFenceLine(line) {
439			n++
440		}
441	}
442	return n
443}
444
445// isFenceLine reports whether line opens or closes a fenced code
446// block.
447func isFenceLine(line string) bool {
448	// Strip up to 3 spaces of indentation.
449	i := 0
450	for i < len(line) && i < 3 && line[i] == ' ' {
451		i++
452	}
453	if i >= len(line) {
454		return false
455	}
456	c := line[i]
457	if c != '`' && c != '~' {
458		return false
459	}
460	run := 0
461	for i < len(line) && line[i] == c {
462		i++
463		run++
464	}
465	return run >= 3
466}
467
468// lastNonBlankLine returns the last non-blank line of s, or ""
469// when every line is blank.
470func lastNonBlankLine(s string) string {
471	last := ""
472	for line := range splitLines(s) {
473		if strings.TrimSpace(line) != "" {
474			last = line
475		}
476	}
477	return last
478}
479
480// firstNonBlankLine returns the first non-blank line of s, or ""
481// when every line is blank.
482func firstNonBlankLine(s string) string {
483	for line := range splitLines(s) {
484		if strings.TrimSpace(line) != "" {
485			return line
486		}
487	}
488	return ""
489}
490
491// splitLines yields the lines of s without their terminators. The
492// final segment is yielded even if not newline-terminated.
493func splitLines(s string) func(yield func(string) bool) {
494	return func(yield func(string) bool) {
495		start := 0
496		for i := 0; i < len(s); i++ {
497			if s[i] == '\n' {
498				if !yield(s[start:i]) {
499					return
500				}
501				start = i + 1
502			}
503		}
504		if start <= len(s)-1 {
505			yield(s[start:])
506		}
507	}
508}
509
510// lineOpensConstruct reports whether line keeps a markdown
511// construct open across the boundary. We err conservatively โ€”
512// any case that smells like list/table/quote/setext/indented-code
513// returns true.
514func lineOpensConstruct(line string) bool {
515	// Indented code: a tab, or 4+ leading spaces.
516	if len(line) > 0 && line[0] == '\t' {
517		return true
518	}
519	if strings.HasPrefix(line, "    ") {
520		return true
521	}
522
523	trimmed := strings.TrimLeft(line, " \t")
524	if trimmed == "" {
525		return false
526	}
527
528	// Block quote.
529	if trimmed[0] == '>' {
530		return true
531	}
532
533	// List item: "- " "* " "+ " or "<digits>. " or "<digits>) ".
534	if isListItemMarker(trimmed) {
535		return true
536	}
537
538	// Table: any pipe character anywhere in the line. Conservative:
539	// pipe-in-prose is rare and the cost of bailing is one slow
540	// frame.
541	if strings.ContainsRune(line, '|') {
542		return true
543	}
544
545	// Setext underline candidate as the LAST line of the prefix:
546	// this would be a setext header for an even-earlier paragraph.
547	// Refuse to split at all in this case โ€” the boundary is right
548	// in the middle of a header.
549	if isSetextUnderlineCandidate(trimmed) {
550		return true
551	}
552
553	return false
554}
555
556// isListItemMarker reports whether line (already left-trimmed)
557// starts with a CommonMark list-item marker followed by a space
558// or tab.
559func isListItemMarker(line string) bool {
560	if line == "" {
561		return false
562	}
563	c := line[0]
564	if c == '-' || c == '*' || c == '+' {
565		if len(line) >= 2 && (line[1] == ' ' || line[1] == '\t') {
566			return true
567		}
568		return false
569	}
570	// Ordered list: digits followed by '.' or ')' and a space.
571	i := 0
572	for i < len(line) && line[i] >= '0' && line[i] <= '9' {
573		i++
574	}
575	if i == 0 || i > 9 {
576		return false
577	}
578	if i >= len(line) {
579		return false
580	}
581	if line[i] != '.' && line[i] != ')' {
582		return false
583	}
584	if i+1 >= len(line) {
585		return false
586	}
587	return line[i+1] == ' ' || line[i+1] == '\t'
588}
589
590// isSetextUnderlineCandidate reports whether line (with optional
591// leading whitespace) consists entirely of '=' or entirely of '-'
592// characters with optional trailing whitespace. CommonMark
593// requires no leading whitespace on the underline; we accept up
594// to three spaces for safety so an indented underline still
595// blocks a split.
596func isSetextUnderlineCandidate(line string) bool {
597	// Strip leading whitespace.
598	i := 0
599	for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
600		i++
601	}
602	if i == len(line) {
603		return false
604	}
605	c := line[i]
606	if c != '=' && c != '-' {
607		return false
608	}
609	j := i
610	for j < len(line) && line[j] == c {
611		j++
612	}
613	// Allow trailing whitespace.
614	for j < len(line) {
615		if line[j] != ' ' && line[j] != '\t' {
616			return false
617		}
618		j++
619	}
620	// Need at least one underline character. "-" alone is also a
621	// list marker without a trailing space; the listItem check
622	// covers the marker case before we get here.
623	return j-i >= 1
624}
625
626// isHTMLBlockOpener reports whether line begins one of the seven
627// CommonMark HTML block patterns. We accept up to three spaces of
628// leading indentation (CommonMark rule). Matching is intentionally
629// loose โ€” we only need to know the line "looks like an HTML
630// block start", not parse the contained markup.
631func isHTMLBlockOpener(line string) bool {
632	// Strip up to 3 spaces of indentation.
633	i := 0
634	for i < len(line) && i < 3 && line[i] == ' ' {
635		i++
636	}
637	rest := line[i:]
638	if len(rest) < 2 || rest[0] != '<' {
639		return false
640	}
641
642	// Type 2: HTML comment "<!--".
643	if strings.HasPrefix(rest, "<!--") {
644		return true
645	}
646	// Type 3: processing instruction "<?".
647	if strings.HasPrefix(rest, "<?") {
648		return true
649	}
650	// Type 5: CDATA "<![CDATA[".
651	if strings.HasPrefix(rest, "<![CDATA[") {
652		return true
653	}
654	// Type 4: declaration "<!" followed by an ASCII letter.
655	if len(rest) >= 3 && rest[1] == '!' && isASCIILetter(rest[2]) {
656		return true
657	}
658
659	// Type 1: <script | <pre | <style | <textarea (case-insensitive)
660	// followed by whitespace, '>', end-of-line, or other non-name
661	// terminators. Use a permissive HasPrefix check on lowercase.
662	low := strings.ToLower(rest)
663	for _, t := range []string{"<script", "<pre", "<style", "<textarea"} {
664		if strings.HasPrefix(low, t) {
665			next := byte(0)
666			if len(low) > len(t) {
667				next = low[len(t)]
668			}
669			if next == 0 || next == ' ' || next == '\t' || next == '>' {
670				return true
671			}
672		}
673	}
674
675	// Types 6 & 7: open or close of a block-level tag.
676	//
677	// Type 6 matches a fixed CommonMark tag set; type 7 matches any
678	// otherwise-valid open/close tag whose name is not in the
679	// script/pre/style/textarea family. We collapse both into a
680	// single check: the line must start with '<' or '</' followed
681	// by an ASCII letter. This deliberately mirrors the other
682	// hazards โ€” when in doubt, forfeit the boundary. Lines like
683	// "<3", "<-", "<<", or mid-line "<foo>" do NOT trigger because
684	// we require the line to *start* (after up to 3 spaces) with
685	// '<letter' or '</letter'.
686	j := 1 // past '<'
687	if j < len(rest) && rest[j] == '/' {
688		j++
689	}
690	if j >= len(rest) || !isASCIILetter(rest[j]) {
691		return false
692	}
693	return true
694}
695
696// isASCIILetter reports whether b is an ASCII letter.
697func isASCIILetter(b byte) bool {
698	return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
699}
700
701// isLinkRefDefinition reports whether line matches a CommonMark
702// link reference definition opener. The conservative pattern:
703//
704//	^[ ]{0,3}\[[^\]]+\]:\s*\S+
705//
706// i.e. up to 3 spaces, then a bracketed label (no nested ']'),
707// then a colon, then whitespace, then at least one non-whitespace
708// character of destination. We do not validate the destination โ€”
709// presence of a ref-def opener anywhere in the prefix is enough
710// to forfeit the boundary.
711func isLinkRefDefinition(line string) bool {
712	i := 0
713	for i < len(line) && i < 3 && line[i] == ' ' {
714		i++
715	}
716	if i >= len(line) || line[i] != '[' {
717		return false
718	}
719	i++
720	labelStart := i
721	for i < len(line) && line[i] != ']' {
722		i++
723	}
724	if i >= len(line) || i == labelStart {
725		// No closing bracket, or empty label.
726		return false
727	}
728	// i points at ']'.
729	i++
730	if i >= len(line) || line[i] != ':' {
731		return false
732	}
733	i++
734	// Skip required whitespace.
735	for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
736		i++
737	}
738	// At least one non-whitespace character of destination.
739	return i < len(line)
740}