From dc6a03c7142c3e075a16e0d15518d2c5bf81c267 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 21:26:52 +0200 Subject: [PATCH] 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 --- README.md | 3 + cmd/summer/docs.go | 26 ++ cmd/summer/main.go | 1 + cmd/summer/main_test.go | 3 +- docs/setup/installation.md | 24 ++ internal/docsite/docsite_test.go | 229 ++++++++++++ internal/docsite/load.go | 5 + internal/docsite/render.go | 83 ++++- internal/docsite/snippet.go | 588 +++++++++++++++++++++++++++++++ modules/bonfire/example_test.go | 27 ++ 10 files changed, 987 insertions(+), 2 deletions(-) create mode 100644 internal/docsite/snippet.go create mode 100644 modules/bonfire/example_test.go diff --git a/README.md b/README.md index a4c8e76..e397fdc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go index f6fa22d..a7e945e 100644 --- a/cmd/summer/docs.go +++ b/cmd/summer/docs.go @@ -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 /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") diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 84561e9..83accb0 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -47,6 +47,7 @@ func toolCommands() []bonfire.Command { delegateScheduleRunCommand(), delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"), docsBuildCommand(), + docsSyncCommand(), } } diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 11ea461..966b1ae 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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 diff --git a/docs/setup/installation.md b/docs/setup/installation.md index 498b567..f3ac58b 100644 --- a/docs/setup/installation.md +++ b/docs/setup/installation.md @@ -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. diff --git a/internal/docsite/docsite_test.go b/internal/docsite/docsite_test.go index 7aaeb9f..674843d 100644 --- a/internal/docsite/docsite_test.go +++ b/internal/docsite/docsite_test.go @@ -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) + } +} diff --git a/internal/docsite/load.go b/internal/docsite/load.go index 5d07b72..2ff9809 100644 --- a/internal/docsite/load.go +++ b/internal/docsite/load.go @@ -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 { diff --git a/internal/docsite/render.go b/internal/docsite/render.go index b9f5ac8..6d6effb 100644 --- a/internal/docsite/render.go +++ b/internal/docsite/render.go @@ -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
; +// a src= fence gets a
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(`
`) + if ref := attrString(n, "data-src"); ref != "" { + _, _ = w.WriteString("
") + if href := attrString(n, "data-href"); href != "" { + _, _ = w.WriteString(`` + html.EscapeString(ref) + "") + } else { + _, _ = w.WriteString(html.EscapeString(ref)) + } + _, _ = w.WriteString("
") + } + _, _ = w.WriteString("
')
+	for i := 0; i < n.Lines().Len(); i++ {
+		line := n.Lines().At(i)
+		_, _ = w.WriteString(html.EscapeString(string(line.Value(src))))
+	}
+	_, _ = w.WriteString("
\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 diff --git a/internal/docsite/snippet.go b/internal/docsite/snippet.go new file mode 100644 index 0000000..29a4460 --- /dev/null +++ b/internal/docsite/snippet.go @@ -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 +} diff --git a/modules/bonfire/example_test.go b/modules/bonfire/example_test.go new file mode 100644 index 0000000..ba72773 --- /dev/null +++ b/modules/bonfire/example_test.go @@ -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 +}