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] = © + } + 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 +}