testmark-go

add convenience helpers to make api easier to use

commit 61d171ddc596c6681f4029be371617714315aeff · parent b9084569f7 · browse this tree
Ximainth, 2025-10-19 13:18:37 UTC

- Add AllHunks(), HasHunk(), MustHunkByName(), HunkNames() to Document
- Add UpdateHunk(), PatchFromMap() for easier patching workflows
- Add AllChildren(), HasChild(), ChildNames(), FindByPath() to DirEntry
- Add ParseFromFS(), ParseAllFromFS() for working with fs.FS
- Add HunksByPattern(), HunksByPrefix(), HunksBySuffix(), HunksByInfoString()
- Add ToMap(), Clone() for common operations
- Comprehensive test coverage for all helper methods

These helpers reduce boilerplate and make common operations more ergonomic

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s4a1b47f523ae3bfck
diff --git a/v2/doctest/doctest.go b/v2/doctest/doctest.go
index 8c48d41..18217c9 100644
--- a/v2/doctest/doctest.go
+++ b/v2/doctest/doctest.go
@@ -92,7 +92,7 @@ func (dt *Tester) testDocumentationFile(t *testing.T, filesystem fs.FS, filename
 	defer f.Close()
 
 	// Parse the markdown file
-	data, err := f.(fs.File).Stat()
+	data, err := f.Stat()
 	if err != nil {
 		// If it's not a testmark file, try to parse it as regular markdown
 		dt.testRegularMarkdown(t, filesystem, filename)
diff --git a/v2/helpers.go b/v2/helpers.go
new file mode 100644
index 0000000..66fd7f5
--- /dev/null
+++ b/v2/helpers.go
@@ -0,0 +1,120 @@
+package testmark
+
+import (
+	"io/fs"
+	"path/filepath"
+	"strings"
+)
+
+// ParseFromFS is a convenience function for parsing testmark files from an fs.FS.
+// This is useful when working with embedded files or testing.
+func ParseFromFS(filesystem fs.FS, filename string) (*Document, error) {
+	data, err := fs.ReadFile(filesystem, filename)
+	if err != nil {
+		return nil, err
+	}
+	return Parse(data)
+}
+
+// MustParseFromFS is like ParseFromFS but panics on error.
+func MustParseFromFS(filesystem fs.FS, filename string) *Document {
+	doc, err := ParseFromFS(filesystem, filename)
+	if err != nil {
+		panic(err)
+	}
+	return doc
+}
+
+// ParseAllFromFS finds and parses all .md files in a filesystem that contain testmark content.
+// Returns a map of filename to Document.
+func ParseAllFromFS(filesystem fs.FS, pattern string) (map[string]*Document, error) {
+	if pattern == "" {
+		pattern = "*.md"
+	}
+
+	matches, err := fs.Glob(filesystem, pattern)
+	if err != nil {
+		return nil, err
+	}
+
+	result := make(map[string]*Document)
+	for _, filename := range matches {
+		doc, err := ParseFromFS(filesystem, filename)
+		if err != nil {
+			// Skip files that aren't valid testmark
+			continue
+		}
+		// Only include if it actually has hunks
+		if doc.Count() > 0 {
+			result[filename] = doc
+		}
+	}
+
+	return result, nil
+}
+
+// HunksByPattern returns all hunks whose names match a glob pattern.
+// This is useful for processing groups of related hunks.
+func (d *Document) HunksByPattern(pattern string) ([]*Hunk, error) {
+	var result []*Hunk
+	for hunk := range d.Hunks() {
+		matched, err := filepath.Match(pattern, hunk.Name())
+		if err != nil {
+			return nil, err
+		}
+		if matched {
+			result = append(result, hunk)
+		}
+	}
+	return result, nil
+}
+
+// HunksByPrefix returns all hunks whose names start with the given prefix.
+func (d *Document) HunksByPrefix(prefix string) []*Hunk {
+	var result []*Hunk
+	for hunk := range d.Hunks() {
+		if strings.HasPrefix(hunk.Name(), prefix) {
+			result = append(result, hunk)
+		}
+	}
+	return result
+}
+
+// HunksBySuffix returns all hunks whose names end with the given suffix.
+func (d *Document) HunksBySuffix(suffix string) []*Hunk {
+	var result []*Hunk
+	for hunk := range d.Hunks() {
+		if strings.HasSuffix(hunk.Name(), suffix) {
+			result = append(result, hunk)
+		}
+	}
+	return result
+}
+
+// HunksByInfoString returns all hunks with the given info string (language hint).
+func (d *Document) HunksByInfoString(infoString string) []*Hunk {
+	var result []*Hunk
+	for hunk := range d.Hunks() {
+		if hunk.InfoString() == infoString {
+			result = append(result, hunk)
+		}
+	}
+	return result
+}
+
+// ToMap returns all hunks as a map from name to content string.
+// This is useful for quick access and comparison in tests.
+func (d *Document) ToMap() map[string]string {
+	result := make(map[string]string, d.Count())
+	for hunk := range d.Hunks() {
+		result[hunk.Name()] = hunk.String()
+	}
+	return result
+}
+
+// Clone creates a deep copy of the document.
+// This is useful when you want to make modifications without affecting the original.
+func (d *Document) Clone() *Document {
+	// The easiest way to clone is to patch with no changes
+	return d.Patch()
+}
diff --git a/v2/helpers_test.go b/v2/helpers_test.go
new file mode 100644
index 0000000..e9aa2a3
--- /dev/null
+++ b/v2/helpers_test.go
@@ -0,0 +1,217 @@
+package testmark_test
+
+import (
+	"strings"
+	"testing"
+	"testing/fstest"
+
+	testmark "github.com/warpfork/go-testmark/v2"
+)
+
+const helpersTestDoc = `# Helper Test Document
+
+[testmark]:# (config/database)
+` + "```json\n{\"host\": \"localhost\"}\n```" + `
+
+[testmark]:# (config/redis)
+` + "```yaml\nhost: redis-server\n```" + `
+
+[testmark]:# (test/unit)
+` + "```go\nfunc TestSomething(t *testing.T) {}\n```" + `
+
+[testmark]:# (test/integration)
+` + "```go\nfunc TestIntegration(t *testing.T) {}\n```" + `
+`
+
+func TestDocumentHelpers(t *testing.T) {
+	doc := testmark.MustParse(helpersTestDoc)
+
+	// Test AllHunks
+	allHunks := doc.AllHunks()
+	if len(allHunks) != 4 {
+		t.Errorf("expected 4 hunks, got %d", len(allHunks))
+	}
+
+	// Test HasHunk
+	if !doc.HasHunk("config/database") {
+		t.Error("should have config/database hunk")
+	}
+	if doc.HasHunk("nonexistent") {
+		t.Error("should not have nonexistent hunk")
+	}
+
+	// Test MustHunkByName
+	hunk := doc.MustHunkByName("config/database")
+	if hunk.Name() != "config/database" {
+		t.Error("MustHunkByName returned wrong hunk")
+	}
+
+	// Test HunkNames
+	names := doc.HunkNames()
+	expectedNames := []string{"config/database", "config/redis", "test/unit", "test/integration"}
+	if len(names) != len(expectedNames) {
+		t.Errorf("expected %d names, got %d", len(expectedNames), len(names))
+	}
+
+	// Test HunksByPrefix
+	configHunks := doc.HunksByPrefix("config/")
+	if len(configHunks) != 2 {
+		t.Errorf("expected 2 config hunks, got %d", len(configHunks))
+	}
+
+	// Test HunksBySuffix
+	testHunks := doc.HunksBySuffix("unit")
+	if len(testHunks) != 1 {
+		t.Errorf("expected 1 unit test hunk, got %d", len(testHunks))
+	}
+
+	// Test HunksByInfoString
+	goHunks := doc.HunksByInfoString("go")
+	if len(goHunks) != 2 {
+		t.Errorf("expected 2 go hunks, got %d", len(goHunks))
+	}
+
+	// Test ToMap
+	hunkMap := doc.ToMap()
+	if len(hunkMap) != 4 {
+		t.Errorf("expected 4 entries in map, got %d", len(hunkMap))
+	}
+	if !strings.Contains(hunkMap["config/database"], "localhost") {
+		t.Error("database config should contain localhost")
+	}
+}
+
+func TestDocumentUpdateHelpers(t *testing.T) {
+	doc := testmark.MustParse(helpersTestDoc)
+
+	// Test UpdateHunk
+	updated := doc.UpdateHunk("config/database", `{"host": "remote-server"}`)
+	updatedHunk := updated.HunkByName("config/database")
+	if !strings.Contains(updatedHunk.String(), "remote-server") {
+		t.Error("hunk should be updated with new content")
+	}
+
+	// Test PatchFromMap
+	updates := map[string]string{
+		"config/database": `{"host": "db.example.com"}`,
+		"config/redis":    "host: cache.example.com",
+	}
+	batchUpdated := doc.PatchFromMap(updates)
+	dbHunk := batchUpdated.HunkByName("config/database")
+	redisHunk := batchUpdated.HunkByName("config/redis")
+	if !strings.Contains(dbHunk.String(), "db.example.com") {
+		t.Error("database config should be updated")
+	}
+	if !strings.Contains(redisHunk.String(), "cache.example.com") {
+		t.Error("redis config should be updated")
+	}
+
+	// Test Clone
+	cloned := doc.Clone()
+	if cloned == doc {
+		t.Error("clone should not be the same instance")
+	}
+	if cloned.Count() != doc.Count() {
+		t.Error("clone should have same number of hunks")
+	}
+}
+
+func TestDirEntryHelpers(t *testing.T) {
+	doc := testmark.MustParse(helpersTestDoc)
+	root := doc.Root()
+
+	// Test AllChildren
+	allChildren := root.AllChildren()
+	if len(allChildren) != 2 { // config and test directories
+		t.Errorf("expected 2 children, got %d", len(allChildren))
+	}
+
+	// Test HasChild
+	if !root.HasChild("config") {
+		t.Error("root should have config child")
+	}
+	if root.HasChild("nonexistent") {
+		t.Error("root should not have nonexistent child")
+	}
+
+	// Test ChildNames
+	childNames := root.ChildNames()
+	if len(childNames) != 2 {
+		t.Errorf("expected 2 child names, got %d", len(childNames))
+	}
+
+	// Test FindByPath
+	dbEntry := root.FindByPath("config/database")
+	if dbEntry == nil {
+		t.Error("should find config/database entry")
+	}
+	if dbEntry != nil && dbEntry.Hunk() == nil {
+		t.Error("database entry should have a hunk")
+	}
+
+	nonexistent := root.FindByPath("config/nonexistent")
+	if nonexistent != nil {
+		t.Error("should not find nonexistent path")
+	}
+}
+
+func TestFSHelpers(t *testing.T) {
+	// Create a test filesystem
+	fsys := fstest.MapFS{
+		"test1.md": &fstest.MapFile{
+			Data: []byte(helpersTestDoc),
+		},
+		"test2.md": &fstest.MapFile{
+			Data: []byte("# No testmark content"),
+		},
+		"README.md": &fstest.MapFile{
+			Data: []byte("# README\n\n[testmark]:# (example)\n" + "```\nexample content\n```\n"),
+		},
+	}
+
+	// Test ParseFromFS
+	doc, err := testmark.ParseFromFS(fsys, "test1.md")
+	if err != nil {
+		t.Fatalf("ParseFromFS failed: %v", err)
+	}
+	if doc.Count() != 4 {
+		t.Errorf("expected 4 hunks, got %d", doc.Count())
+	}
+
+	// Test MustParseFromFS
+	doc2 := testmark.MustParseFromFS(fsys, "README.md")
+	if doc2.Count() != 1 {
+		t.Errorf("expected 1 hunk, got %d", doc2.Count())
+	}
+
+	// Test ParseAllFromFS
+	allDocs, err := testmark.ParseAllFromFS(fsys, "*.md")
+	if err != nil {
+		t.Fatalf("ParseAllFromFS failed: %v", err)
+	}
+	// Should find test1.md and README.md (test2.md has no testmark content)
+	if len(allDocs) != 2 {
+		t.Errorf("expected 2 documents, got %d", len(allDocs))
+	}
+}
+
+func TestPatternMatching(t *testing.T) {
+	doc := testmark.MustParse(helpersTestDoc)
+
+	// Test HunksByPattern
+	configHunks, err := doc.HunksByPattern("config/*")
+	if err != nil {
+		t.Fatalf("HunksByPattern failed: %v", err)
+	}
+	if len(configHunks) != 2 {
+		t.Errorf("expected 2 config hunks, got %d", len(configHunks))
+	}
+
+	testHunks, err := doc.HunksByPattern("test/*")
+	if err != nil {
+		t.Fatalf("HunksByPattern failed: %v", err)
+	}
+	if len(testHunks) != 2 {
+		t.Errorf("expected 2 test hunks, got %d", len(testHunks))
+	}
+}
diff --git a/v2/patch.go b/v2/patch.go
index 4874d44..78e7c85 100644
--- a/v2/patch.go
+++ b/v2/patch.go
@@ -139,6 +139,32 @@ func (d *Document) Patch(hunks ...*Hunk) *Document {
 	return newDoc
 }
 
+// UpdateHunk creates a new document with a single hunk updated.
+// This is a convenience method for the common case of updating one hunk.
+func (d *Document) UpdateHunk(name, content string) *Document {
+	return d.UpdateHunkWithInfo(name, "", content)
+}
+
+// UpdateHunkWithInfo creates a new document with a single hunk updated, including info string.
+func (d *Document) UpdateHunkWithInfo(name, infoString, content string) *Document {
+	hunk := NewHunk(name, infoString, content)
+	return d.Patch(hunk)
+}
+
+// PatchFromMap creates a new document by patching multiple hunks from a map.
+// The map keys are hunk names, values are the new content (as strings).
+func (d *Document) PatchFromMap(updates map[string]string) *Document {
+	if len(updates) == 0 {
+		return d
+	}
+
+	hunks := make([]*Hunk, 0, len(updates))
+	for name, content := range updates {
+		hunks = append(hunks, NewHunk(name, "", content))
+	}
+	return d.Patch(hunks...)
+}
+
 // appendHunkLines adds the testmark formatting around hunk content
 func appendHunkLines(lines [][]byte, hunkName string, infoString string, bodyLines [][]byte) [][]byte {
 	// Add comment line
diff --git a/v2/testmark.go b/v2/testmark.go
index c0a0151..7a3136b 100644
--- a/v2/testmark.go
+++ b/v2/testmark.go
@@ -3,6 +3,7 @@ package testmark
 
 import (
 	"flag"
+	"strings"
 )
 
 // bytesLike represents basic types that can be converted to []byte for content.
@@ -93,6 +94,40 @@ func (d *Document) Count() int {
 	return len(d.hunks)
 }
 
+// AllHunks returns all hunks as a slice in document order.
+// Convenient alternative to iterating with Hunks().
+func (d *Document) AllHunks() []*Hunk {
+	var result []*Hunk
+	for hunk := range d.Hunks() {
+		result = append(result, hunk)
+	}
+	return result
+}
+
+// HasHunk returns true if a hunk with the given name exists.
+func (d *Document) HasHunk(name string) bool {
+	return d.HunkByName(name) != nil
+}
+
+// MustHunkByName returns a hunk by name, panicking if not found.
+// Useful for tests where you know the hunk should exist.
+func (d *Document) MustHunkByName(name string) *Hunk {
+	hunk := d.HunkByName(name)
+	if hunk == nil {
+		panic("hunk not found: " + name)
+	}
+	return hunk
+}
+
+// HunkNames returns the names of all hunks in document order.
+func (d *Document) HunkNames() []string {
+	var names []string
+	for hunk := range d.Hunks() {
+		names = append(names, hunk.Name())
+	}
+	return names
+}
+
 // Root returns the root directory entry for hierarchical access.
 func (d *Document) Root() *DirEntry {
 	return d.dirIndex
@@ -129,3 +164,51 @@ func (e *DirEntry) IsDir() bool {
 func (e *DirEntry) IsFile() bool {
 	return e.hunk != nil
 }
+
+// AllChildren returns all child entries as a slice.
+// Convenient alternative to iterating with Children().
+func (e *DirEntry) AllChildren() []*DirEntry {
+	var result []*DirEntry
+	for child := range e.Children() {
+		result = append(result, child)
+	}
+	return result
+}
+
+// HasChild returns true if a child with the given name exists.
+func (e *DirEntry) HasChild(name string) bool {
+	return e.Child(name) != nil
+}
+
+// ChildNames returns the names of all child entries.
+func (e *DirEntry) ChildNames() []string {
+	var names []string
+	for child := range e.Children() {
+		names = append(names, child.Name())
+	}
+	return names
+}
+
+// FindByPath finds a descendant entry by path (e.g., "config/db").
+// Returns nil if not found.
+func (e *DirEntry) FindByPath(path string) *DirEntry {
+	if path == "" || path == "." {
+		return e
+	}
+
+	parts := strings.Split(path, "/")
+	current := e
+
+	for _, part := range parts {
+		if part == "" || part == "." {
+			continue
+		}
+		child := current.Child(part)
+		if child == nil {
+			return nil
+		}
+		current = child
+	}
+
+	return current
+}