testmark-go

parse.go

package testmark

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
	"unicode"
)

// Parse parses testmark data from bytes and returns a Document.
func Parse(data []byte) (*Document, error) {
	doc, err := parseInternal(data)
	if err != nil {
		return nil, err
	}
	buildDirIndex(doc)
	return doc, nil
}

// ReadFile parses testmark data from a file.
func ReadFile(filename string) (*Document, error) {
	f, err := os.Open(filename)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return Read(f)
}

// Read parses testmark data from a reader.
func Read(r io.Reader) (*Document, error) {
	data, err := io.ReadAll(r)
	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
}

// ParseString parses testmark data from a string.
func ParseString(data string) (*Document, error) {
	return Parse([]byte(data))
}

// MustParseString is like ParseString but panics on error.
func MustParseString(data string) *Document {
	doc, err := ParseString(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.1 KiB · 07c6e4c9e7