package docrevision import ( "crypto/rand" "encoding/hex" "fmt" "regexp" "strings" ) var ( headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) reqIDRe = regexp.MustCompile(`\b(REQ-[A-Z0-9-]+)\b`) listItemRe = regexp.MustCompile(`^(\s*[-*+]|\s*\d+\.)\s+`) tableRowRe = regexp.MustCompile(`^\|`) fenceRe = regexp.MustCompile("^```") mermaidRe = regexp.MustCompile(`(?i)^mermaid\b`) ) // BuildBlockMap parses markdown into stable block nodes. // When previous is provided, nodes are aligned by business key → heading path → fingerprint → order. func BuildBlockMap(markdown string, previous []BlockNode) []BlockNode { lines := strings.Split(strings.TrimSuffix(markdown, "\n"), "\n") var nodes []BlockNode offset := 0 headingPath := []string{} inFence := false fenceLang := "" var fenceBuf []string fenceStart := 0 order := 0 flushFence := func() { if !inFence { return } body := strings.Join(fenceBuf, "\n") typ := "code" if mermaidRe.MatchString(fenceLang) { typ = "mermaid" } nodes = append(nodes, makeNode(typ, fenceStart, offset, strings.Join(headingPath, " / "), "", body, order, previous)) order++ inFence = false fenceBuf = nil fenceLang = "" } for i, line := range lines { lineLen := len(line) if i < len(lines)-1 || strings.HasSuffix(markdown, "\n") { lineLen++ // account for newline except possibly last without NL; we trimSuffix above so always +1 except last } // recompute offset carefully _ = lineLen } offset = 0 for i, line := range lines { start := offset end := start + len(line) if i < len(lines)-1 { end++ // newline } else if strings.HasSuffix(markdown, "\n") { end++ } if fenceRe.MatchString(line) { if !inFence { inFence = true fenceLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) fenceStart = start fenceBuf = nil } else { flushFence() } offset = end continue } if inFence { fenceBuf = append(fenceBuf, line) offset = end continue } if m := headingRe.FindStringSubmatch(line); m != nil { level := len(m[1]) title := strings.TrimSpace(m[2]) if level <= len(headingPath) { headingPath = headingPath[:level-1] } headingPath = append(headingPath, title) path := strings.Join(headingPath, " / ") bk := "" if id := reqIDRe.FindString(title); id != "" { bk = id } nodes = append(nodes, makeNode("heading", start, end, path, bk, line, order, previous)) order++ offset = end continue } path := strings.Join(headingPath, " / ") switch { case tableRowRe.MatchString(line): bk := "" if id := reqIDRe.FindString(line); id != "" { bk = id } nodes = append(nodes, makeNode("table_row", start, end, path, bk, line, order, previous)) case listItemRe.MatchString(line): bk := "" if id := reqIDRe.FindString(line); id != "" { bk = id } nodes = append(nodes, makeNode("list_item", start, end, path, bk, line, order, previous)) case strings.TrimSpace(line) == "": // skip empty default: bk := "" if id := reqIDRe.FindString(line); id != "" { bk = id } nodes = append(nodes, makeNode("paragraph", start, end, path, bk, line, order, previous)) } order++ offset = end } flushFence() return nodes } func makeNode(typ string, start, end int, path, businessKey, text string, order int, previous []BlockNode) BlockNode { fp := ContentFingerprint(text) id := matchPreviousID(typ, path, businessKey, fp, order, previous) if id == "" { id = newBlockID() } return BlockNode{ ID: id, Type: typ, StartOffset: start, EndOffset: end, HeadingPath: path, BusinessKey: businessKey, Fingerprint: fp, Order: order, } } func matchPreviousID(typ, path, businessKey, fp string, order int, previous []BlockNode) string { if len(previous) == 0 { return "" } if businessKey != "" { for _, p := range previous { if p.BusinessKey == businessKey && p.Type == typ { return p.ID } } } for _, p := range previous { if p.Type == typ && p.HeadingPath == path && p.Fingerprint == fp { return p.ID } } for _, p := range previous { if p.Type == typ && p.Fingerprint == fp { return p.ID } } for _, p := range previous { if p.Order == order && p.Type == typ { return p.ID } } return "" } func newBlockID() string { var b [8]byte _, _ = rand.Read(b[:]) return fmt.Sprintf("blk_%s", hex.EncodeToString(b[:])) }