feat(11.1-01): verify src= code blocks and add summer docs:sync

- src= fences name a file, a Go declaration or Example body, or a docs:start region
- confinement: relative clean paths inside the root, no dotfiles or .env,
  no nested go.mod modules, Examples need // Output:, test regions must run
- a drifted or missing snippet is a problem, so docs:build writes nothing
- docs:sync rewrites drifted fence bodies in place
- fences render in figure.code with a source caption; .md fences keep only the language
- bonfire ExampleCall is the first verified example, shown in setup/installation
This commit is contained in:
Jakub Zych
2026-09-30 21:26:52 +02:00
parent e433dcf0c9
commit dc6a03c714
10 changed files with 987 additions and 2 deletions

View File

@@ -73,6 +73,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|------|----------|
| `admin/` | The Vue 3 and TypeScript admin SPA; its build output is embedded by boardwalk. |
| `cmd/` | The `summer` CLI (`cmd/summer`). |
| `docs/` | Documentation source; `summer docs:build` renders it into a static site. |
| `examples/` | Example applications; `examples/hello` is the reference application and workspace. |
| `internal/` | CLI internals: the build and scaffolding generator, the watch loop and an OpenAPI conversion tool. |
| `modules/` | The framework modules, one Go package each, listed below. |
@@ -145,6 +146,8 @@ npm --prefix admin run gen:api # regenerate TypeScript types from admin/open
`summer dev` watches an application's sources and rebuilds its binary on change.
`summer docs:build` renders `docs/` and every module README into a static site under `site/` (use `--out` for another directory, `--check` to validate without writing). Code blocks with a `src=` reference are copies of real source; after changing that source, run `summer docs:sync` to refresh the copies.
## Design notes
Decisions and background live in [`.planning/notes/`](.planning/notes/), including:

View File

@@ -45,6 +45,32 @@ func docsBuildCommand() bonfire.Command {
}
}
func docsSyncCommand() bonfire.Command {
return bonfire.Command{
Name: "docs:sync",
Description: "Rewrite src= code blocks from their sources",
Flags: []bonfire.Flag{
{Name: "root", Description: "Repository root; src= paths resolve against it", Default: "."},
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
result, problems, err := docsite.Sync(docsOptions(in))
if err != nil {
return err
}
if len(problems) > 0 {
return reportDocsProblems(out, "docs:sync", problems)
}
if result.Snippets == 0 {
out.Printf("docs:sync: all snippets up to date\n")
return nil
}
out.Printf("docs:sync: updated %d snippets in %d files\n", result.Snippets, result.Files)
return nil
},
}
}
func docsOptions(in bonfire.Input) docsite.Options {
var opts docsite.Options
opts.Root, _ = in.Flag("root")

View File

@@ -47,6 +47,7 @@ func toolCommands() []bonfire.Command {
delegateScheduleRunCommand(),
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
docsBuildCommand(),
docsSyncCommand(),
}
}

View File

@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
for _, c := range toolCommands() {
names = append(names, c.Name)
}
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build"} {
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync"} {
if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names)
}
@@ -35,6 +35,7 @@ func TestToolCommandNames(t *testing.T) {
"schedule:run": {"--once"},
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
"docs:sync": {"--src", "--root"},
}
for cmd, wants := range helpWants {
var buf bytes.Buffer

View File

@@ -33,3 +33,27 @@ go test -short ./...
```
`go test -short` skips the tests that need Docker. Run `go test ./...` without `-short` when Docker is available.
## Check your install
Console commands in SummerCMS are plain `bonfire.Command` values: a name in `namespace:verb` form, its arguments and flags, and a run function that reads input and writes output. The `summer` tool and every application binary are built from such values, and `bonfire.Call` runs one in-process, which is how tests call commands. This example comes from the framework's own tests, so it compiles and runs whenever you run `go test ./...`:
```go src=modules/bonfire/example_test.go#ExampleCall
commands := []bonfire.Command{{
Name: "acme:greet",
Description: "Greet someone by name",
Args: []bonfire.Arg{{Name: "name", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name, _ := in.Argument("name")
out.Printf("Hello, %s\n", name)
return nil
},
}}
if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
fmt.Println(err)
}
// Output: Hello, blog
```
If the tests above pass, this example ran and printed `Hello, blog`. See the [bonfire](../../modules/bonfire/README.md) reference for flags, prompts and styled output.

View File

@@ -147,3 +147,232 @@ func TestReadmeIngestion(t *testing.T) {
t.Errorf("search index missing the alpha Usage entry: %s", search)
}
}
const snippetSite = `title: Acme
description: Acme docs.
sections:
- name: setup
title: Setup
`
const libGo = `package lib
// Greeting returns a greeting.
func Greeting(name string) string {
return "Hello, " + name
}
const (
// A is the first.
A = 1
B = 2
)
`
const libTestGo = `package lib
import (
"fmt"
"testing"
)
func TestGreeting(t *testing.T) {
// docs:start call
got := Greeting("x")
// docs:end call
if got == "" || check() == "" {
t.Fatal("empty")
}
}
func check() string {
// docs:start called
return Greeting("y")
// docs:end called
}
func helper() string {
// docs:start orphan
return "x"
// docs:end orphan
}
func ExampleGreeting() {
fmt.Println(Greeting("blog"))
// Output: Hello, blog
}
func ExampleB() {
_ = Greeting("x")
}
`
// snippetTree is a repository fixture with Go sources, tests and YAML that
// src= references can name.
func snippetTree(t *testing.T, extra map[string]string) string {
t.Helper()
files := map[string]string{
"go.mod": "module example.com/acme\n\ngo 1.27\n",
"docs/site.yaml": snippetSite,
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
"pkg/lib.go": libGo,
"pkg/lib_test.go": libTestGo,
"config/app.yaml": "app:\n # docs:start db\n db:\n host: localhost\n # docs:end db\n",
"nested/go.mod": "module example.com/nested\n",
"nested/x.go": "package nested\n\n// X is x.\nconst X = 1\n",
"nested/x_test.go": "package nested\n",
"notests/a.go": "package notests\n\n// A is a.\nconst A = 1\n",
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
}
for k, v := range extra {
files[k] = v
}
return writeTree(t, files)
}
func TestSnippetForms(t *testing.T) {
root := snippetTree(t, nil)
for _, tc := range []struct {
ref Ref
want string
}{
{Ref{Path: "pkg/lib.go"}, libGo},
{Ref{Path: "pkg/lib.go", Fragment: "Greeting"}, "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}"},
{Ref{Path: "pkg/lib.go", Fragment: "A"}, "// A is the first.\n\tA = 1"},
{Ref{Path: "pkg/lib.go", Fragment: "B"}, "B = 2"},
{Ref{Path: "pkg/lib_test.go", Fragment: "ExampleGreeting"}, "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog"},
{Ref{Path: "pkg/lib_test.go", Fragment: "call"}, "got := Greeting(\"x\")"},
{Ref{Path: "pkg/lib_test.go", Fragment: "called"}, "return Greeting(\"y\")"},
{Ref{Path: "pkg/lib_test.go", Fragment: "check"}, "func check() string {\n\t// docs:start called\n\treturn Greeting(\"y\")\n\t// docs:end called\n}"},
{Ref{Path: "config/app.yaml", Fragment: "db"}, "db:\n host: localhost"},
} {
got, err := Extract(root, tc.ref)
if err != nil {
t.Errorf("Extract(%s): %v", tc.ref, err)
continue
}
if got != tc.want {
t.Errorf("Extract(%s) = %q, want %q", tc.ref, got, tc.want)
}
}
for _, ref := range []Ref{{Path: "pkg/lib.go", Fragment: "Missing"}, {Path: "pkg/nope.go"}, {Path: "config/app.yaml", Fragment: "nope"}} {
if _, err := Extract(root, ref); err != errSnippetNotFound {
t.Errorf("Extract(%s) err = %v, want not found", ref, err)
}
}
if ref, ok := ParseSrc("go src=pkg/lib.go#Greeting title=x"); !ok || ref != (Ref{Path: "pkg/lib.go", Fragment: "Greeting"}) {
t.Errorf("ParseSrc = %+v %v", ref, ok)
}
if _, ok := ParseSrc("go"); ok {
t.Error("ParseSrc found src= in a bare info string")
}
}
func TestSnippetConfinement(t *testing.T) {
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
cases := []struct{ src, want string }{
{"/etc/passwd", "path must be relative"},
{"../x.go", "must not leave the repository root"},
{".env", "dotfile or .env file"},
{"config/prod.env", "dotfile or .env file"},
{"nested/x.go#X", "nested module nested/go.mod"},
{"escape.txt", "outside the repository root"},
{"notests/a.go#A", "no _test.go files"},
{"pkg/lib_test.go#ExampleB", "has no // Output: comment"},
{"pkg/lib_test.go#orphan", "not inside a Test or Example function"},
{"pkg/lib_test.go#helper", "not inside a Test or Example function"},
{"pkg/nope.go", "pkg/nope.go not found"},
}
var body strings.Builder
lines := map[string]int{}
line := 8 // frontmatter (6 lines), "# Snippets", blank
for _, tc := range cases {
body.WriteString("```go src=" + tc.src + "\n```\n\n")
line++
lines[tc.src] = line
line += 2
}
root := snippetTree(t, map[string]string{
"docs/setup/snippets.md": page("Snippets", "setup", 20, body.String()),
"config/prod.env": "SECRET=1\n",
})
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
t.Fatal(err)
}
problems, err := Check(Options{Root: root})
if err != nil {
t.Fatal(err)
}
got := problemLines(problems)
if len(got) != len(cases) {
t.Errorf("got %d problems, want %d:\n%s", len(got), len(cases), strings.Join(got, "\n"))
}
for _, tc := range cases {
prefix := "docs/setup/snippets.md:" + strconv.Itoa(lines[tc.src]) + ": snippet: "
if !slices.ContainsFunc(got, func(g string) bool { return strings.HasPrefix(g, prefix) && strings.Contains(g, tc.want) }) {
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
}
}
if _, _, err := Build(Options{Root: root, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
t.Fatal(err)
}
}
func TestSyncRewritesDrift(t *testing.T) {
doc := page("Start", "setup", 10, "Intro.\n\n- In a list:\n\n ```go src=pkg/lib.go#Greeting\n func stale() {}\n ```\n\n"+
"```yaml src=config/app.yaml#db\ndb:\n host: localhost\n```\n\nOutro.\n")
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
path := filepath.Join(root, "docs/setup/start.md")
problems, err := Check(Options{Root: root})
if err != nil {
t.Fatal(err)
}
want := "docs/setup/start.md:13: snippet: body differs from pkg/lib.go#Greeting (run: summer docs:sync)"
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
t.Fatalf("problems = %q, want [%q]", got, want)
}
out := filepath.Join(t.TempDir(), "site")
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) != 1 {
t.Fatalf("Build with drift: %v %v", err, problems)
}
if _, err := os.Stat(out); err == nil {
t.Fatal("Build wrote output despite a drifted snippet")
}
res, problems, err := Sync(Options{Root: root})
if err != nil || len(problems) > 0 {
t.Fatalf("Sync: %v %v", err, problems)
}
if res != (SyncResult{Snippets: 1, Files: 1}) {
t.Fatalf("Sync = %+v, want 1 snippet in 1 file", res)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
wantDoc := strings.Replace(doc, " func stale() {}\n",
" // Greeting returns a greeting.\n func Greeting(name string) string {\n \treturn \"Hello, \" + name\n }\n", 1)
if string(got) != wantDoc {
t.Fatalf("synced file =\n%s\nwant\n%s", got, wantDoc)
}
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
}
if problems, err := Check(Options{Root: root}); err != nil || len(problems) > 0 {
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
}
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) > 0 {
t.Fatalf("Build after Sync: %v %v", err, problems)
}
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(md), "src=") || !strings.Contains(string(md), " ```go\n") || !strings.Contains(string(md), "```yaml\n") {
t.Errorf("start.md fences not reduced to the language word:\n%s", md)
}
}

View File

@@ -179,6 +179,11 @@ func assemble(opts Options) (*site, []Problem, error) {
if err != nil || s == nil {
return nil, problems, err
}
sp, err := s.checkSnippets()
if err != nil {
return nil, nil, err
}
problems = append(problems, sp...)
if len(problems) == 0 {
rp, err := s.render()
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"cmp"
"fmt"
"html"
"path"
"slices"
"strings"
@@ -14,6 +15,7 @@ import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
@@ -30,11 +32,84 @@ func newMarkdown() goldmark.Markdown {
parser.WithASTTransformers(
util.Prioritized(h1Stripper{}, 100),
util.Prioritized(linkRewriter{}, 200),
util.Prioritized(fenceAnnotator{}, 300),
),
),
// goldmark registers lower priority values last, so 100 overrides the
// default html renderer (1000) for fenced code blocks.
goldmark.WithRendererOptions(renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100))),
)
}
// fenceAnnotator marks fences that carry src= with the reference and its
// source_url link, for codeRenderer's caption.
type fenceAnnotator struct{}
func (fenceAnnotator) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
pctx, _ := pc.Get(pageKey).(*pageContext)
src := reader.Source()
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
fc, ok := n.(*ast.FencedCodeBlock)
if !entering || !ok || fc.Info == nil {
return ast.WalkContinue, nil
}
ref, ok := ParseSrc(string(fc.Info.Segment.Value(src)))
if !ok {
return ast.WalkContinue, nil
}
fc.SetAttributeString("data-src", []byte(ref.String()))
if pctx != nil && pctx.site.cfg.SourceURL != "" {
fc.SetAttributeString("data-href", []byte(strings.ReplaceAll(pctx.site.cfg.SourceURL, "{path}", ref.Path)))
}
return ast.WalkSkipChildren, nil
})
}
// codeRenderer renders every fenced code block inside <figure class="code">;
// a src= fence gets a <figcaption> naming its source, linked to source_url.
type codeRenderer struct{}
func (codeRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
r.Register(ast.KindFencedCodeBlock, renderFence)
}
func attrString(n ast.Node, name string) string {
v, ok := n.AttributeString(name)
if !ok {
return ""
}
b, _ := v.([]byte)
return string(b)
}
func renderFence(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
_, _ = w.WriteString(`<figure class="code">`)
if ref := attrString(n, "data-src"); ref != "" {
_, _ = w.WriteString("<figcaption>")
if href := attrString(n, "data-href"); href != "" {
_, _ = w.WriteString(`<a href="` + html.EscapeString(href) + `">` + html.EscapeString(ref) + "</a>")
} else {
_, _ = w.WriteString(html.EscapeString(ref))
}
_, _ = w.WriteString("</figcaption>")
}
_, _ = w.WriteString("<pre><code")
if lang := n.Language(src); lang != nil {
_, _ = w.WriteString(` class="language-` + html.EscapeString(string(lang)) + `"`)
}
_ = w.WriteByte('>')
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
_, _ = w.WriteString(html.EscapeString(string(line.Value(src))))
}
_, _ = w.WriteString("</code></pre></figure>\n")
return ast.WalkSkipChildren, nil
}
// h1Stripper removes the page's leading "# Title" heading; the template
// renders the title once.
type h1Stripper struct{}
@@ -327,7 +402,8 @@ func closesFence(line string, f fence) bool {
}
// rewriteMarkdown applies the raw-output transforms to a Markdown body:
// link destinations outside code fences are replaced through links.
// fence info strings are reduced to their language word (dropping src=),
// and link destinations outside code fences are replaced through links.
func rewriteMarkdown(body string, links map[string]string) string {
lines := strings.Split(body, "\n")
inFence := make([]bool, len(lines))
@@ -345,6 +421,11 @@ func rewriteMarkdown(body string, links map[string]string) string {
origs = append(origs, o)
}
slices.SortFunc(origs, func(a, b string) int { return cmp.Or(cmp.Compare(len(b), len(a)), strings.Compare(a, b)) })
for _, f := range scanFences(lines) {
if lang, _, _ := strings.Cut(f.info, " "); lang != f.info {
lines[f.open] = strings.Repeat(" ", f.indent) + strings.Repeat(string(f.char), f.count) + lang
}
}
for i, line := range lines {
if inFence[i] || !strings.Contains(line, "](") {
continue

588
internal/docsite/snippet.go Normal file
View File

@@ -0,0 +1,588 @@
package docsite
import (
"errors"
"fmt"
"go/ast"
"go/doc"
"go/parser"
"go/token"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
)
// Ref is a src= reference in a code fence info string: a
// repository-relative path and an optional fragment (a top-level Go
// identifier or a docs:start/docs:end region name).
type Ref struct {
Path string
Fragment string
}
// String returns "path" or "path#fragment".
func (r Ref) String() string {
if r.Fragment == "" {
return r.Path
}
return r.Path + "#" + r.Fragment
}
// ParseSrc finds the src= field in a fence info string, such as
// "go src=modules/bonfire/example_test.go#ExampleCall".
func ParseSrc(info string) (Ref, bool) {
for _, f := range strings.Fields(info) {
if v, ok := strings.CutPrefix(f, "src="); ok {
p, frag, _ := strings.Cut(v, "#")
return Ref{Path: p, Fragment: frag}, true
}
}
return Ref{}, false
}
// errSnippetNotFound marks a missing file, identifier or region.
var errSnippetNotFound = errors.New("not found")
// snippetError is a src= reference that breaks a confinement or run rule.
type snippetError string
func (e snippetError) Error() string { return string(e) }
// Extract returns the text a src= reference names, after checking that
// the path is confined to the repository root and that Go sources are code
// go test compiles and runs:
//
// - no fragment: the whole file;
// - #region: the lines between "// docs:start region" and
// "// docs:end region" ("# docs:..." in YAML), dedented;
// - #Ident: a top-level Go declaration with its doc comment, verbatim;
// for an Example function, its body dedented with the // Output: line.
func Extract(root string, ref Ref) (string, error) {
if err := checkRefPath(ref.Path); err != nil {
return "", err
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("docsite: %w", err)
}
rootReal, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return "", fmt.Errorf("docsite: %w", err)
}
real, err := filepath.EvalSymlinks(filepath.Join(rootAbs, filepath.FromSlash(ref.Path)))
if errors.Is(err, fs.ErrNotExist) {
return "", errSnippetNotFound
}
if err != nil {
return "", fmt.Errorf("docsite: %w", err)
}
if !within(real, rootReal) {
return "", snippetError("path resolves outside the repository root")
}
if rel, err := filepath.Rel(rootReal, real); err == nil {
if err := checkRefPath(filepath.ToSlash(rel)); err != nil {
return "", snippetError("path resolves to a dotfile or .env file")
}
}
st, err := os.Stat(real)
if err != nil {
return "", fmt.Errorf("docsite: %w", err)
}
if st.IsDir() {
return "", snippetError("path is a directory")
}
raw, err := os.ReadFile(real)
if err != nil {
return "", fmt.Errorf("docsite: %w", err)
}
isGo := strings.HasSuffix(real, ".go")
isTest := strings.HasSuffix(real, "_test.go")
if isGo {
if err := checkRootModule(rootReal, filepath.Dir(real)); err != nil {
return "", err
}
if !isTest {
tests, _ := filepath.Glob(filepath.Join(filepath.Dir(real), "*_test.go"))
if len(tests) == 0 {
return "", snippetError("package has no _test.go files, so go test does not cover it")
}
}
}
if ref.Fragment == "" {
return string(raw), nil
}
lines := strings.Split(string(raw), "\n")
if text, start, end, found, err := extractRegion(lines, ref.Fragment); err != nil {
return "", err
} else if found {
if isTest {
if err := checkTestRegion(real, start, end); err != nil {
return "", err
}
}
return text, nil
}
if !isGo {
return "", errSnippetNotFound
}
return extractIdent(real, raw, ref.Fragment, isTest)
}
// checkRefPath enforces the form of a src= path: relative, clean, inside
// the root, and never a dotfile or .env file.
func checkRefPath(p string) error {
if p == "" {
return snippetError("src= has no path")
}
if filepath.IsAbs(p) || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) || filepath.VolumeName(p) != "" ||
(len(p) > 1 && p[1] == ':') {
return snippetError("path must be relative to the repository root")
}
if strings.Contains(p, `\`) {
return snippetError("path must use forward slashes")
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return snippetError("path must not leave the repository root")
}
if strings.HasPrefix(seg, ".") || strings.HasSuffix(seg, ".env") {
return snippetError("path names a dotfile or .env file")
}
}
if path.Clean(p) != p {
return snippetError("path must be clean (no ./, // or trailing /)")
}
return nil
}
// checkRootModule refuses a Go source that sits in a nested module (a
// directory between it and the root holds a go.mod): root go test does not
// run it.
func checkRootModule(rootReal, dir string) error {
for d := dir; d != rootReal && within(d, rootReal); d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
rel, _ := filepath.Rel(rootReal, d)
return snippetError(fmt.Sprintf("path is inside the nested module %s/go.mod, which root go test does not run", filepath.ToSlash(rel)))
}
}
return nil
}
// extractRegion returns the dedented lines between the docs:start and
// docs:end markers of a region, and the 1-based marker lines.
func extractRegion(lines []string, name string) (text string, start, end int, found bool, err error) {
isMarker := func(line, kind string) bool {
t := strings.TrimSpace(line)
return t == "// docs:"+kind+" "+name || t == "# docs:"+kind+" "+name
}
start = -1
for i, line := range lines {
if start < 0 && isMarker(line, "start") {
start = i
continue
}
if start >= 0 && isMarker(line, "end") {
return strings.Join(dedent(lines[start+1:i]), "\n"), start + 1, i + 1, true, nil
}
}
if start >= 0 {
return "", 0, 0, false, snippetError(fmt.Sprintf("region %q has no docs:end marker", name))
}
return "", 0, 0, false, nil
}
// dedent removes the longest common leading whitespace of the non-blank
// lines and trims leading and trailing blank lines.
func dedent(lines []string) []string {
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
prefix, set := "", false
for _, l := range lines {
if strings.TrimSpace(l) == "" {
continue
}
lead := l[:len(l)-len(strings.TrimLeft(l, " \t"))]
if !set {
prefix, set = lead, true
continue
}
for !strings.HasPrefix(lead, prefix) {
prefix = prefix[:len(prefix)-1]
}
}
out := make([]string, len(lines))
for i, l := range lines {
if strings.TrimSpace(l) == "" {
out[i] = ""
continue
}
out[i] = strings.TrimPrefix(l, prefix)
}
return out
}
// extractIdent returns a top-level declaration by name, or an Example
// function's body.
func extractIdent(file string, raw []byte, name string, isTest bool) (string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, raw, parser.ParseComments)
if err != nil {
return "", snippetError("cannot parse Go source: " + firstLine(err.Error()))
}
offset := func(p token.Pos) int { return fset.Position(p).Offset }
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if funcKey(d) != name {
continue
}
if isTest {
if err := checkTestIdent(file, name, true); err != nil {
return "", err
}
}
if d.Recv == nil && strings.HasPrefix(name, "Example") && isTest {
if !exampleHasOutput(f, name) {
return "", snippetError(name + " has no // Output: comment, so go test compiles it but never runs it")
}
body := string(raw[offset(d.Body.Lbrace)+1 : offset(d.Body.Rbrace)])
return strings.Join(dedent(strings.Split(body, "\n")), "\n"), nil
}
start := d.Pos()
if d.Doc != nil {
start = d.Doc.Pos()
}
return string(raw[offset(start):offset(d.End())]), nil
case *ast.GenDecl:
for _, spec := range d.Specs {
var names []*ast.Ident
var specDoc *ast.CommentGroup
switch sp := spec.(type) {
case *ast.TypeSpec:
names, specDoc = []*ast.Ident{sp.Name}, sp.Doc
case *ast.ValueSpec:
names, specDoc = sp.Names, sp.Doc
}
if !slices.ContainsFunc(names, func(id *ast.Ident) bool { return id.Name == name }) {
continue
}
if isTest {
if err := checkTestIdent(file, name, false); err != nil {
return "", err
}
}
start, end := d.Pos(), d.End()
doc := d.Doc
if d.Lparen.IsValid() {
start, end, doc = spec.Pos(), spec.End(), specDoc
}
if doc != nil {
start = doc.Pos()
}
return string(raw[offset(start):offset(end)]), nil
}
}
}
return "", errSnippetNotFound
}
// funcKey is "Name" for a function and "Type.Name" for a method.
func funcKey(d *ast.FuncDecl) string {
if d.Recv == nil || len(d.Recv.List) == 0 {
return d.Name.Name
}
t := d.Recv.List[0].Type
for {
switch x := t.(type) {
case *ast.StarExpr:
t = x.X
continue
case *ast.IndexExpr:
t = x.X
continue
case *ast.IndexListExpr:
t = x.X
continue
case *ast.Ident:
return x.Name + "." + d.Name.Name
}
return d.Name.Name
}
}
func exampleHasOutput(f *ast.File, name string) bool {
for _, ex := range doc.Examples(f) {
if "Example"+ex.Name == name {
return ex.Output != "" || ex.EmptyOutput
}
}
return false
}
// testGraph is the reachability of the functions in one directory's test
// files from its Test and Example functions.
type testGraph struct {
fset *token.FileSet
funcs map[string][]*ast.FuncDecl // by key, per file order
reachable map[string]bool // func keys
referenced map[string]bool // identifier names used by reachable funcs
files map[string]*ast.File // by path
}
func isRoot(d *ast.FuncDecl) bool {
return d.Recv == nil && (strings.HasPrefix(d.Name.Name, "Test") || strings.HasPrefix(d.Name.Name, "Example"))
}
func loadTestGraph(dir string) (*testGraph, error) {
paths, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
if err != nil {
return nil, fmt.Errorf("docsite: %w", err)
}
g := &testGraph{fset: token.NewFileSet(), funcs: map[string][]*ast.FuncDecl{},
reachable: map[string]bool{}, referenced: map[string]bool{}, files: map[string]*ast.File{}}
byName := map[string][]string{} // plain or method name -> keys
var queue []string
for _, p := range paths {
f, err := parser.ParseFile(g.fset, p, nil, 0)
if err != nil {
return nil, snippetError("cannot parse " + filepath.Base(p) + ": " + firstLine(err.Error()))
}
g.files[p] = f
for _, decl := range f.Decls {
d, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(d)
g.funcs[key] = append(g.funcs[key], d)
byName[d.Name.Name] = append(byName[d.Name.Name], key)
if isRoot(d) && !g.reachable[key] {
g.reachable[key] = true
queue = append(queue, key)
}
}
}
for len(queue) > 0 {
key := queue[0]
queue = queue[1:]
for _, d := range g.funcs[key] {
if d.Body == nil {
continue
}
ast.Inspect(d.Body, func(n ast.Node) bool {
id, ok := n.(*ast.Ident)
if !ok {
return true
}
g.referenced[id.Name] = true
for _, k := range byName[id.Name] {
if !g.reachable[k] {
g.reachable[k] = true
queue = append(queue, k)
}
}
return true
})
}
}
return g, nil
}
const notRunMessage = "fragment is not inside a Test or Example function, or a function one of them calls, so go test does not run it"
// checkTestIdent requires a _test.go declaration to be run by go test: a
// function reachable from a Test or Example, or a type, var or const that
// such a function uses.
func checkTestIdent(file, name string, isFunc bool) error {
g, err := loadTestGraph(filepath.Dir(file))
if err != nil {
return err
}
if (isFunc && g.reachable[name]) || (!isFunc && g.referenced[name]) {
return nil
}
return snippetError(notRunMessage)
}
// checkTestRegion requires the region's marker lines to sit inside one
// function of the file that a Test or Example function reaches.
func checkTestRegion(file string, start, end int) error {
g, err := loadTestGraph(filepath.Dir(file))
if err != nil {
return err
}
f := g.files[file]
if f == nil {
return snippetError(notRunMessage)
}
for _, decl := range f.Decls {
d, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
from, to := g.fset.Position(d.Pos()).Line, g.fset.Position(d.End()).Line
if from < start && end < to && g.reachable[funcKey(d)] {
return nil
}
}
return snippetError(notRunMessage)
}
// drift is a src= fence whose body differs from its source.
type drift struct {
f fence
want string
}
// checkFences verifies every src= fence in lines. lineBase is the 1-based
// file line of lines[0]; file is the display path for problems.
func checkFences(root, file string, lines []string, lineBase int) ([]drift, []Problem, error) {
var drifts []drift
var problems []Problem
for _, f := range scanFences(lines) {
ref, ok := ParseSrc(f.info)
if !ok {
continue
}
line := lineBase + f.open
fail := func(msg string) {
problems = append(problems, Problem{File: file, Line: line, Rule: "snippet", Message: msg})
}
if f.close < 0 {
fail("code block has no closing fence")
continue
}
want, err := Extract(root, ref)
var se snippetError
switch {
case errors.Is(err, errSnippetNotFound):
fail(ref.String() + " not found")
continue
case errors.As(err, &se):
fail(ref.String() + ": " + se.Error())
continue
case err != nil:
return nil, nil, err
}
if fenceBody(lines, f) != strings.TrimRight(want, "\n") {
fail(fmt.Sprintf("body differs from %s (run: summer docs:sync)", ref))
drifts = append(drifts, drift{f: f, want: want})
}
}
return drifts, problems, nil
}
// fenceBody returns a fence's body with the fence indentation removed and
// trailing newlines trimmed.
func fenceBody(lines []string, f fence) string {
body := make([]string, 0, f.close-f.open)
for _, l := range lines[f.open+1 : f.close] {
body = append(body, unindent(l, f.indent))
}
return strings.TrimRight(strings.Join(body, "\n"), "\n")
}
func unindent(l string, n int) string {
i := 0
for i < n && i < len(l) && l[i] == ' ' {
i++
}
return l[i:]
}
// checkSnippets verifies the src= fences of every docs page.
func (s *site) checkSnippets() ([]Problem, error) {
var problems []Problem
for _, p := range s.pages {
if p.Module != "" {
continue
}
_, ps, err := checkFences(s.opts.Root, p.Source, strings.Split(string(p.Body), "\n"), p.BodyLine)
if err != nil {
return nil, err
}
problems = append(problems, ps...)
}
return problems, nil
}
// Sync rewrites every drifted src= fence body under Src from its source,
// preserving everything else byte for byte. When a reference is missing or
// breaks a rule, it returns the problems and writes nothing.
func Sync(opts Options) (SyncResult, []Problem, error) {
opts, err := opts.normalize()
if err != nil {
return SyncResult{}, nil, err
}
s := &site{opts: opts}
files, err := walkPages(opts.Src)
if err != nil {
return SyncResult{}, nil, err
}
type rewrite struct {
path string
mode fs.FileMode
data []byte
}
var rewrites []rewrite
var problems []Problem
var result SyncResult
for _, abs := range files {
raw, err := os.ReadFile(abs)
if err != nil {
return SyncResult{}, nil, fmt.Errorf("docs:sync: read %s: %w", abs, err)
}
lines := strings.Split(string(raw), "\n")
drifts, ps, err := checkFences(opts.Root, s.rel(abs), lines, 1)
if err != nil {
return SyncResult{}, nil, err
}
for _, p := range ps {
if !strings.HasPrefix(p.Message, "body differs from ") {
problems = append(problems, p)
}
}
if len(drifts) == 0 {
continue
}
for i := len(drifts) - 1; i >= 0; i-- {
d := drifts[i]
pad := strings.Repeat(" ", d.f.indent)
var body []string
for _, l := range strings.Split(strings.TrimRight(d.want, "\n"), "\n") {
if l == "" {
body = append(body, "")
} else {
body = append(body, pad+l)
}
}
lines = slices.Concat(lines[:d.f.open+1], body, lines[d.f.close:])
}
st, err := os.Stat(abs)
if err != nil {
return SyncResult{}, nil, fmt.Errorf("docs:sync: %w", err)
}
rewrites = append(rewrites, rewrite{path: abs, mode: st.Mode().Perm(), data: []byte(strings.Join(lines, "\n"))})
result.Snippets += len(drifts)
result.Files++
}
if len(problems) > 0 {
sortProblems(problems)
return SyncResult{}, problems, nil
}
for _, r := range rewrites {
if err := os.WriteFile(r.path, r.data, r.mode); err != nil {
return SyncResult{}, nil, fmt.Errorf("docs:sync: write %s: %w", r.path, err)
}
}
return result, nil, nil
}

View File

@@ -0,0 +1,27 @@
package bonfire_test
import (
"context"
"fmt"
"os"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
func ExampleCall() {
commands := []bonfire.Command{{
Name: "acme:greet",
Description: "Greet someone by name",
Args: []bonfire.Arg{{Name: "name", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
name, _ := in.Argument("name")
out.Printf("Hello, %s\n", name)
return nil
},
}}
if err := bonfire.Call(context.Background(), commands, "acme:greet", []string{"blog"}, os.Stdout); err != nil {
fmt.Println(err)
}
// Output: Hello, blog
}