package index
import (
"regexp"
"sort"
"strings"
"unicode/utf8"
)
var (
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
idRe = regexp.MustCompile(`\b([A-Z][A-Z0-9]+-[0-9]+)\b`)
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
wikiRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
statusRe = regexp.MustCompile(`(?i)\bstatus\s*[:=]\s*([a-z0-9_-]+)|\[( |x|X)\]`)
)
func ParseSource(source SourceRecord) ParsedSource {
normalized, diagnostics := normalizeBody(source)
lineStarts := computeLineStarts(source.Body)
frontmatter, bodyStart, fmDiagnostics := parseFrontmatter(source, normalized, lineStarts)
diagnostics = append(diagnostics, fmDiagnostics...)
parsed := ParsedSource{SourceID: source.ID, ContentHash: ContentHash(source.Body), Frontmatter: frontmatter, FrontmatterEnd: bodyStart, Diagnostics: diagnostics, LineStarts: lineStarts, NormalizedBody: normalized}
metadata := mergeMetadata(source.Metadata, frontmatter.Values)
parsed.Aliases = aliasesFromMetadata(metadata)
lines := splitNormalizedLines(normalized)
offset := 0
var headingStack []Heading
for lineIndex, line := range lines {
lineNo := lineIndex + 1
lineByteStart := normalizedOffsetToOriginalOffset(source.Body, normalized, offset)
lineByteEnd := normalizedOffsetToOriginalOffset(source.Body, normalized, offset+len(line))
if offset < bodyStart {
offset += len(line) + newlineWidthAt(normalized, offset+len(line))
continue
}
if match := headingRe.FindStringSubmatch(line); len(match) == 3 {
level := len(match[1])
title := strings.TrimSpace(match[2])
headingStack = trimHeadingStack(headingStack, level)
heading := Heading{Level: level, Title: title, ByteStart: lineByteStart, ByteEnd: lineByteEnd, LineStart: lineNo, LineEnd: lineNo}
heading.HeadingPath = append(headingPath(headingStack), title)
headingStack = append(headingStack, heading)
parsed.Headings = append(parsed.Headings, heading)
}
path := headingPath(headingStack)
for _, match := range mdLinkRe.FindAllStringSubmatchIndex(line, -1) {
text := line[match[2]:match[3]]
target := strings.TrimSpace(line[match[4]:match[5]])
parsed.Links = append(parsed.Links, Link{Raw: line[match[0]:match[1]], Target: target, Text: text, Kind: linkKind(target), ByteStart: lineByteStart + match[0], ByteEnd: lineByteStart + match[1], LineStart: lineNo, LineEnd: lineNo})
}
for _, match := range wikiRe.FindAllStringSubmatchIndex(line, -1) {
target := strings.TrimSpace(line[match[2]:match[3]])
parsed.Links = append(parsed.Links, Link{Raw: line[match[0]:match[1]], Target: target, Text: target, Kind: "wiki", ByteStart: lineByteStart + match[0], ByteEnd: lineByteStart + match[1], LineStart: lineNo, LineEnd: lineNo})
}
for _, match := range idRe.FindAllStringSubmatchIndex(line, -1) {
parsed.StableIDs = append(parsed.StableIDs, StableID{ID: line[match[2]:match[3]], ByteStart: lineByteStart + match[2], ByteEnd: lineByteStart + match[3], LineStart: lineNo, LineEnd: lineNo})
}
if match := statusRe.FindStringSubmatchIndex(line); len(match) > 0 {
value := "open"
if match[2] >= 0 {
value = strings.ToLower(line[match[2]:match[3]])
} else if strings.Contains(strings.ToLower(line[match[0]:match[1]]), "x") {
value = "done"
}
parsed.Statuses = append(parsed.Statuses, Status{Value: value, ByteStart: lineByteStart + match[0], ByteEnd: lineByteStart + match[1], LineStart: lineNo, LineEnd: lineNo, HeadingPath: path})
}
offset += len(line) + newlineWidthAt(normalized, offset+len(line))
}
parsed.StableIDs = append(parsed.StableIDs, StableID{ID: source.ID, LineStart: 1, LineEnd: 1})
return parsed
}
func parseFrontmatter(source SourceRecord, normalized string, lineStarts []int) (Frontmatter, int, []CollisionDiagnostic) {
frontmatter := Frontmatter{Values: map[string]string{}, Valid: true}
if !strings.HasPrefix(normalized, "---\n") && normalized != "---" {
return frontmatter, 0, nil
}
end := strings.Index(normalized[4:], "\n---")
if end < 0 {
frontmatter.Valid = false
return frontmatter, 0, []CollisionDiagnostic{{SourceID: source.ID, Kind: "malformed_frontmatter", Message: "frontmatter opener has no closing marker", LineStart: 1, LineEnd: 1}}
}
end += 4
block := normalized[4:end]
for i, line := range strings.Split(block, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
frontmatter.Valid = false
return frontmatter, end + len("\n---\n"), []CollisionDiagnostic{{SourceID: source.ID, Kind: "malformed_frontmatter", Message: "frontmatter line is not key/value", LineStart: i + 2, LineEnd: i + 2}}
}
frontmatter.Values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return frontmatter, end + len("\n---\n"), nil
}
func normalizeBody(source SourceRecord) (string, []CollisionDiagnostic) {
body := source.Body
var diagnostics []CollisionDiagnostic
if !utf8.ValidString(body) {
for i := 0; i < len(body); {
r, size := utf8.DecodeRuneInString(body[i:])
if r == utf8.RuneError && size == 1 {
diagnostics = append(diagnostics, CollisionDiagnostic{SourceID: source.ID, Kind: "invalid_utf8", Message: "invalid UTF-8 byte replaced in normalized text", LineStart: lineForOffset(computeLineStarts(body), i), LineEnd: lineForOffset(computeLineStarts(body), i)})
}
i += size
}
}
return strings.ReplaceAll(strings.ReplaceAll(strings.ToValidUTF8(body, "�"), "\r\n", "\n"), "\r", "\n"), diagnostics
}
func computeLineStarts(body string) []int {
starts := []int{0}
for i := 0; i < len(body); i++ {
if body[i] == '\n' {
starts = append(starts, i+1)
} else if body[i] == '\r' {
if i+1 < len(body) && body[i+1] == '\n' {
starts = append(starts, i+2)
i++
} else {
starts = append(starts, i+1)
}
}
}
return starts
}
func lineForOffset(starts []int, offset int) int {
idx := sort.Search(len(starts), func(i int) bool { return starts[i] > offset })
if idx == 0 {
return 1
}
return idx
}
func splitNormalizedLines(body string) []string {
lines := strings.Split(body, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
return lines[:len(lines)-1]
}
return lines
}
func newlineWidthAt(body string, offset int) int {
if offset >= len(body) {
return 0
}
if body[offset] == '\n' {
return 1
}
return 0
}
func normalizedOffsetToOriginalOffset(original, normalized string, normalizedOffset int) int {
oi, ni := 0, 0
for oi < len(original) && ni < normalizedOffset {
if original[oi] == '\r' {
if oi+1 < len(original) && original[oi+1] == '\n' {
oi += 2
} else {
oi++
}
ni++
continue
}
_, osize := utf8.DecodeRuneInString(original[oi:])
_, nsize := utf8.DecodeRuneInString(normalized[ni:])
if osize < 1 {
osize = 1
}
if nsize < 1 {
nsize = 1
}
oi += osize
ni += nsize
}
return oi
}
func trimHeadingStack(stack []Heading, level int) []Heading {
for len(stack) > 0 && stack[len(stack)-1].Level >= level {
stack = stack[:len(stack)-1]
}
return stack
}
func headingPath(stack []Heading) []string {
path := make([]string, 0, len(stack))
for _, heading := range stack {
path = append(path, heading.Title)
}
return path
}
func linkKind(target string) string {
if strings.HasPrefix(target, "#") {
return "anchor"
}
if strings.Contains(target, ".md") {
return "relative_path"
}
return "references"
}
func mergeMetadata(first, second map[string]string) map[string]string {
out := map[string]string{}
for k, v := range first {
out[k] = v
}
for k, v := range second {
out[k] = v
}
return out
}
func aliasesFromMetadata(metadata map[string]string) []Alias {
seen := map[string]bool{}
var aliases []Alias
for _, key := range []string{"id", "alias", "aliases"} {
value := metadata[key]
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" || seen[part] {
continue
}
seen[part] = true
aliases = append(aliases, Alias{Type: "id", ID: part})
}
}
return aliases
}