parse.go
package testmark
import (
"bytes"
"fmt"
"os"
"strings"
"unicode"
)
// Parse parses testmark data from bytes or string and returns a Document.
func Parse[T bytesLike](data T) (*Document, error) {
var dataBytes []byte
switch v := any(data).(type) {
case []byte:
dataBytes = v
case string:
dataBytes = []byte(v)
default:
// This case should never happen due to type constraints
dataBytes = []byte(any(data).(string))
}
doc, err := parseInternal(dataBytes)
if err != nil {
return nil, err
}
buildDirIndex(doc)
return doc, nil
}
// ReadFile parses testmark data from a file.
func ReadFile(filename string) (*Document, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return Parse(data)
}
// MustReadFile is like ReadFile but panics on error.
func MustReadFile(filename string) *Document {
doc, err := ReadFile(filename)
if err != nil {
panic(err)
}
return doc
}
// MustParse is like Parse but panics on error.
func MustParse[T bytesLike](data T) *Document {
doc, err := Parse(data)
if err != nil {
panic(err)
}
return doc
}
var (
sigilLineBreak = []byte{'\n'}
sigilCarriageReturn = []byte{'\r'}
sigilCrLf = []byte{'\r', '\n'}
sigilCodeBlock = []byte("```")
sigilTestmark = []byte("[testmark]:# ")
)
// parseInternal performs the actual parsing without building the directory index
func parseInternal(data []byte) (*Document, error) {
doc := &Document{
original: data,
hunksByName: make(map[string]*Hunk),
}
// Split into lines
doc.lines = bytes.Split(data, sigilLineBreak)
// Parse state
var offset int
var inCodeBlock bool
var expectCodeBlock bool
var codeBlockOffset int
hunkInProgress := hunkInfo{lineStart: -1}
for i, origLine := range doc.lines {
// Support CRLF line endings
line := bytes.TrimSuffix(origLine, sigilCarriageReturn)
// Check for code block transitions
if bytes.HasPrefix(line, sigilCodeBlock) {
switch inCodeBlock {
case false: // starting a block
if expectCodeBlock {
infoString := string(line[len(sigilCodeBlock):])
hunkInProgress.hunk.infoString = infoString
codeBlockOffset = offset + len(origLine) + 1
}
expectCodeBlock = false
case true: // ending a block
if hunkInProgress.lineStart > -1 {
hunkInProgress.lineEnd = i
hunkInProgress.hunk.body = normalizeEndings(doc.original[codeBlockOffset:offset])
doc.hunks = append(doc.hunks, hunkInProgress)
doc.hunksByName[hunkInProgress.hunk.name] = hunkInProgress.hunk
hunkInProgress = hunkInfo{lineStart: -1}
}
}
inCodeBlock = !inCodeBlock
goto next
}
if inCodeBlock {
// Skip lines inside code blocks
goto next
}
if expectCodeBlock {
// Expected a code block but didn't get one
return nil, fmt.Errorf("invalid markdown comment on line %d. Missing code block for hunk %s", i+1, hunkInProgress.hunk.name)
}
// Look for testmark indicators
if bytes.HasPrefix(line, sigilTestmark) {
// Validate format
remainder := line[len(sigilTestmark):]
if len(remainder) < 2 || remainder[0] != '(' || remainder[len(remainder)-1] != ')' {
return nil, fmt.Errorf("invalid markdown comment on line %d", i+1)
}
remainder = remainder[1 : len(remainder)-1]
// Extract name
nameEnd := bytes.IndexFunc(remainder, unicode.IsSpace)
if nameEnd < 0 {
nameEnd = len(remainder)
}
name := string(remainder[0:nameEnd])
if len(name) == 0 {
return nil, fmt.Errorf("invalid markdown comment on line %d, hunk name is empty", i+1)
}
// Check for duplicates
if _, exists := doc.hunksByName[name]; exists {
return nil, fmt.Errorf("repeated testmark hunk name %q on line %d", name, i+1)
}
// Set up hunk
expectCodeBlock = true
hunkInProgress.lineStart = i
hunkInProgress.hunk = &Hunk{name: name}
}
next:
// Track offset for subslicing
offset += len(origLine) + 1
}
return doc, nil
}
// normalizeEndings converts CRLF to LF
func normalizeEndings(in []byte) []byte {
if bytes.Count(in, sigilCrLf) == 0 {
return in
}
return bytes.Replace(in, sigilCrLf, sigilLineBreak, -1)
}
// buildDirIndex constructs the directory tree automatically
func buildDirIndex(doc *Document) {
doc.dirIndex = &DirEntry{
name: "",
path: "",
children: make(map[string]*DirEntry),
}
for _, hunkInfo := range doc.hunks {
segments := strings.Split(hunkInfo.hunk.name, "/")
fillDirEntry(doc.dirIndex, segments, 0, hunkInfo.hunk)
}
}
// fillDirEntry recursively builds directory structure
func fillDirEntry(entry *DirEntry, pathSegments []string, index int, hunk *Hunk) {
if index >= len(pathSegments) {
entry.hunk = hunk
return
}
segment := pathSegments[index]
if entry.children == nil {
entry.children = make(map[string]*DirEntry)
}
child, exists := entry.children[segment]
if !exists {
child = &DirEntry{
name: segment,
path: strings.Join(pathSegments[:index+1], "/"),
children: make(map[string]*DirEntry),
}
entry.children[segment] = child
entry.childrenOrdered = append(entry.childrenOrdered, child)
}
fillDirEntry(child, pathSegments, index+1, hunk)
}
v2/parse.go · 5.0 KiB · b9084569f7