doctest.go
// Package doctest provides experimental support for testing Go code snippets extracted from documentation.
//
// This package is experimental and its API may change. It extracts Go code blocks from
// markdown files and tests them by compiling and running them.
//
// Basic usage:
//
// testdocs := doctest.New()
// testdocs.TestReadme(t, os.DirFS("."))
package doctest
import (
"fmt"
"go/parser"
"go/token"
"io/fs"
"regexp"
"strings"
"testing"
"github.com/warpfork/go-testmark/v2"
// TODO: re-enable when testexec integration is implemented
// "github.com/warpfork/go-testmark/v2/testexec"
)
// Tester extracts and tests Go code snippets from markdown documentation.
type Tester 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
}
// New returns a Tester with sensible defaults for testing Go snippets.
func New() *Tester {
return &Tester{
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 *Tester) 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 *Tester) 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
data, err := f.(fs.File).Stat()
if err != nil {
// If it's not a testmark file, try to parse it as regular markdown
dt.testRegularMarkdown(t, filesystem, filename)
return
}
_ = data // TODO: implement proper parsing
// TODO: implement testmark hunk testing
_ = filename // suppress unused warning
}
// testTestmarkHunks tests Go code in testmark hunks
// TODO: implement this properly with the new API
func (dt *Tester) testTestmarkHunks(t *testing.T, doc *testmark.Document, filename string) {
// Stub implementation
_ = doc
_ = filename
t.Skip("testTestmarkHunks not implemented yet")
}
// testRegularMarkdown tests Go code blocks in regular markdown
func (dt *Tester) 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 *Tester) 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 *Tester) 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 *Tester) 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 *Tester) testGoCode(t *testing.T, code, location string) {
// TODO: reimplement this with the new API
_ = code
_ = location
t.Skip("testGoCode not implemented yet - needs redesign")
}
// isValidGoSyntax checks if code is syntactically valid Go
func (dt *Tester) 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 *Tester) WrapCode(code string) string {
return fmt.Sprintf(dt.PackageTemplate, dt.ImportTemplate, dt.indentCode(code))
}
// indentCode indents code for wrapping in templates
func (dt *Tester) 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
// TestReadme tests Go code examples in README files.
func TestReadme(t *testing.T, filesystem fs.FS) {
dt := New()
dt.TestDocumentation(t, filesystem, "README.md", "**/README.md")
}
// TestAll tests Go code examples in all markdown files.
func TestAll(t *testing.T, filesystem fs.FS) {
dt := New()
dt.TestDocumentation(t, filesystem, "*.md", "**/*.md")
}
v2/doctest/doctest.go · 6.7 KiB · b9084569f7