Files
dingtalk-workspace-cli/internal/helpers/content_splitter.go
2026-07-15 11:41:30 +08:00

258 lines
7.0 KiB
Go

package helpers
import (
"strings"
"unicode/utf8"
)
// splitMarkdownSafe splits content into chunks of at most `limit` runes each,
// respecting markdown structure boundaries.
//
// Split priority (high to low):
// 1. H1 headings (# )
// 2. H2 headings (## )
// 3. H3 headings (### )
// 4. Blank lines (paragraph boundaries)
// 5. Hard split (preserving table/code block integrity)
//
// Invariant: strings.Join(result, "") == content (no content loss)
func splitMarkdownSafe(content string, limit int) []string {
if utf8.RuneCountInString(content) <= limit {
return []string{content}
}
blocks := parseMarkdownBlocks(content)
return mergeBlocksIntoChunks(blocks, limit)
}
// markdownBlock represents an atomic block that should not be split.
type markdownBlock struct {
text string
blockType int
}
const (
blockNormal = 0
blockH1 = 1
blockH2 = 2
blockH3 = 3
blockTable = 4
blockCodeBlock = 5
)
// parseMarkdownBlocks splits content into atomic blocks that should be kept together.
// The invariant is: strings.Join(all block texts, "") == original content.
// Each block's text includes trailing newlines up to (but not including) the next block's start.
func parseMarkdownBlocks(content string) []markdownBlock {
content = strings.ReplaceAll(content, "\r\n", "\n")
lines := strings.Split(content, "\n")
var blocks []markdownBlock
var currentLines []string
currentType := blockNormal
inCodeBlock := false
flushCurrent := func(includeTrailingNewline bool) {
if len(currentLines) > 0 {
text := strings.Join(currentLines, "\n")
if includeTrailingNewline {
text += "\n"
}
blocks = append(blocks, markdownBlock{text: text, blockType: currentType})
currentLines = nil
currentType = blockNormal
}
}
for i, line := range lines {
trimmed := strings.TrimSpace(line)
isLastLine := i == len(lines)-1
// Code block fence detection
if strings.HasPrefix(trimmed, "```") {
if !inCodeBlock {
flushCurrent(!isLastLine)
currentType = blockCodeBlock
inCodeBlock = true
currentLines = append(currentLines, line)
continue
}
// End of code block
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
inCodeBlock = false
continue
}
if inCodeBlock {
currentLines = append(currentLines, line)
continue
}
// Table line detection
if strings.HasPrefix(trimmed, "|") {
if currentType != blockTable {
flushCurrent(!isLastLine)
currentType = blockTable
}
currentLines = append(currentLines, line)
continue
}
// If we were in a table and hit a non-table line, flush
if currentType == blockTable {
flushCurrent(!isLastLine)
}
// Heading detection — only at line start (not inside other blocks)
// Order matters: check H3 before H2 before H1 to avoid ambiguity
if strings.HasPrefix(line, "### ") {
flushCurrent(!isLastLine)
currentType = blockH3
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
} else if strings.HasPrefix(line, "## ") {
flushCurrent(!isLastLine)
currentType = blockH2
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
}
if strings.HasPrefix(line, "# ") && !strings.HasPrefix(line, "## ") {
flushCurrent(!isLastLine)
currentType = blockH1
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
}
currentLines = append(currentLines, line)
}
// Last block: no trailing newline
flushCurrent(false)
return blocks
}
// mergeBlocksIntoChunks greedily fills chunks up to the limit, then splits
// backwards at the nearest heading boundary. This ensures chunks are as large
// as possible while still breaking at meaningful markdown structure points.
//
// Strategy: fill forward until adding the next block would exceed the limit,
// then look backwards for the last heading in the current chunk to split there.
// If no heading is found, split at the overflow point (greedy).
func mergeBlocksIntoChunks(blocks []markdownBlock, limit int) []string {
var chunks []string
i := 0
for i < len(blocks) {
// Accumulate blocks greedily until we'd exceed the limit
var chunkBlocks []markdownBlock
chunkRunes := 0
for i < len(blocks) {
blockRunes := utf8.RuneCountInString(blocks[i].text)
// Single oversized block: hard-split it
if blockRunes > limit && chunkRunes == 0 {
subChunks := hardSplitBlock(blocks[i].text, limit)
chunks = append(chunks, subChunks...)
i++
chunkBlocks = nil
chunkRunes = 0
continue
}
// Would exceed limit: stop accumulating
if chunkRunes+blockRunes > limit && chunkRunes > 0 {
break
}
chunkBlocks = append(chunkBlocks, blocks[i])
chunkRunes += blockRunes
i++
}
if len(chunkBlocks) == 0 {
continue
}
// If we stopped because of overflow AND there are multiple blocks,
// look backwards for the last heading to use as a split point
if i < len(blocks) && len(chunkBlocks) > 1 {
splitIdx := -1
for j := len(chunkBlocks) - 1; j > 0; j-- {
bt := chunkBlocks[j].blockType
if bt == blockH1 || bt == blockH2 || bt == blockH3 {
splitIdx = j
break
}
}
if splitIdx > 0 {
// Split: emit blocks before the heading, push heading+ back
var emitBuilder strings.Builder
for _, b := range chunkBlocks[:splitIdx] {
emitBuilder.WriteString(b.text)
}
chunks = append(chunks, emitBuilder.String())
// Rewind: put the heading and subsequent blocks back for next iteration
i -= len(chunkBlocks) - splitIdx
continue
}
}
// No heading split point found (or single block): emit all accumulated blocks
var emitBuilder strings.Builder
for _, b := range chunkBlocks {
emitBuilder.WriteString(b.text)
}
chunks = append(chunks, emitBuilder.String())
}
return chunks
}
// hardSplitBlock splits a single oversized block at paragraph boundaries,
// falling back to rune-level splitting.
func hardSplitBlock(text string, limit int) []string {
// SplitAfter keeps the paragraph separator attached to the preceding
// paragraph. The previous Split implementation rebuilt separators while
// merging, but dropped them whenever a chunk boundary fell between two
// paragraphs, violating the no-content-loss invariant.
paragraphs := strings.SplitAfter(text, "\n\n")
var chunks []string
var current strings.Builder
currentRunes := 0
for _, para := range paragraphs {
paraRunes := utf8.RuneCountInString(para)
if currentRunes+paraRunes > limit && currentRunes > 0 {
chunks = append(chunks, current.String())
current.Reset()
currentRunes = 0
}
// If single paragraph exceeds limit, split by runes
if paraRunes > limit {
runes := []rune(para)
for start := 0; start < len(runes); start += limit {
end := start + limit
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[start:end]))
}
continue
}
current.WriteString(para)
currentRunes += paraRunes
}
if currentRunes > 0 {
chunks = append(chunks, current.String())
}
return chunks
}