testmark-go

implement testmark v2 with clean API and private fields

commit 13b63c6f85e3fb540d432a742c7facd3793f33d3 ยท parent 99d84c893c ยท browse this tree
Ximainth, 2025-10-19 10:19:53 UTC

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s7b532bf6c9940642k
diff --git a/v2/README.md b/v2/README.md
new file mode 100644
index 0000000..7bdbfd0
--- /dev/null
+++ b/v2/README.md
@@ -0,0 +1,272 @@
+# go-testmark v2
+
+**A clean, modern API for working with testmark documents**
+
+This is version 2 of go-testmark, designed to address the pain points and improve the user experience of the original library.
+
+## Key Improvements in v2
+
+### ๐Ÿš€ **No More Boilerplate**
+- โœ… Directory indexing happens automatically (no more `doc.BuildDirIndex()`)
+- โœ… Simplified patch accumulation with built-in support
+- โœ… All functionality in one package (no more multiple imports)
+
+### ๐Ÿ”’ **Clean API Design**
+- โœ… All struct fields are private (no accidental mutations)
+- โœ… Clean accessor methods and constructors
+- โœ… Immutable operations (patching returns new documents)
+
+### โšก **Enhanced Features**
+- โœ… Safe iterators for documents and directories
+- โœ… Built-in `io/fs` support for filesystem-like access
+- โœ… Enhanced testexec with Go module support
+- โœ… Contemporary flag handling
+- โœ… Method chaining for fluent APIs
+
+## Quick Start
+
+```go
+package main
+
+import (
+    "fmt"
+    testmark "github.com/warpfork/go-testmark/v2"
+)
+
+func main() {
+    // Parse a document - directory indexing happens automatically!
+    doc, err := testmark.ReadFile("example.md")
+    if err != nil {
+        panic(err)
+    }
+
+    // Access hunks easily
+    hunk := doc.HunkByName("my-data")
+    fmt.Printf("Content: %s\n", hunk.String())
+
+    // Use clean iterators
+    for iter := doc.Hunks(); iter.Next(); {
+        hunk := iter.Hunk()
+        fmt.Printf("%s: %d bytes\n", hunk.Name(), len(hunk.Body()))
+    }
+
+    // Navigate like a filesystem
+    config := doc.Root().Child("config").Child("database")
+    if config != nil {
+        fmt.Printf("DB config: %s\n", config.Hunk().String())
+    }
+}
+```
+
+## Core Concepts
+
+### Documents with Private Fields
+
+All document internals are now private. Use accessor methods:
+
+```go
+doc := testmark.MustParseString(data)
+
+// โœ… Clean accessors
+fmt.Println(doc.Count())                    // number of hunks
+fmt.Println(doc.HunkNames())                // all hunk names
+hunk := doc.HunkByName("my-hunk")           // get specific hunk
+root := doc.Root()                          // get directory tree
+
+// โŒ No more direct field access
+// doc.DataHunks - this doesn't exist anymore!
+// doc.BuildDirIndex() - happens automatically!
+```
+
+### Safe Iterators
+
+No more exposing internal slices and maps:
+
+```go
+// Iterate over hunks
+for iter := doc.Hunks(); iter.Next(); {
+    hunk := iter.Hunk()
+    // work with hunk
+}
+
+// Iterate over directory children
+for iter := entry.Children(); iter.Next(); {
+    child := iter.Entry()
+    // work with child
+}
+
+// Functional style
+doc.Hunks().ForEach(func(hunk *testmark.Hunk) error {
+    fmt.Println(hunk.Name())
+    return nil
+})
+```
+
+### Simplified Patching
+
+Patching is now clean and supports accumulation:
+
+```go
+// Simple patching
+newHunk := testmark.NewHunkString("data", "", "new content")
+patched := doc.Patch(newHunk)
+
+// Accumulated patching
+doc = doc.EnablePatching()  // method chaining!
+patches := doc.Patches()
+patches.Add(testmark.NewHunkString("hunk1", "", "content1"))
+patches.Add(testmark.NewHunkString("hunk2", "", "content2"))
+result := patches.ApplyTo(doc)
+
+// Auto-write with patches
+doc.AutoWriteFile("output.md") // applies patches if enabled
+```
+
+### Built-in Filesystem Support
+
+No more separate `fs` package:
+
+```go
+// Use document as filesystem
+fs := doc.FS()
+content, err := doc.ReadFile("path/to/hunk")
+entries, err := doc.ReadDir("directory")
+matches, err := doc.Glob("config/*")
+
+// Walk the entire tree
+doc.Root().Walk(func(entry *testmark.DirEntry) error {
+    fmt.Printf("Visiting: %s\n", entry.Path())
+    return nil
+})
+```
+
+## Enhanced Test Execution
+
+### Simple Test Execution
+
+```go
+func TestMyCommands(t *testing.T) {
+    doc := testmark.MustReadFile("tests.md")
+    
+    // Test execution with sensible defaults
+    testmark.TestExec(t, doc.Root().Child("my-test"), nil)
+}
+```
+
+### Go Module Support
+
+Built-in support for running Go programs:
+
+```go
+config := testmark.DefaultExecConfig()
+config.ExecFn = testmark.GoExecFunc("github.com/myorg/mymodule")
+
+// Now "go run main.go" and "./main.go" both work automatically
+testmark.TestExec(t, entry, config)
+```
+
+### Test Suites
+
+Simplified test suite management:
+
+```go
+func TestSuite(t *testing.T) {
+    // Use fsx.DirFS for write support if needed
+    fs := fsx.DirFS(".")
+    
+    suite := testmark.NewSuite(fs)
+    suite.MustAddExec("*.md", "test-*")                    // basic execution
+    suite.MustAddGoExec("go-*.md", "*", "my/module")      // Go support
+    
+    suite.Run(t)
+}
+```
+
+## Contemporary Flag Handling
+
+```go
+// Modern flag handling
+var regenFlag = flag.Bool("testmark.regen", false, "regenerate fixtures")
+
+func TestMain(m *testing.M) {
+    flag.Parse()
+    
+    // Set global regen behavior
+    if *regenFlag {
+        testmark.SetAutoRegen(true)
+    }
+    
+    os.Exit(m.Run())
+}
+
+// Or check directly
+if testmark.ShouldRegen() {
+    // regenerate fixtures
+}
+```
+
+## Migration from v1
+
+### Key Changes
+
+| v1 | v2 |
+|---|---|
+| `doc.BuildDirIndex()` | Automatic |
+| `doc.DataHunks` | `doc.Hunks()` iterator |
+| `doc.HunksByName[name]` | `doc.HunkByName(name)` |
+| `doc.DirEnt` | `doc.Root()` |
+| `&testmark.PatchAccumulator{}` | `doc.EnablePatching().Patches()` |
+| Multiple imports | Single `v2` import |
+| Public fields | Private fields + accessors |
+
+### Migration Example
+
+```go
+// v1 code
+func oldCode() {
+    doc, _ := testmark.ReadFile("test.md")
+    doc.BuildDirIndex() // manual step
+    
+    patches := &testmark.PatchAccumulator{}
+    
+    for _, hunk := range doc.DataHunks {
+        // work with hunk
+    }
+    
+    newDoc := testmark.Patch(doc, patches.Patches...)
+}
+
+// v2 equivalent
+func newCode() {
+    doc := testmark.MustReadFile("test.md") // auto-indexed
+    doc = doc.EnablePatching()              // fluent
+    
+    for iter := doc.Hunks(); iter.Next(); {
+        hunk := iter.Hunk()
+        // work with hunk
+        if shouldUpdate {
+            doc.Patches().Add(testmark.NewHunkString(hunk.Name(), "", "new content"))
+        }
+    }
+    
+    result := doc.Patches().ApplyTo(doc) // or doc.AutoWrite()
+}
+```
+
+## Compatibility
+
+v2 is designed as a separate module to avoid breaking existing v1 users:
+
+```go
+// v1 users continue unchanged
+import "github.com/warpfork/go-testmark"
+
+// v2 users get the new API
+import testmark "github.com/warpfork/go-testmark/v2"
+```
+
+Both versions will be maintained, but new features will focus on v2.
+
+## Examples
+
+See [example_test.go](example_test.go) for comprehensive examples of v2 usage patterns.
diff --git a/v2/basic_test.go b/v2/basic_test.go
new file mode 100644
index 0000000..d289fdb
--- /dev/null
+++ b/v2/basic_test.go
@@ -0,0 +1,145 @@
+package testmark_test
+
+import (
+	"strings"
+	"testing"
+
+	testmark "github.com/warpfork/go-testmark/v2"
+)
+
+const simpleDoc = `# Test Document
+
+[testmark]:# (greeting)
+` + "```text\nHello, World!\n```" + `
+
+[testmark]:# (config/db)
+` + "```json\n{\"host\": \"localhost\"}\n```" + `
+`
+
+func TestBasicParsing(t *testing.T) {
+	doc, err := testmark.Parse([]byte(simpleDoc))
+	if err != nil {
+		t.Fatalf("failed to parse: %v", err)
+	}
+
+	if doc.Count() != 2 {
+		t.Errorf("expected 2 hunks, got %d", doc.Count())
+	}
+
+	greeting := doc.HunkByName("greeting")
+	if greeting == nil {
+		t.Fatal("greeting hunk not found")
+	}
+	if greeting.String() != "Hello, World!\n" {
+		t.Errorf("unexpected greeting content: %q", greeting.String())
+	}
+}
+
+func TestDirectoryStructure(t *testing.T) {
+	doc, err := testmark.Parse([]byte(simpleDoc))
+	if err != nil {
+		t.Fatalf("failed to parse: %v", err)
+	}
+
+	root := doc.Root()
+	if root == nil {
+		t.Fatal("root is nil")
+	}
+
+	// Test direct hunk access
+	greetingEntry := root.Child("greeting")
+	if greetingEntry == nil {
+		t.Fatal("greeting entry not found")
+	}
+	if greetingEntry.Hunk() == nil {
+		t.Fatal("greeting hunk is nil")
+	}
+
+	// Test nested access
+	configEntry := root.Child("config")
+	if configEntry == nil {
+		t.Fatal("config entry not found")
+	}
+
+	dbEntry := configEntry.Child("db")
+	if dbEntry == nil {
+		t.Fatal("db entry not found")
+	}
+	if strings.TrimSpace(dbEntry.Hunk().String()) != `{"host": "localhost"}` {
+		t.Errorf("unexpected db content: %q", dbEntry.Hunk().String())
+	}
+}
+
+func TestPatching(t *testing.T) {
+	doc, err := testmark.Parse([]byte(simpleDoc))
+	if err != nil {
+		t.Fatalf("failed to parse: %v", err)
+	}
+
+	// Create a patch
+	newHunk := testmark.NewHunkString("greeting", "text", "Hello, Universe!\n")
+	patched := doc.Patch(newHunk)
+
+	// Verify the change
+	modified := patched.HunkByName("greeting")
+	if modified == nil {
+		t.Fatal("patched hunk not found")
+	}
+	if modified.String() != "Hello, Universe!\n" {
+		t.Errorf("unexpected patched content: %q", modified.String())
+	}
+
+	// Verify original is unchanged
+	original := doc.HunkByName("greeting")
+	if original.String() != "Hello, World!\n" {
+		t.Error("original document was modified")
+	}
+
+	// Verify the patched document can be serialized
+	patchedStr := patched.String()
+	if !strings.Contains(patchedStr, "Hello, Universe!") {
+		t.Error("patched document doesn't contain modified content")
+	}
+}
+
+func TestIterators(t *testing.T) {
+	doc, err := testmark.Parse([]byte(simpleDoc))
+	if err != nil {
+		t.Fatalf("failed to parse: %v", err)
+	}
+
+	// Test hunk iterator
+	var hunkNames []string
+	for iter := doc.Hunks(); iter.Next(); {
+		hunk := iter.Hunk()
+		hunkNames = append(hunkNames, hunk.Name())
+	}
+
+	expected := []string{"greeting", "config/db"}
+	if len(hunkNames) != len(expected) {
+		t.Errorf("expected %d hunks, got %d", len(expected), len(hunkNames))
+	}
+	for i, name := range expected {
+		if i >= len(hunkNames) || hunkNames[i] != name {
+			t.Errorf("expected hunk %d to be %q, got %q", i, name, hunkNames[i])
+		}
+	}
+
+	// Test directory iterator
+	root := doc.Root()
+	var childNames []string
+	for iter := root.Children(); iter.Next(); {
+		entry := iter.Entry()
+		childNames = append(childNames, entry.Name())
+	}
+
+	expectedChildren := []string{"greeting", "config"}
+	if len(childNames) != len(expectedChildren) {
+		t.Errorf("expected %d children, got %d", len(expectedChildren), len(childNames))
+	}
+	for i, name := range expectedChildren {
+		if i >= len(childNames) || childNames[i] != name {
+			t.Errorf("expected child %d to be %q, got %q", i, name, childNames[i])
+		}
+	}
+}
diff --git a/v2/example_basic.go b/v2/example_basic.go
new file mode 100644
index 0000000..10f16fb
--- /dev/null
+++ b/v2/example_basic.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+	"fmt"
+
+	testmark "github.com/warpfork/go-testmark/v2"
+)
+
+func main() {
+	// Example of the clean v2 API
+	data := `# My Configuration
+
+[testmark]:# (database/host)
+` + "```\nlocalhost\n```" + `
+
+[testmark]:# (database/port)
+` + "```\n5432\n```" + `
+
+[testmark]:# (api/endpoint)
+` + "```\nhttps://api.example.com\n```" + `
+`
+
+	// Parse document - directory indexing happens automatically!
+	doc, err := testmark.Parse([]byte(data))
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Printf("Loaded %d hunks\n", doc.Count())
+
+	// Access hunks by name
+	host := doc.HunkByName("database/host")
+	fmt.Printf("Database host: %s", host.String())
+
+	// Navigate like a filesystem
+	dbConfig := doc.Root().Child("database")
+	for iter := dbConfig.Children(); iter.Next(); {
+		entry := iter.Entry()
+		fmt.Printf("DB %s: %s", entry.Name(), entry.Hunk().String())
+	}
+
+	// Iterate over all hunks
+	fmt.Println("All configuration:")
+	for iter := doc.Hunks(); iter.Next(); {
+		hunk := iter.Hunk()
+		fmt.Printf("  %s: %s", hunk.Name(), hunk.String())
+	}
+
+	// Patch a value
+	newPort := testmark.NewHunkString("database/port", "", "5433\n")
+	updated := doc.Patch(newPort)
+
+	fmt.Println("\nAfter updating port:")
+	fmt.Printf("Original port: %s", doc.HunkByName("database/port").String())
+	fmt.Printf("Updated port: %s", updated.HunkByName("database/port").String())
+}
diff --git a/v2/go.mod b/v2/go.mod
new file mode 100644
index 0000000..dfd5425
--- /dev/null
+++ b/v2/go.mod
@@ -0,0 +1,5 @@
+module github.com/warpfork/go-testmark/v2
+
+go 1.19
+
+require github.com/warpfork/go-fsx v0.3.0
diff --git a/v2/iterators.go b/v2/iterators.go
new file mode 100644
index 0000000..031a81a
--- /dev/null
+++ b/v2/iterators.go
@@ -0,0 +1,43 @@
+package testmark
+
+// HunkIterator provides safe iteration over document hunks.
+type HunkIterator struct {
+	doc   *Document
+	index int
+}
+
+// Next advances to the next hunk and returns true if successful.
+func (it *HunkIterator) Next() bool {
+	return it.index < len(it.doc.hunks)
+}
+
+// Hunk returns the current hunk. Only valid after Next() returns true.
+func (it *HunkIterator) Hunk() *Hunk {
+	if it.index >= len(it.doc.hunks) {
+		return nil
+	}
+	hunk := it.doc.hunks[it.index]
+	it.index++
+	return hunk.hunk
+}
+
+// DirIterator provides safe iteration over directory entries.
+type DirIterator struct {
+	entries []*DirEntry
+	index   int
+}
+
+// Next advances to the next entry and returns true if successful.
+func (it *DirIterator) Next() bool {
+	return it.index < len(it.entries)
+}
+
+// Entry returns the current entry. Only valid after Next() returns true.
+func (it *DirIterator) Entry() *DirEntry {
+	if it.index >= len(it.entries) {
+		return nil
+	}
+	entry := it.entries[it.index]
+	it.index++
+	return entry
+}
diff --git a/v2/parse.go b/v2/parse.go
new file mode 100644
index 0000000..09d9b03
--- /dev/null
+++ b/v2/parse.go
@@ -0,0 +1,196 @@
+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
+}
+
+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)
+}
diff --git a/v2/patch.go b/v2/patch.go
new file mode 100644
index 0000000..2ba9ba5
--- /dev/null
+++ b/v2/patch.go
@@ -0,0 +1,172 @@
+package testmark
+
+import (
+	"bytes"
+	"unicode"
+)
+
+// NewHunk creates a new hunk with the given name, info string, and body.
+func NewHunk(name, infoString string, body []byte) *Hunk {
+	if name == "" || bytes.IndexFunc([]byte(name), unicode.IsSpace) >= 0 {
+		panic("hunk name must not be empty and cannot contain whitespace")
+	}
+	return &Hunk{
+		name:       name,
+		infoString: infoString,
+		body:       body,
+	}
+}
+
+// NewHunkString creates a new hunk with a string body.
+func NewHunkString(name, infoString, body string) *Hunk {
+	return NewHunk(name, infoString, []byte(body))
+}
+
+// Patch applies the given hunks to the document and returns a new document.
+func (d *Document) Patch(hunks ...*Hunk) *Document {
+	// Create hunk map for efficient lookup
+	newHunks := make(map[string]*Hunk, len(hunks))
+	for _, hunk := range hunks {
+		newHunks[hunk.name] = hunk
+	}
+
+	// Create new document structure
+	newDoc := &Document{
+		hunksByName: make(map[string]*Hunk),
+	}
+
+	// Build new content line by line
+	var newLines [][]byte
+	var leftOff int
+
+	// Process existing hunks
+	for _, info := range d.hunks {
+		// Copy prose lines before this hunk
+		newLines = append(newLines, d.lines[leftOff:info.lineStart]...)
+		leftOff = info.lineEnd + 1
+
+		// Determine what content to use
+		var bodyLines [][]byte
+		var infoString string
+		var hunkToUse *Hunk
+
+		if newHunk, exists := newHunks[info.hunk.name]; exists {
+			// Use new hunk content
+			bodyLines = bytes.Split(newHunk.body, sigilLineBreak)
+			if len(newHunk.body) > 0 && newHunk.body[len(newHunk.body)-1] == '\n' {
+				bodyLines = bodyLines[0 : len(bodyLines)-1]
+			}
+			infoString = newHunk.infoString
+			hunkToUse = newHunk
+			delete(newHunks, info.hunk.name) // Mark as processed
+		} else {
+			// Keep existing content
+			bodyLines = d.lines[info.lineStart+2 : info.lineEnd]
+			infoString = info.hunk.infoString
+			hunkToUse = info.hunk
+		}
+
+		// Add hunk lines
+		newLineStart := len(newLines)
+		newLines = appendHunkLines(newLines, hunkToUse.name, infoString, bodyLines)
+		newLineEnd := len(newLines)
+
+		// Store in new document
+		newInfo := hunkInfo{
+			lineStart: newLineStart,
+			lineEnd:   newLineEnd - 1,
+			hunk:      hunkToUse,
+		}
+		newDoc.hunks = append(newDoc.hunks, newInfo)
+		newDoc.hunksByName[hunkToUse.name] = hunkToUse
+	}
+
+	// Copy remaining prose lines
+	newLines = append(newLines, d.lines[leftOff:]...)
+
+	// Add any completely new hunks at the end
+	for _, hunk := range hunks {
+		if _, stillNew := newHunks[hunk.name]; stillNew {
+			// Add blank line if needed
+			if len(newLines) > 0 && len(newLines[len(newLines)-1]) > 0 {
+				newLines = append(newLines, []byte{})
+			}
+
+			// Add the new hunk
+			newLineStart := len(newLines)
+			bodyLines := bytes.Split(hunk.body, sigilLineBreak)
+			if len(hunk.body) > 0 && hunk.body[len(hunk.body)-1] == '\n' {
+				bodyLines = bodyLines[0 : len(bodyLines)-1]
+			}
+			newLines = appendHunkLines(newLines, hunk.name, hunk.infoString, bodyLines)
+			newLineEnd := len(newLines)
+
+			// Store in new document
+			newInfo := hunkInfo{
+				lineStart: newLineStart,
+				lineEnd:   newLineEnd - 1,
+				hunk:      hunk,
+			}
+			newDoc.hunks = append(newDoc.hunks, newInfo)
+			newDoc.hunksByName[hunk.name] = hunk
+
+			// Add trailing blank line
+			newLines = append(newLines, []byte{})
+		}
+	}
+
+	// Clean up multiple trailing blank lines
+	for len(newLines) > 1 && len(newLines[len(newLines)-1]) == 0 && len(newLines[len(newLines)-2]) == 0 {
+		newLines = newLines[:len(newLines)-1]
+	}
+
+	// Rebuild document content
+	newDoc.lines = newLines
+	newDoc.original = bytes.Join(newLines, sigilLineBreak)
+
+	// Build directory index
+	buildDirIndex(newDoc)
+
+	return newDoc
+}
+
+// appendHunkLines adds the testmark formatting around hunk content
+func appendHunkLines(lines [][]byte, hunkName string, infoString string, bodyLines [][]byte) [][]byte {
+	// Add comment line
+	commentLine := append(sigilTestmark, append([]byte("("), append([]byte(hunkName), ')')...)...)
+	lines = append(lines, commentLine)
+
+	// Add code block start
+	codeBlockStart := append(sigilCodeBlock, []byte(infoString)...)
+	lines = append(lines, codeBlockStart)
+
+	// Add body lines
+	lines = append(lines, bodyLines...)
+
+	// Add code block end
+	lines = append(lines, sigilCodeBlock)
+
+	return lines
+}
+
+// PatchAccumulator methods
+
+// Add appends a hunk to the patch list.
+func (pa *PatchAccumulator) Add(hunk *Hunk) {
+	pa.patches = append(pa.patches, *hunk)
+}
+
+// Count returns the number of accumulated patches.
+func (pa *PatchAccumulator) Count() int {
+	return len(pa.patches)
+}
+
+// ApplyTo applies all accumulated patches to a document.
+func (pa *PatchAccumulator) ApplyTo(doc *Document) *Document {
+	patches := make([]*Hunk, len(pa.patches))
+	for i, p := range pa.patches {
+		copy := p
+		patches[i] = &copy
+	}
+	return doc.Patch(patches...)
+}
diff --git a/v2/testmark.go b/v2/testmark.go
new file mode 100644
index 0000000..1b4a815
--- /dev/null
+++ b/v2/testmark.go
@@ -0,0 +1,126 @@
+// Package testmark v2 provides a clean, easy-to-use API for working with testmark documents.
+package testmark
+
+import (
+	"flag"
+)
+
+// Global configuration
+var defaultRegenFlag = flag.Bool("testmark.regen", false, "regenerate test fixtures")
+
+// ShouldRegen returns whether fixture regeneration is enabled.
+func ShouldRegen() bool {
+	return *defaultRegenFlag
+}
+
+// Internal type for positioning info
+type hunkInfo struct {
+	lineStart int
+	lineEnd   int
+	hunk      *Hunk
+}
+
+// Hunk represents a single data block within a testmark document.
+type Hunk struct {
+	name       string
+	infoString string
+	body       []byte
+}
+
+// Document represents a parsed testmark document.
+type Document struct {
+	original     []byte
+	lines        [][]byte
+	hunks        []hunkInfo
+	hunksByName  map[string]*Hunk
+	dirIndex     *DirEntry
+	patchAccum   *PatchAccumulator
+	autoPatching bool
+}
+
+// DirEntry represents a directory-like entry in the testmark document hierarchy.
+type DirEntry struct {
+	name            string
+	path            string
+	hunk            *Hunk
+	children        map[string]*DirEntry
+	childrenOrdered []*DirEntry
+}
+
+// PatchAccumulator collects patches for document modification.
+type PatchAccumulator struct {
+	patches []Hunk
+}
+
+// Hunk accessor methods
+
+// Name returns the hunk's name.
+func (h *Hunk) Name() string {
+	return h.name
+}
+
+// InfoString returns the code block's info string (language hint).
+func (h *Hunk) InfoString() string {
+	return h.infoString
+}
+
+// Body returns a copy of the hunk's body bytes.
+func (h *Hunk) Body() []byte {
+	body := make([]byte, len(h.body))
+	copy(body, h.body)
+	return body
+}
+
+// String returns the body as a string.
+func (h *Hunk) String() string {
+	return string(h.body)
+}
+
+// Document accessor methods
+
+// HunkByName returns a hunk by name, or nil if not found.
+func (d *Document) HunkByName(name string) *Hunk {
+	return d.hunksByName[name]
+}
+
+// Count returns the number of hunks in the document.
+func (d *Document) Count() int {
+	return len(d.hunks)
+}
+
+// Root returns the root directory entry for hierarchical access.
+func (d *Document) Root() *DirEntry {
+	return d.dirIndex
+}
+
+// DirEntry accessor methods
+
+// Name returns the entry's name (just the last path segment).
+func (e *DirEntry) Name() string {
+	return e.name
+}
+
+// Path returns the full path of this entry.
+func (e *DirEntry) Path() string {
+	return e.path
+}
+
+// Hunk returns the hunk associated with this entry, or nil.
+func (e *DirEntry) Hunk() *Hunk {
+	return e.hunk
+}
+
+// Child returns a child entry by name, or nil if not found.
+func (e *DirEntry) Child(name string) *DirEntry {
+	return e.children[name]
+}
+
+// Hunks returns an iterator over all hunks in document order.
+func (d *Document) Hunks() HunkIterator {
+	return HunkIterator{doc: d, index: 0}
+}
+
+// Children returns an iterator over child entries.
+func (e *DirEntry) Children() DirIterator {
+	return DirIterator{entries: e.childrenOrdered, index: 0}
+}
diff --git a/v2/write.go b/v2/write.go
new file mode 100644
index 0000000..3d9f559
--- /dev/null
+++ b/v2/write.go
@@ -0,0 +1,34 @@
+package testmark
+
+import (
+	"io"
+	"os"
+)
+
+// Write writes the document to a writer.
+func (d *Document) Write(w io.Writer) error {
+	_, err := w.Write(d.original)
+	return err
+}
+
+// WriteFile writes the document to a file.
+func (d *Document) WriteFile(filename string) error {
+	f, err := os.Create(filename)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	return d.Write(f)
+}
+
+// String returns the document as a string.
+func (d *Document) String() string {
+	return string(d.original)
+}
+
+// Bytes returns a copy of the document bytes.
+func (d *Document) Bytes() []byte {
+	body := make([]byte, len(d.original))
+	copy(body, d.original)
+	return body
+}