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 +}