add mdtest command to generate and run tests from a markdown file

This commit is contained in:
Alex Flint 2022-10-07 14:14:01 -07:00
parent 47ff44303f
commit 2ffe24630b
3 changed files with 199 additions and 0 deletions

11
mdtest/example1.go.tpl Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"github.com/alexflint/go-arg/v2"
{{if contains .Code "fmt."}}"fmt"{{end}}
{{if contains .Code "strings."}}"strings"{{end}}
)
func main() {
{{.Code}}
}

9
mdtest/example2.go.tpl Normal file
View File

@ -0,0 +1,9 @@
package main
import (
"github.com/alexflint/go-arg/v2"
{{if contains .Code "fmt."}}"fmt"{{end}}
{{if contains .Code "strings."}}"strings"{{end}}
)
{{.Code}}

179
mdtest/mdtest.go Normal file
View File

@ -0,0 +1,179 @@
// mdtest executes code blocks in markdown and checks that they run as expected
package main
import (
"bytes"
"context"
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
"github.com/alexflint/go-arg/v2"
)
// var pattern = "```go(.*)```\\s*```\\s*\\$(.*)\\n(.*)```"
var pattern = "(?s)```go([^`]*?)```\\s*```([^`]*?)```" //go(.*)```\\s*```\\s*\\$(.*)\\n(.*)```"
var re = regexp.MustCompile(pattern)
var funcs = map[string]any{
"contains": strings.Contains,
}
//go:embed example1.go.tpl
var templateSource1 string
//go:embed example2.go.tpl
var templateSource2 string
var t1 = template.Must(template.New("example1.go").Funcs(funcs).Parse(templateSource1))
var t2 = template.Must(template.New("example2.go").Funcs(funcs).Parse(templateSource2))
type payload struct {
Code string
}
func runCode(ctx context.Context, code []byte, cmd string) ([]byte, error) {
dir, err := os.MkdirTemp("", "")
if err != nil {
return nil, fmt.Errorf("error creating temp dir to build and run code: %w", err)
}
fmt.Println(dir)
fmt.Println(strings.Repeat("-", 80))
srcpath := filepath.Join(dir, "src.go")
binpath := filepath.Join(dir, "example")
// If the code contains a main function then use t2, otherwise use t1
t := t1
if strings.Contains(string(code), "func main") {
t = t2
}
var b bytes.Buffer
err = t.Execute(&b, payload{Code: string(code)})
if err != nil {
return nil, fmt.Errorf("error executing template for source file: %w", err)
}
fmt.Println(b.String())
fmt.Println(strings.Repeat("-", 80))
err = os.WriteFile(srcpath, b.Bytes(), os.ModePerm)
if err != nil {
return nil, fmt.Errorf("error writing temporary source file: %w", err)
}
compiler, err := exec.LookPath("go")
if err != nil {
return nil, fmt.Errorf("could not find path to go compiler: %w", err)
}
buildCmd := exec.CommandContext(ctx, compiler, "build", "-o", binpath, srcpath)
out, err := buildCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("error building source: %w. Compiler said:\n%s", err, string(out))
}
// replace "./example" with full path to compiled program
var env, args []string
var found bool
for _, part := range strings.Split(cmd, " ") {
if found {
args = append(args, part)
} else if part == "./example" {
found = true
} else {
env = append(env, part)
}
}
runCmd := exec.CommandContext(ctx, binpath, args...)
runCmd.Env = env
output, err := runCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("error runing example: %w. Program said:\n%s", err, string(output))
}
// Clean up the temp dir
if err := os.RemoveAll(dir); err != nil {
return nil, fmt.Errorf("error deleting temp dir: %w", err)
}
return output, nil
}
func Main() error {
ctx := context.Background()
var args struct {
Input string `arg:"positional,required"`
}
arg.MustParse(&args)
buf, err := os.ReadFile(args.Input)
if err != nil {
return err
}
fmt.Println(strings.Repeat("=", 80))
matches := re.FindAllSubmatchIndex(buf, -1)
for k, match := range matches {
codebegin, codeend := match[2], match[3]
code := buf[codebegin:codeend]
shellbegin, shellend := match[4], match[5]
shell := buf[shellbegin:shellend]
lines := strings.Split(string(shell), "\n")
for i := 0; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "$") && strings.Contains(lines[i], "./example") {
cmd := strings.TrimSpace(strings.TrimPrefix(lines[i], "$"))
var output []string
i++
for i < len(lines) && !strings.HasPrefix(lines[i], "$") {
output = append(output, lines[i])
i++
}
expected := strings.TrimSpace(strings.Join(output, "\n"))
fmt.Println(string(code))
fmt.Println(strings.Repeat("-", 80))
fmt.Println(string(cmd))
fmt.Println(strings.Repeat("-", 80))
fmt.Println(string(expected))
fmt.Println(strings.Repeat("-", 80))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
actual, err := runCode(ctx, code, cmd)
if err != nil {
return fmt.Errorf("error running example %d: %w\nCode was:\n%s", k, err, string(code))
}
fmt.Println(string(actual))
fmt.Println(strings.Repeat("=", 80))
}
}
}
fmt.Printf("found %d matches\n", len(matches))
return nil
}
func main() {
if err := Main(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}