doctest.go
package testmark
import (
"fmt"
"go/parser"
"go/token"
"io/fs"
"regexp"
"strings"
"testing"
)
// DocTest extracts and tests Go code snippets from markdown documentation.
type DocTest struct {
// PackageTemplate is the Go package template for wrapping code snippets
PackageTemplate string
// ImportTemplate is the import template for code snippets
ImportTemplate string
// TestTimeout configures how long to wait for code execution
TestTimeout string
// SkipPatterns are regex patterns for code blocks to skip
SkipPatterns []*regexp.Regexp
}
// DefaultDocTest returns a DocTest with sensible defaults for testing Go snippets.
func DefaultDocTest() *DocTest {
return &DocTest{
PackageTemplate: `package main
import (
"fmt"
"strings"
testmark "github.com/warpfork/go-testmark/v2"
%s
)
func main() {
%s
}`,
ImportTemplate: "",
TestTimeout: "30s",
SkipPatterns: []*regexp.Regexp{
regexp.MustCompile(`(?i)// skip|// todo|// fixme`),
regexp.MustCompile(`(?i)func Test|func Example`), // Don't run test functions as main
},
}
}
// TestDocumentation scans documentation files for Go code blocks and tests them.
func (dt *DocTest) TestDocumentation(t *testing.T, filesystem fs.FS, patterns ...string) {
if len(patterns) == 0 {
patterns = []string{"*.md", "**/README.md"}
}
for _, pattern := range patterns {
matches, err := fs.Glob(filesystem, pattern)
if err != nil {
t.Errorf("failed to glob pattern %s: %v", pattern, err)
continue
}
for _, match := range matches {
t.Run(match, func(t *testing.T) {
dt.testDocumentationFile(t, filesystem, match)
})
}
}
}
// testDocumentationFile tests a single documentation file
func (dt *DocTest) testDocumentationFile(t *testing.T, filesystem fs.FS, filename string) {
f, err := filesystem.Open(filename)
if err != nil {
t.Fatalf("failed to open %s: %v", filename, err)
}
defer f.Close()
// Parse the markdown file
doc, err := Read(f)
if err != nil {
// If it's not a testmark file, try to parse it as regular markdown
dt.testRegularMarkdown(t, filesystem, filename)
return
}
// Test Go code blocks in testmark hunks
dt.testTestmarkHunks(t, doc, filename)
}
// testTestmarkHunks tests Go code in testmark hunks
func (dt *DocTest) testTestmarkHunks(t *testing.T, doc *Document, filename string) {
for iter := doc.Hunks(); iter.Next(); {
hunk := iter.Hunk()
// Only test Go code blocks
if !dt.IsGoCode(hunk.InfoString(), hunk.String()) {
continue
}
// Skip if matches skip patterns
if dt.ShouldSkip(hunk.String()) {
continue
}
t.Run(hunk.Name(), func(t *testing.T) {
dt.testGoCode(t, hunk.String(), fmt.Sprintf("%s:%s", filename, hunk.Name()))
})
}
}
// testRegularMarkdown tests Go code blocks in regular markdown
func (dt *DocTest) testRegularMarkdown(t *testing.T, filesystem fs.FS, filename string) {
// For regular markdown, we'd need a markdown parser
// For now, we'll implement a simple code block extractor
f, err := filesystem.Open(filename)
if err != nil {
t.Fatalf("failed to open %s: %v", filename, err)
}
defer f.Close()
content, err := fs.ReadFile(filesystem, filename)
if err != nil {
t.Fatalf("failed to read %s: %v", filename, err)
}
codeBlocks := dt.ExtractGoCodeBlocks(string(content))
for i, code := range codeBlocks {
if dt.ShouldSkip(code) {
continue
}
t.Run(fmt.Sprintf("code-block-%d", i+1), func(t *testing.T) {
dt.testGoCode(t, code, fmt.Sprintf("%s:block-%d", filename, i+1))
})
}
}
// extractGoCodeBlocks extracts Go code blocks from markdown content
func (dt *DocTest) ExtractGoCodeBlocks(content string) []string {
var blocks []string
lines := strings.Split(content, "\n")
var inCodeBlock bool
var currentBlock strings.Builder
var blockLang string
for _, line := range lines {
if strings.HasPrefix(line, "```") {
if !inCodeBlock {
// Starting a code block
blockLang = strings.TrimSpace(line[3:])
inCodeBlock = true
currentBlock.Reset()
} else {
// Ending a code block
inCodeBlock = false
if dt.IsGoCode(blockLang, currentBlock.String()) {
blocks = append(blocks, strings.TrimSpace(currentBlock.String()))
}
}
} else if inCodeBlock {
currentBlock.WriteString(line + "\n")
}
}
return blocks
}
// IsGoCode determines if a code block contains Go code
func (dt *DocTest) IsGoCode(lang, code string) bool {
// Check language hint
if lang == "go" || lang == "golang" {
return true
}
// Heuristic: look for Go-like syntax
if strings.Contains(code, "package ") ||
strings.Contains(code, "import ") ||
strings.Contains(code, "func ") ||
strings.Contains(code, ":=") ||
strings.Contains(code, "testmark.") ||
strings.Contains(code, "fmt.") ||
strings.Contains(code, "log.") ||
strings.Contains(code, "errors.") ||
strings.Contains(code, "context.") ||
strings.Contains(code, "time.") {
return true
}
return false
}
// shouldSkip checks if code should be skipped based on skip patterns
func (dt *DocTest) ShouldSkip(code string) bool {
for _, pattern := range dt.SkipPatterns {
if pattern.MatchString(code) {
return true
}
}
return false
}
// testGoCode tests a single Go code snippet
func (dt *DocTest) testGoCode(t *testing.T, code, location string) {
// First, check if it's valid Go syntax
if !dt.IsValidGoSyntax(code) {
// Try wrapping it in a main function
wrapped := dt.WrapCode(code)
if !dt.IsValidGoSyntax(wrapped) {
t.Errorf("invalid Go syntax in %s", location)
return
}
code = wrapped
}
// Create a test entry and run it
entry := &DirEntry{
name: "doctest",
path: "doctest",
children: make(map[string]*DirEntry),
childrenOrdered: []*DirEntry{
{
name: "script",
path: "doctest/script",
hunk: &Hunk{
name: "doctest/script",
body: []byte(code),
},
},
},
}
entry.children["script"] = entry.childrenOrdered[0]
// Run with Go-aware execution
config := DefaultExecConfig()
config.ExecFn = SmartGoExecFunc()
// Capture any panic and convert to test failure
defer func() {
if r := recover(); r != nil {
t.Errorf("code execution failed in %s: %v", location, r)
}
}()
TestExec(t, entry, config)
}
// isValidGoSyntax checks if code is syntactically valid Go
func (dt *DocTest) IsValidGoSyntax(code string) bool {
fset := token.NewFileSet()
_, err := parser.ParseFile(fset, "test.go", code, parser.ParseComments)
return err == nil
}
// wrapCode wraps code snippets that aren't complete programs
func (dt *DocTest) WrapCode(code string) string {
return fmt.Sprintf(dt.PackageTemplate, dt.ImportTemplate, dt.indentCode(code))
}
// indentCode indents code for wrapping in templates
func (dt *DocTest) indentCode(code string) string {
lines := strings.Split(strings.TrimSpace(code), "\n")
for i, line := range lines {
if strings.TrimSpace(line) != "" {
lines[i] = "\t" + line
}
}
return strings.Join(lines, "\n")
}
// Convenience functions
// TestReadmeExamples tests Go code examples in README files.
func TestReadmeExamples(t *testing.T, filesystem fs.FS) {
dt := DefaultDocTest()
dt.TestDocumentation(t, filesystem, "README.md", "**/README.md")
}
// TestAllDocExamples tests Go code examples in all markdown files.
func TestAllDocExamples(t *testing.T, filesystem fs.FS) {
dt := DefaultDocTest()
dt.TestDocumentation(t, filesystem, "*.md", "**/*.md")
}
v2/doctest.go · 7.2 KiB · 4d0e1b6afd