Files

1077 lines
34 KiB
Go

package helpers
import (
"strings"
"unicode/utf8"
)
// This file implements the structural scan behind SplitMarkdownForAppend.
//
// Why a scan instead of "parse into blocks, then re-join them": the server's
// update_document mode=append always inserts a brand new structure, so a chunk
// boundary can never be continued by the next chunk. Every chunk must therefore
// stand alone as a complete top-level block sequence. The scan records *offsets*
// of positions where cutting is legal, graded by how much damage the cut does,
// and never rebuilds text — which is what makes "no content is lost" a
// structural property rather than something each code path must remember.
//
// Three passes, each O(n):
//
// 1. classifyLines — fence-aware line classification. Must be exact, because
// a fence interior may never be read as structure.
// 2. groupRegions — group non-blank runs into regions, with intra-run
// lookahead (a table's delimiter row is the *second* line of the region and
// its header is the line *before* that, so one forward pass cannot see it).
// 3. emitCandidates — derive split candidates from (prev line, line, region).
//
// Invariant: any misclassification must resolve toward the *lower* (more
// conservative) tier. Guessing "safe" corrupts documents; guessing "unsafe"
// only costs an extra chunk.
// splitTier grades a split position by how much it changes the rendered result.
type splitTier uint8
const (
// tierSafe splits change nothing: no injected characters, and the two
// halves render exactly as the whole did.
tierSafe splitTier = iota
// tierSoft splits inject nothing but do change structure — a paragraph
// becomes two paragraphs, a list becomes two lists.
tierSoft
// tierRepair splits must inject characters to keep each half self-contained
// (a repeated table header, a closed-and-reopened fence).
tierRepair
)
// splitTierCount is the number of stored tiers. Hard splits are synthesized on
// demand and never stored, so they are not counted here.
const splitTierCount = 3
// srcSpan is a byte range in the normalized content, excluding any trailing
// newline. Spans keep repair payloads out of memory until one is actually used:
// a 5000-row table stores its header once, not once per candidate row.
type srcSpan struct{ start, end int }
type lineKind uint8
const (
lineParagraph lineKind = iota
lineBlank
lineATXHeading
lineFenceOpen
lineFenceBody
lineFenceClose
lineSetextUnderline // a run of '=' only; unambiguous
lineThematicBreakDash // a run of '-'; ALSO a setext underline candidate
lineThematicBreakOther // '*' or '_' form; cannot be a setext underline
lineTableDelim
lineListItemStart
lineQuote
lineHTMLStart
lineIndentedCode
lineLinkRefDef
)
type regionKind uint8
const (
regionParagraph regionKind = iota
regionFence
regionTable
regionList
regionQuote
regionHTML
regionIndentedCode
regionSetext
regionLeaf // a single-line block: heading, thematic break, link ref def
)
type lineInfo struct {
start int // byte offset of the first character
nlEnd int // byte offset past the trailing '\n' (== end of content if none)
runeStart int // rune index of start; makes every budget comparison O(1)
indent int // leading whitespace in columns, tab = 4
kind lineKind
region int32 // index into markdownScan.regions
// fence bookkeeping, set on lineFenceOpen
fenceChar byte
fenceCount int
// list bookkeeping, set on lineListItemStart
ordered bool
contentCol int // column where the item's content starts
// html bookkeeping, set on lineHTMLStart
htmlKind uint8
}
type region struct {
kind regionKind
firstLine int
lastLine int
// regionFence
fenceOpen srcSpan // the original opening line, indentation and info string included
fenceMarker srcSpan // just the run of ` or ~, so a 5-backtick fence closes with 5
// regionTable
header srcSpan
delim srcSpan
// regionList
ordered bool
// regionHTML repair (only for <pre>/<style>/<textarea>; never <script>)
htmlRepair bool
htmlOpen srcSpan // the full opening tag, including a multi-line one
htmlOpenEndLine int // line index the opening tag's '>' sits on
htmlCloser string // e.g. "</pre>"
}
type candidateKind uint8
const (
candBlankLine candidateKind = iota
candBlockStart
candBlockEnd
candParagraphLine
candListItem
candOrderedListItem
candQuoteLine
candTableRow
candFenceBody
candRegionBoundary
candIndentedCodeLine
candHTMLLine
candHTMLRepair
// The hard kinds are synthesized on demand by hardCandidate and never
// stored in the candidate list.
candHardWhitespace
candHardRune
candHardHTML
)
type splitCandidate struct {
offset int
runeIdx int
// trimRuneIdx is runeIdx with the trailing whitespace that emit() will trim
// already discounted. Budgeting against runeIdx would charge the chunk for
// blank lines it is about to drop, which loses one safe boundary per blank
// run and makes the greedy choice needlessly early.
trimRuneIdx int
line int32 // 1-based, for diagnostics only
region int32 // -1 when no repair is involved
tier splitTier
kind candidateKind
// keepTrailing suppresses trailing-whitespace trimming of the emitted
// chunk. Only set inside a fence, where trailing blank lines are content.
keepTrailing bool
}
type markdownScan struct {
content string
totalRune int
lines []lineInfo
regions []region
cands []splitCandidate
byTier [splitTierCount][]int32 // candidate indices, ascending offset
linkRefs int
}
// scanMarkdownStructure runs all three passes over already-normalized content.
func scanMarkdownStructure(content string) *markdownScan {
s := &markdownScan{content: content, totalRune: utf8.RuneCountInString(content)}
s.classifyLines()
s.groupRegions()
s.emitCandidates()
return s
}
// runesFrom reports how many runes remain from the given rune index.
func (s *markdownScan) runesFrom(runeIdx int) int { return s.totalRune - runeIdx }
// ── Pass 1: fence-aware line classification ─────────────────────────────────
func (s *markdownScan) classifyLines() {
var (
inFence bool
fenceChar byte
fenceCount int
)
offset, runeIdx := 0, 0
for offset <= len(s.content) {
nl := strings.IndexByte(s.content[offset:], '\n')
var raw string
var nlEnd int
if nl < 0 {
raw, nlEnd = s.content[offset:], len(s.content)
} else {
raw, nlEnd = s.content[offset:offset+nl], offset+nl+1
}
li := lineInfo{start: offset, nlEnd: nlEnd, runeStart: runeIdx, region: -1}
li.indent = leadingIndent(raw)
trimmed := strings.TrimSpace(raw)
switch {
case inFence:
if ch, n, ok := fenceMarkerOf(trimmed); ok && li.indent <= 3 &&
ch == fenceChar && n >= fenceCount && closingFenceIsBare(trimmed, ch, n) {
li.kind = lineFenceClose
inFence = false
} else {
li.kind = lineFenceBody
}
case trimmed == "":
li.kind = lineBlank
case li.indent <= 3 && isFenceOpen(trimmed):
ch, n, _ := fenceMarkerOf(trimmed)
li.kind, li.fenceChar, li.fenceCount = lineFenceOpen, ch, n
inFence, fenceChar, fenceCount = true, ch, n
case li.indent >= 4:
li.kind = lineIndentedCode
case isATXHeading(trimmed):
li.kind = lineATXHeading
case isRunOf(trimmed, '='):
li.kind = lineSetextUnderline
case isRunOf(trimmed, '-'):
li.kind = lineThematicBreakDash
case isThematicBreak(trimmed, '*') || isThematicBreak(trimmed, '_'):
li.kind = lineThematicBreakOther
case trimmed[0] == '>':
li.kind = lineQuote
case isTableDelimiterRow(trimmed):
li.kind = lineTableDelim
default:
if ordered, contentCol, ok := listMarkerOf(raw); ok {
li.kind, li.ordered, li.contentCol = lineListItemStart, ordered, contentCol
} else if hk, ok := htmlBlockKind(trimmed); ok {
li.kind, li.htmlKind = lineHTMLStart, hk
} else if isLinkReferenceDefinition(trimmed) {
li.kind = lineLinkRefDef
s.linkRefs++
} else {
li.kind = lineParagraph
}
}
s.lines = append(s.lines, li)
runeIdx += utf8.RuneCountInString(raw)
if nl < 0 {
break
}
runeIdx++ // the '\n'
offset = nlEnd
}
}
func leadingIndent(line string) int {
col := 0
for i := 0; i < len(line); i++ {
switch line[i] {
case ' ':
col++
case '\t':
col += 4 - col%4
default:
return col
}
}
return col
}
// fenceMarkerOf returns the fence character and its run length when the line
// starts with at least three backticks or tildes.
func fenceMarkerOf(trimmed string) (byte, int, bool) {
if trimmed == "" {
return 0, 0, false
}
ch := trimmed[0]
if ch != '`' && ch != '~' {
return 0, 0, false
}
n := 0
for n < len(trimmed) && trimmed[n] == ch {
n++
}
if n < 3 {
return 0, 0, false
}
return ch, n, true
}
func isFenceOpen(trimmed string) bool {
ch, n, ok := fenceMarkerOf(trimmed)
if !ok {
return false
}
// A backtick info string may not contain a backtick.
return ch != '`' || !strings.Contains(trimmed[n:], "`")
}
// closingFenceIsBare reports whether the line is only the fence marker, which
// CommonMark requires of a closing fence.
func closingFenceIsBare(trimmed string, ch byte, n int) bool {
rest := trimmed[n:]
return strings.TrimRight(rest, " \t") == "" || strings.Trim(rest, string(ch)+" \t") == ""
}
func isATXHeading(trimmed string) bool {
n := 0
for n < len(trimmed) && trimmed[n] == '#' {
n++
}
if n == 0 || n > 6 {
return false
}
rest := trimmed[n:]
return rest == "" || rest[0] == ' ' || rest[0] == '\t'
}
// isRunOf reports whether the line consists solely of the given character. The
// empty string is not a run: callers have already excluded blank lines and empty
// table cells.
func isRunOf(trimmed string, ch byte) bool {
if trimmed == "" {
return false
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] != ch {
return false
}
}
return true
}
// isThematicBreak reports whether the line is three or more of ch, allowing
// interior spaces.
func isThematicBreak(trimmed string, ch byte) bool {
n := 0
for i := 0; i < len(trimmed); i++ {
switch trimmed[i] {
case ch:
n++
case ' ', '\t':
default:
return false
}
}
return n >= 3
}
// isTableDelimiterRow matches a GFM delimiter row such as `|---|:--:|`.
//
// A pipe is required: without one, `---` is a thematic break or a setext
// underline, never a table delimiter.
func isTableDelimiterRow(trimmed string) bool {
if !strings.ContainsRune(trimmed, '|') {
return false
}
body := strings.TrimSuffix(strings.TrimPrefix(trimmed, "|"), "|")
if body == "" || !strings.ContainsRune(trimmed, '-') {
return false
}
for _, cell := range strings.Split(body, "|") {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if cell == "" || !isRunOf(cell, '-') {
return false
}
}
return true
}
// tableCellCount counts the cells of a table row, used to check that a
// delimiter row matches the header above it.
func tableCellCount(trimmed string) int {
if !strings.ContainsRune(trimmed, '|') {
return 0
}
body := strings.TrimSuffix(strings.TrimPrefix(trimmed, "|"), "|")
return len(strings.Split(body, "|"))
}
// listMarkerOf recognizes a bullet or ordered list marker and reports the
// column where the item's content begins.
func listMarkerOf(raw string) (ordered bool, contentCol int, ok bool) {
indent := leadingIndent(raw)
rest := strings.TrimLeft(raw, " \t")
if rest == "" {
return false, 0, false
}
width := 0
switch rest[0] {
case '-', '+', '*':
width = 1
default:
digits := 0
for digits < len(rest) && digits < 9 && rest[digits] >= '0' && rest[digits] <= '9' {
digits++
}
if digits == 0 || digits >= len(rest) || (rest[digits] != '.' && rest[digits] != ')') {
return false, 0, false
}
ordered, width = true, digits+1
}
after := rest[width:]
if after != "" && after[0] != ' ' && after[0] != '\t' {
return false, 0, false
}
spaces := 0
for spaces < len(after) && (after[spaces] == ' ' || after[spaces] == '\t') {
spaces++
}
if spaces > 4 {
spaces = 1
}
return ordered, indent + width + spaces, true
}
// listMarkerNumber returns the leading number of an ordered list marker.
func listMarkerNumber(raw string) int {
rest := strings.TrimLeft(raw, " \t")
n := 0
for i := 0; i < len(rest) && rest[i] >= '0' && rest[i] <= '9'; i++ {
n = n*10 + int(rest[i]-'0')
}
return n
}
// htmlBlockKind classifies an HTML block start into CommonMark's types 1-7.
// Only the terminator behaviour matters here: type 1 spans blank lines and ends
// at its closing tag, types 2-5 have their own terminators, 6-7 end at a blank
// line.
func htmlBlockKind(trimmed string) (uint8, bool) {
if trimmed == "" || trimmed[0] != '<' {
return 0, false
}
lower := strings.ToLower(trimmed)
for _, name := range []string{"<script", "<pre", "<style", "<textarea"} {
if strings.HasPrefix(lower, name) {
return 1, true
}
}
switch {
case strings.HasPrefix(trimmed, "<!--"):
return 2, true
case strings.HasPrefix(trimmed, "<?"):
return 3, true
case strings.HasPrefix(trimmed, "<!") && len(trimmed) > 2 && trimmed[2] >= 'A' && trimmed[2] <= 'Z':
return 4, true
case strings.HasPrefix(trimmed, "<![CDATA["):
return 5, true
}
name := strings.TrimLeft(lower[1:], "/")
if name == "" || name[0] < 'a' || name[0] > 'z' {
return 0, false // a tag name must start with a letter
}
end := 0
for end < len(name) && (name[end] >= 'a' && name[end] <= 'z' || name[end] >= '0' && name[end] <= '9') {
end++
}
if htmlBlockTags[name[:end]] {
return 6, true
}
return 7, true
}
var htmlBlockTags = map[string]bool{
"address": true, "article": true, "aside": true, "base": true, "blockquote": true,
"body": true, "caption": true, "center": true, "col": true, "colgroup": true,
"dd": true, "details": true, "dialog": true, "dir": true, "div": true, "dl": true,
"dt": true, "fieldset": true, "figcaption": true, "figure": true, "footer": true,
"form": true, "frame": true, "frameset": true, "h1": true, "h2": true, "h3": true,
"h4": true, "h5": true, "h6": true, "head": true, "header": true, "hr": true,
"html": true, "iframe": true, "legend": true, "li": true, "link": true, "main": true,
"menu": true, "menuitem": true, "nav": true, "noframes": true, "ol": true,
"optgroup": true, "option": true, "p": true, "param": true, "section": true,
"source": true, "summary": true, "table": true, "tbody": true, "td": true,
"tfoot": true, "th": true, "thead": true, "title": true, "tr": true, "track": true,
"ul": true,
}
// isLinkReferenceDefinition matches `[label]: destination`, whose definition
// must not be separated from the links that use it.
func isLinkReferenceDefinition(trimmed string) bool {
if trimmed == "" || trimmed[0] != '[' {
return false
}
close := strings.Index(trimmed, "]:")
return close > 1
}
// ── Pass 2: run grouping and region assignment ───────────────────────────────
func (s *markdownScan) groupRegions() {
i := 0
for i < len(s.lines) {
if s.lines[i].kind == lineBlank {
i++
continue
}
i = s.groupOneRegion(i)
}
}
// groupOneRegion assigns the region starting at line i and returns the index of
// the first line after it.
func (s *markdownScan) groupOneRegion(i int) int {
switch s.lines[i].kind {
case lineFenceOpen:
return s.groupFence(i)
case lineIndentedCode:
return s.groupIndentedCode(i)
case lineQuote:
return s.groupSimple(i, s.quoteEnd(i), region{kind: regionQuote})
case lineListItemStart:
return s.groupList(i)
case lineHTMLStart:
last := s.htmlEnd(i)
return s.groupSimple(i, last, s.htmlRegion(i, last))
case lineATXHeading, lineThematicBreakOther, lineLinkRefDef:
return s.groupSimple(i, i, region{kind: regionLeaf})
case lineThematicBreakDash:
// A dash run is a thematic break only when it cannot be read as a
// setext underline, i.e. when no paragraph precedes it.
return s.groupSimple(i, i, region{kind: regionLeaf})
}
return s.groupParagraphRun(i)
}
func (s *markdownScan) groupSimple(first, last int, r region) int {
r.firstLine, r.lastLine = first, last
s.assign(r)
return last + 1
}
func (s *markdownScan) assign(r region) {
idx := int32(len(s.regions))
s.regions = append(s.regions, r)
for k := r.firstLine; k <= r.lastLine && k < len(s.lines); k++ {
s.lines[k].region = idx
}
}
func (s *markdownScan) groupFence(i int) int {
last := i
for last+1 < len(s.lines) && s.lines[last].kind != lineFenceClose {
last++
if s.lines[last].kind == lineFenceClose {
break
}
}
if s.lines[last].kind != lineFenceClose {
last = len(s.lines) - 1 // unterminated fence swallows the rest
}
open := s.lines[i]
markerStart := open.start
for markerStart < open.nlEnd && (s.content[markerStart] == ' ' || s.content[markerStart] == '\t') {
markerStart++
}
return s.groupSimple(i, last, region{
kind: regionFence,
fenceOpen: srcSpan{open.start, s.lineTextEnd(i)},
fenceMarker: srcSpan{markerStart, markerStart + open.fenceCount},
})
}
func (s *markdownScan) groupIndentedCode(i int) int {
last := i
for k := i + 1; k < len(s.lines); k++ {
if s.lines[k].kind == lineIndentedCode {
last = k
continue
}
if s.lines[k].kind == lineBlank {
continue // a blank line only ends the block if no indented line follows
}
break
}
return s.groupSimple(i, last, region{kind: regionIndentedCode})
}
func (s *markdownScan) quoteEnd(i int) int {
last := i
for k := i + 1; k < len(s.lines); k++ {
if s.lines[k].kind == lineBlank {
break
}
// Lazy continuation: a plain paragraph line continues the quote.
if s.lines[k].kind != lineQuote && s.lines[k].kind != lineParagraph {
break
}
last = k
}
return last
}
func (s *markdownScan) htmlEnd(i int) int {
if s.lines[i].htmlKind >= 6 {
last := i
for k := i + 1; k < len(s.lines) && s.lines[k].kind != lineBlank; k++ {
last = k
}
return last
}
if s.lines[i].htmlKind == 1 {
// Type 1 covers <script>, <pre>, <style> and <textarea>, each with its own
// closing tag. CommonMark ends the block on the first line containing any
// of the four closers, so searching for a single fixed tag (e.g. only
// </script>) would let a <pre>/<style>/<textarea> block swallow the rest
// of the document — including a following table, which would then be split
// without its header re-emitted.
for k := i; k < len(s.lines); k++ {
if lineHasType1Closer(strings.ToLower(s.lineText(k))) {
return k
}
}
return len(s.lines) - 1
}
closer := htmlCloser(s.lines[i].htmlKind)
for k := i; k < len(s.lines); k++ {
if strings.Contains(strings.ToLower(s.lineText(k)), closer) {
return k
}
}
return len(s.lines) - 1
}
// lineHasType1Closer reports whether a lowercased line contains any of the four
// CommonMark type-1 HTML closing tags. Type 1 ends on whichever appears first,
// regardless of which opened the block.
func lineHasType1Closer(lower string) bool {
return strings.Contains(lower, "</script>") ||
strings.Contains(lower, "</pre>") ||
strings.Contains(lower, "</style>") ||
strings.Contains(lower, "</textarea>")
}
// htmlCloser returns the terminator for HTML block types 2-5. Type 1 is handled
// by lineHasType1Closer in htmlEnd (it has four possible closers, not one), and
// types 6-7 end at a blank line; do not route those through here.
func htmlCloser(kind uint8) string {
switch kind {
case 2:
return "-->"
case 3:
return "?>"
case 4:
return ">"
case 5:
return "]]>"
default:
return "</script>"
}
}
// htmlRegion builds a regionHTML, enabling <pre>/<style>/<textarea> repair when
// the block has the canonical shape: a self-contained opening tag, a matching
// closer on the last line, and at least one line of interior between them.
// <script> is deliberately never repaired — splitting JavaScript across <script>
// tags reliably breaks execution. Anything irregular falls back to a plain
// regionHTML (line-boundary split, no reopen).
func (s *markdownScan) htmlRegion(first, last int) region {
r := region{kind: regionHTML}
tag, ok := htmlType1RepairTag(strings.ToLower(strings.TrimSpace(s.lineText(first))))
if !ok {
return r
}
openEndOff, openEndLine, found := s.htmlTagEnd(first)
if !found || openEndLine >= last {
return r
}
closer := "</" + tag + ">"
if !strings.Contains(strings.ToLower(s.lineText(last)), closer) {
return r // not cleanly terminated by the matching closer
}
r.htmlRepair = true
r.htmlOpen = srcSpan{s.lines[first].start, openEndOff}
r.htmlOpenEndLine = openEndLine
r.htmlCloser = closer
return r
}
// htmlType1RepairTag returns the tag name when the line opens a repairable type-1
// block. The tag must be followed by whitespace, '>' or end of line so that e.g.
// "<presentation" is not mistaken for "<pre".
func htmlType1RepairTag(lower string) (string, bool) {
for _, tag := range []string{"pre", "style", "textarea"} {
p := "<" + tag
if !strings.HasPrefix(lower, p) {
continue
}
rest := lower[len(p):]
if rest == "" || rest[0] == ' ' || rest[0] == '\t' || rest[0] == '>' {
return tag, true
}
}
return "", false
}
// htmlTagEnd finds the '>' that closes the tag opening on line `first`, which may
// sit on a later line. It returns the byte offset just past the '>' and its line.
func (s *markdownScan) htmlTagEnd(first int) (offset, line int, ok bool) {
for k := first; k < len(s.lines); k++ {
text := s.content[s.lines[k].start:s.lineTextEnd(k)]
if idx := strings.IndexByte(text, '>'); idx >= 0 {
return s.lines[k].start + idx + 1, k, true
}
}
return 0, 0, false
}
func (s *markdownScan) groupList(i int) int {
marker := s.lines[i]
last := i
for k := i + 1; k < len(s.lines); k++ {
li := s.lines[k]
if li.kind == lineBlank {
// The list continues only if the next non-blank line is indented
// into the item's content column or is another marker.
n := k + 1
for n < len(s.lines) && s.lines[n].kind == lineBlank {
n++
}
if n >= len(s.lines) {
break
}
nx := s.lines[n]
if nx.indent >= marker.contentCol ||
(nx.kind == lineListItemStart && nx.indent == marker.indent) {
k = n - 1
continue
}
break
}
if li.kind == lineListItemStart && li.indent <= marker.indent+3 {
last = k
continue
}
if li.indent >= marker.contentCol || li.kind == lineParagraph {
last = k // nested content or lazy continuation
continue
}
break
}
return s.groupSimple(i, last, region{kind: regionList, ordered: marker.ordered})
}
// groupParagraphRun handles a maximal non-blank run that starts with paragraph
// text. Within the run, setext underlines and GFM tables take precedence, in
// that order, mirroring the parser phase order (block parsers first, table
// transformation over already-parsed paragraphs second).
func (s *markdownScan) groupParagraphRun(i int) int {
end := i
for end+1 < len(s.lines) && s.lines[end+1].kind != lineBlank {
// interruptsParagraph is the single source of truth for "does this end
// the paragraph". Notably a `1.` list item interrupts while a `7.` one
// does not, so the latter stays paragraph text — the same asymmetry that
// decides whether the boundary is safe or merely soft.
if s.interruptsParagraph(end + 1) {
break
}
end++
}
// Setext: an '=' or '-' run on a later line turns everything above it into
// a heading, so `para\n---` is NOT a thematic break.
for j := i + 1; j <= end; j++ {
if s.lines[j].kind == lineSetextUnderline || s.lines[j].kind == lineThematicBreakDash {
s.assign(region{kind: regionSetext, firstLine: i, lastLine: j})
return j + 1
}
}
// Table: the delimiter row is at k, its header at k-1. Everything from the
// header to the end of the run becomes table rows — including lines with no
// pipes at all — so the region does NOT stop at the first non-pipe line.
for k := i + 1; k <= end; k++ {
if s.lines[k].kind != lineTableDelim {
continue
}
if tableCellCount(strings.TrimSpace(s.lineText(k-1))) != tableCellCount(strings.TrimSpace(s.lineText(k))) {
continue
}
if k-1 > i {
s.assign(region{kind: regionParagraph, firstLine: i, lastLine: k - 2})
}
s.assign(region{
kind: regionTable,
firstLine: k - 1,
lastLine: end,
header: srcSpan{s.lines[k-1].start, s.lineTextEnd(k - 1)},
delim: srcSpan{s.lines[k].start, s.lineTextEnd(k)},
})
return end + 1
}
s.assign(region{kind: regionParagraph, firstLine: i, lastLine: end})
return end + 1
}
// lineTextEnd is the byte offset just past the line's text, excluding '\n'.
func (s *markdownScan) lineTextEnd(i int) int {
li := s.lines[i]
if li.nlEnd > li.start && li.nlEnd <= len(s.content) && s.content[li.nlEnd-1] == '\n' {
return li.nlEnd - 1
}
return li.nlEnd
}
func (s *markdownScan) lineText(i int) string {
return s.content[s.lines[i].start:s.lineTextEnd(i)]
}
// ── Pass 3: candidate emission ───────────────────────────────────────────────
func (s *markdownScan) emitCandidates() {
for i := 1; i < len(s.lines); i++ {
c, ok := s.candidateAt(i)
if !ok {
continue
}
c.trimRuneIdx = c.runeIdx
if !c.keepTrailing {
c.trimRuneIdx = s.runeIndexOf(trimTrailingWhitespaceEnd(s.content, c.offset))
}
idx := int32(len(s.cands))
s.cands = append(s.cands, c)
s.byTier[c.tier] = append(s.byTier[c.tier], idx)
}
}
func (s *markdownScan) candidateAt(i int) (splitCandidate, bool) {
li, prev := s.lines[i], s.lines[i-1]
if li.kind == lineBlank {
return splitCandidate{}, false // cut before content, never before a blank
}
mk := func(tier splitTier, kind candidateKind, regionIdx int32) (splitCandidate, bool) {
return splitCandidate{
offset: li.start, runeIdx: li.runeStart, line: int32(i + 1),
region: regionIdx, tier: tier, kind: kind,
keepTrailing: kind == candFenceBody || kind == candHTMLRepair,
}, true
}
reg := int32(-1)
if li.region >= 0 {
reg = li.region
}
inSameRegion := reg >= 0 && prev.region == reg
startsRegion := reg >= 0 && int(s.regions[reg].firstLine) == i
// A1 — a blank line at top level separates two independent blocks. This is
// the strongest guarantee available: nothing renders differently.
if prev.kind == lineBlank && (prev.region < 0 || !s.regionSpansBlanks(prev.region)) {
return mk(tierSafe, candBlankLine, -1)
}
// A2 — the line starts a construct that provably interrupts a paragraph.
if startsRegion && s.interruptsParagraph(i) {
return mk(tierSafe, candBlockStart, -1)
}
// A3 — the previous line completed a non-paragraph block and this line
// starts a new one. Tables are excluded: whether a table interrupts a
// paragraph is parser-dependent, so splitting there could *create* one.
if startsRegion && prev.region >= 0 && prev.region != reg &&
s.regions[prev.region].kind != regionParagraph &&
s.regions[reg].kind != regionTable {
return mk(tierSafe, candBlockEnd, -1)
}
// A cross-region boundary none of the safe rules accepted: the two halves may
// parse differently than the whole did — a table header following a paragraph
// line, or indented code following one. Cutting here is allowed but recorded.
//
// Reaching this implies startsRegion: every non-blank line belongs to a
// region, regions tile contiguous line ranges, and a region that spans blank
// lines always ends on a non-blank one, so a line whose predecessor sits in a
// different region can only be its own region's first line.
if !inSameRegion {
return mk(tierSoft, candRegionBoundary, -1)
}
switch s.regions[reg].kind {
case regionParagraph:
// B1 — a soft line break inside a paragraph becomes a paragraph break.
return mk(tierSoft, candParagraphLine, -1)
case regionQuote:
if li.kind == lineQuote && li.indent == s.regions[reg].firstLineIndent(s) {
return mk(tierSoft, candQuoteLine, -1)
}
case regionList:
if li.kind == lineListItemStart && li.indent == s.lines[s.regions[reg].firstLine].indent {
if s.regions[reg].ordered {
// Visible numbering restarts at the server, so this is a
// heavier change than an unordered split.
return mk(tierRepair, candOrderedListItem, -1)
}
return mk(tierSoft, candListItem, -1)
}
case regionTable:
// B2 — a body row strictly after the delimiter; the next chunk gets the
// header and delimiter rows re-emitted so it is a table in its own right.
if i > s.regions[reg].firstLine+1 {
return mk(tierRepair, candTableRow, reg)
}
case regionFence:
// B2 — close the fence here and reopen it with the original marker and
// info string on the next chunk.
if li.kind == lineFenceBody {
return mk(tierRepair, candFenceBody, reg)
}
case regionIndentedCode:
// Each half stays an indented code block on its own, so no injection is
// needed — but one code block becomes two.
if li.kind == lineIndentedCode {
return mk(tierSoft, candIndentedCodeLine, -1)
}
case regionHTML:
r := &s.regions[reg]
if r.htmlRepair {
if i <= r.htmlOpenEndLine {
// Never cut inside the opening tag (it may span several lines).
return splitCandidate{}, false
}
// B2 — close the tag here and reopen it (with any attributes) on the
// next chunk, exactly like a code fence.
return mk(tierRepair, candHTMLRepair, reg)
}
// Other tags are not repaired: a line boundary is less destructive than a
// mid-line cut, but the tags may no longer balance.
return mk(tierSoft, candHTMLLine, -1)
}
return splitCandidate{}, false
}
// firstLineIndent is the indent of the region's first line.
func (r region) firstLineIndent(s *markdownScan) int { return s.lines[r.firstLine].indent }
// regionSpansBlanks reports whether blank lines inside this region belong to it,
// which disqualifies them as safe boundaries.
func (s *markdownScan) regionSpansBlanks(idx int32) bool {
r := s.regions[idx]
if r.kind == regionHTML {
// Type-1 blocks (<script>/<pre>/<style>/<textarea>) run through blank
// lines, so an interior blank is content, not a safe boundary.
return s.lines[r.firstLine].htmlKind == 1
}
return r.kind == regionFence || r.kind == regionIndentedCode || r.kind == regionList
}
// interruptsParagraph reports whether the construct starting at line i can
// interrupt a paragraph, i.e. whether cutting immediately before it is safe
// even when the preceding line is paragraph text.
func (s *markdownScan) interruptsParagraph(i int) bool {
li := s.lines[i]
switch li.kind {
case lineATXHeading, lineFenceOpen, lineQuote, lineThematicBreakOther:
return true
case lineHTMLStart:
return li.htmlKind <= 6
case lineListItemStart:
// An ordered item can only interrupt a paragraph when it starts at 1.
return !li.ordered || listMarkerNumber(s.lineText(i)) == 1
case lineThematicBreakDash:
// Ambiguous with a setext underline; only safe when no paragraph is open.
return s.lines[i-1].kind == lineBlank
default:
return false
}
}
// ── Repair payloads ─────────────────────────────────────────────────────────
// injectionAt reports the repair text a boundary at the given byte offset needs:
// prefix is prepended to the chunk that STARTS there, suffix is appended to the
// chunk that ENDS there.
//
// Keying this on the offset rather than on the candidate that produced it is
// what makes the repair correct under hard splits. An earlier version attached
// the injection to the candidate, so a chunk that opened an injected fence but
// was then terminated by a hard split emitted an unbalanced fence. Position is
// the truth: if a boundary sits inside a fence, the chunk before it must close
// and the chunk after it must reopen, no matter which rule chose the boundary.
func (s *markdownScan) injectionAt(offset int) (prefix, suffix string) {
if offset <= 0 || offset >= len(s.content) {
return "", ""
}
reg := s.lines[s.lineIndexOf(offset)].region
if reg < 0 {
return "", ""
}
r := &s.regions[reg]
switch r.kind {
case regionFence:
// Everything from the second line of the region onward is fence
// interior, including the original closing line: a chunk starting at
// the closing line still needs an opener to be a valid code block.
if body := r.firstLine + 1; body <= r.lastLine && offset >= s.lines[body].start {
return s.span(r.fenceOpen) + "\n", s.span(r.fenceMarker)
}
case regionTable:
// Only body rows need the header re-emitted. A boundary inside the
// header or delimiter row itself cannot be repaired into a table and is
// only reachable when the limit is smaller than the header.
if body := r.firstLine + 2; body <= r.lastLine && offset >= s.lines[body].start {
return s.span(r.header) + "\n" + s.span(r.delim) + "\n", ""
}
case regionHTML:
// <pre>/<style>/<textarea>: reopen the (possibly multi-line) opening tag
// on the chunk that starts here and close it on the chunk that ends here,
// exactly like a fence. Only interior lines past the opening tag qualify.
if r.htmlRepair {
if body := r.htmlOpenEndLine + 1; body <= r.lastLine && offset >= s.lines[body].start {
return s.span(r.htmlOpen) + "\n", r.htmlCloser
}
}
}
return "", ""
}
// injectionCostAt is injectionAt measured in runes, with the suffix budgeting one
// extra rune for the newline that separates it from a body ending mid-line.
func (s *markdownScan) injectionCostAt(offset int) (prefix, suffix int) {
p, sfx := s.injectionAt(offset)
prefix = utf8.RuneCountInString(p)
if sfx != "" {
suffix = utf8.RuneCountInString(sfx) + 1
}
return prefix, suffix
}
// span is only ever called on a span injectionAt has established, so it is
// always non-empty.
func (s *markdownScan) span(sp srcSpan) string {
return s.content[sp.start:sp.end]
}
// trimTrailingWhitespaceEnd walks back from end over spaces, tabs and newlines.
func trimTrailingWhitespaceEnd(content string, end int) int {
for end > 0 {
r, size := utf8.DecodeLastRuneInString(content[:end])
if r != ' ' && r != '\t' && r != '\n' {
break
}
end -= size
}
return end
}
// runeIndexOf converts a byte offset to a rune index, using the per-line rune
// index as the base so only the tail of one line is counted.
func (s *markdownScan) runeIndexOf(offset int) int {
i := s.lineIndexOf(offset)
return s.lines[i].runeStart + utf8.RuneCountInString(s.content[s.lines[i].start:offset])
}
// lineIndexOf returns the index of the line containing the given byte offset.
func (s *markdownScan) lineIndexOf(offset int) int {
lo, hi := 0, len(s.lines)-1
for lo < hi {
mid := (lo + hi + 1) / 2
if s.lines[mid].start <= offset {
lo = mid
} else {
hi = mid - 1
}
}
return lo
}