- eighteen violation plants pin nested src=, Go aliases, unrun examples, build membership and command forms - unit tests cover fence collection, captions, goLang, go doc -c, commandWord and Sync
481 lines
16 KiB
Go
481 lines
16 KiB
Go
package docsite
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const genericGo = `package gen
|
|
|
|
// Map applies f to every element.
|
|
func Map[T, U any](in []T, f func(T) U) []U {
|
|
out := make([]U, 0, len(in))
|
|
for _, v := range in {
|
|
out = append(out, f(v))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Box holds one value.
|
|
type Box[T any] struct{ v T }
|
|
|
|
// Get returns the value.
|
|
func (b *Box[T]) Get() T { return b.v }
|
|
|
|
// Pair holds a key and a value.
|
|
type Pair[K comparable, V any] struct {
|
|
K K
|
|
V V
|
|
}
|
|
|
|
// Key returns the key.
|
|
func (p Pair[K, V]) Key() K { return p.K }
|
|
|
|
// Paren has a parenthesised receiver.
|
|
func (b (*Box[T])) Paren() {}
|
|
|
|
var (
|
|
// Limit is documented in the group.
|
|
Limit = 10
|
|
Other = 2
|
|
)
|
|
|
|
// Single is an ungrouped var.
|
|
var Single = 1
|
|
|
|
type (
|
|
// ID is a grouped type.
|
|
ID int
|
|
)
|
|
`
|
|
|
|
const genericTestGo = `package gen
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestMap(t *testing.T) {
|
|
// docs:start map
|
|
got := Map([]int{1, 2}, func(v int) string {
|
|
return fmt.Sprint(v)
|
|
})
|
|
// docs:end map
|
|
_ = viaVar
|
|
if len(got) != 2 || (fixture{name: "x"}).name == "" {
|
|
t.Fatal(got)
|
|
}
|
|
// docs:start map
|
|
duplicate := 1
|
|
// docs:end map
|
|
_ = duplicate
|
|
}
|
|
|
|
// fixture is referenced by a test, so it counts as run.
|
|
type fixture struct{ name string }
|
|
|
|
// indirect is only reached through a package-level var: the run check is
|
|
// conservative and does not follow var initialisers.
|
|
type indirect struct{}
|
|
|
|
var viaVar = indirect{}
|
|
|
|
func ExampleBox_Get() {
|
|
b := &Box[string]{v: "boxed"}
|
|
if b != nil {
|
|
fmt.Println(b.Get())
|
|
}
|
|
// Output: boxed
|
|
}
|
|
|
|
func ExamplePair_Key() {
|
|
fmt.Println(Pair[string, int]{K: "k"}.Key())
|
|
// Output:
|
|
// k
|
|
}
|
|
|
|
func ExampleSingle() {
|
|
_ = Single
|
|
// Output:
|
|
}
|
|
`
|
|
|
|
func genericTree(t *testing.T) string {
|
|
t.Helper()
|
|
return writeTree(t, map[string]string{
|
|
"go.mod": "module example.com/gen\n\ngo 1.27\n",
|
|
"gen/gen.go": genericGo,
|
|
"gen/gen_test.go": genericTestGo,
|
|
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
|
"config/tabs.go.txt": "x",
|
|
"docs/site.yaml": snippetSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
|
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
|
})
|
|
}
|
|
|
|
func TestSnippetGenericsAndGroups(t *testing.T) {
|
|
root := genericTree(t)
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{"gen/gen.go", "Map"}, "// Map applies f to every element.\nfunc Map[T, U any](in []T, f func(T) U) []U {\n\tout := make([]U, 0, len(in))\n\tfor _, v := range in {\n\t\tout = append(out, f(v))\n\t}\n\treturn out\n}"},
|
|
{Ref{"gen/gen.go", "Box"}, "// Box holds one value.\ntype Box[T any] struct{ v T }"},
|
|
{Ref{"gen/gen.go", "Box.Get"}, "// Get returns the value.\nfunc (b *Box[T]) Get() T { return b.v }"},
|
|
{Ref{"gen/gen.go", "Pair.Key"}, "// Key returns the key.\nfunc (p Pair[K, V]) Key() K { return p.K }"},
|
|
{Ref{"gen/gen.go", "Box.Paren"}, "// Paren has a parenthesised receiver.\nfunc (b (*Box[T])) Paren() {}"},
|
|
{Ref{"gen/gen.go", "Limit"}, "// Limit is documented in the group.\n\tLimit = 10"},
|
|
{Ref{"gen/gen.go", "Other"}, "Other = 2"},
|
|
{Ref{"gen/gen.go", "Single"}, "// Single is an ungrouped var.\nvar Single = 1"},
|
|
{Ref{"gen/gen.go", "ID"}, "// ID is a grouped type.\n\tID int"},
|
|
{Ref{"gen/gen_test.go", "ExampleBox_Get"}, "b := &Box[string]{v: \"boxed\"}\nif b != nil {\n\tfmt.Println(b.Get())\n}\n// Output: boxed"},
|
|
{Ref{"gen/gen_test.go", "ExamplePair_Key"}, "fmt.Println(Pair[string, int]{K: \"k\"}.Key())\n// Output:\n// k"},
|
|
{Ref{"gen/gen_test.go", "ExampleSingle"}, "_ = Single\n// Output:"},
|
|
{Ref{"gen/gen_test.go", "fixture"}, "// fixture is referenced by a test, so it counts as run.\ntype fixture struct{ name string }"},
|
|
{Ref{"gen/gen_test.go", "viaVar"}, "var viaVar = indirect{}"},
|
|
// The first of two same-named regions wins.
|
|
{Ref{"gen/gen_test.go", "map"}, "got := Map([]int{1, 2}, func(v int) string {\n\treturn fmt.Sprint(v)\n})"},
|
|
// A YAML region keeps its inner blank line and is dedented.
|
|
{Ref{"config/app.yaml", "keys"}, "b: 1\n\nc: 2"},
|
|
} {
|
|
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) =\n%q\nwant\n%q", tc.ref, got, tc.want)
|
|
}
|
|
}
|
|
if _, err := Extract(root, Ref{"gen/gen_test.go", "indirect"}); err == nil || err.Error() != notRunMessage {
|
|
t.Errorf("Extract(indirect) = %v, want the not-run refusal", err)
|
|
}
|
|
// A fragment on a non-Go file that is not a region is not found.
|
|
if _, err := Extract(root, Ref{"config/app.yaml", "Map"}); err != errSnippetNotFound {
|
|
t.Errorf("YAML ident err = %v", err)
|
|
}
|
|
// A missing root is an error, not a problem.
|
|
if _, err := Extract(filepath.Join(root, "missing"), Ref{Path: "gen/gen.go"}); err == nil || err == errSnippetNotFound {
|
|
t.Errorf("Extract with a missing root = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDedent(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in, want []string
|
|
}{
|
|
{[]string{"", "\t\ta", "\t\t\tb", "", "\t\tc", " "}, []string{"a", "\tb", "", "c"}},
|
|
{[]string{" x", " y"}, []string{" x", "y"}},
|
|
{[]string{"\t x", "\t\ty"}, []string{" x", "\ty"}},
|
|
{[]string{" ", ""}, []string{}},
|
|
{nil, []string{}},
|
|
} {
|
|
if got := dedent(tc.in); !slices.Equal(got, tc.want) {
|
|
t.Errorf("dedent(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSnippetRefPathRules(t *testing.T) {
|
|
for p, want := range map[string]string{
|
|
"": "src= has no path",
|
|
"/abs.go": "must be relative",
|
|
`\abs.go`: "must be relative",
|
|
"C:/x.go": "must be relative",
|
|
"c:x.go": "must be relative",
|
|
`a\b.go`: "must use forward slashes",
|
|
"a/../b.go": "must not leave the repository root",
|
|
"a/.git/x": "dotfile or .env file",
|
|
"a/prod.env": "dotfile or .env file",
|
|
"./a.go": "dotfile or .env file",
|
|
"a//b.go": "must be clean",
|
|
"a/b/": "must be clean",
|
|
"a/b.go": "",
|
|
"docs/x.md": "",
|
|
"environment": "",
|
|
"a.environ.go": "",
|
|
} {
|
|
err := checkRefPath(p)
|
|
if (want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), want)) {
|
|
t.Errorf("checkRefPath(%q) = %v, want %q", p, err, want)
|
|
}
|
|
}
|
|
for info, want := range map[string]Ref{
|
|
"go src=a.go#X": {"a.go", "X"},
|
|
"yaml src=b.yaml": {"b.yaml", ""},
|
|
"go title=x src=c.go": {"c.go", ""},
|
|
"go src=d.go#A#B": {"d.go", "A#B"},
|
|
} {
|
|
if got, ok := ParseSrc(info); !ok || got != want {
|
|
t.Errorf("ParseSrc(%q) = %+v, %v", info, got, ok)
|
|
}
|
|
}
|
|
if (Ref{Path: "a.go"}).String() != "a.go" || (Ref{"a.go", "X"}).String() != "a.go#X" {
|
|
t.Error("Ref.String")
|
|
}
|
|
}
|
|
|
|
func TestSnippetSymlinkInsideRoot(t *testing.T) {
|
|
root := genericTree(t)
|
|
if err := os.Symlink(filepath.Join(root, "config", "app.yaml"), filepath.Join(root, "config", "alias.yaml")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := Extract(root, Ref{"config/alias.yaml", "keys"})
|
|
if err != nil || got != "b: 1\n\nc: 2" {
|
|
t.Fatalf("Extract through an in-root symlink = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestSnippetTrailingNewlines(t *testing.T) {
|
|
// A fence body with or without trailing blank lines matches a source
|
|
// ending in newlines; any other difference is drift.
|
|
body := "```yaml src=config/app.yaml#keys\nb: 1\n\nc: 2\n\n\n```\n\n" +
|
|
"```text src=config/whole.txt\nline\n```\n\n" +
|
|
" ```text src=config/whole.txt\n line\n ```\n"
|
|
root := snippetTree(t, map[string]string{
|
|
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
|
"config/whole.txt": "line\n\n\n",
|
|
"docs/setup/start.md": page("Start", "setup", 10, body),
|
|
})
|
|
if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Check: %v %q", err, problemLines(problems))
|
|
}
|
|
}
|
|
|
|
func TestSyncPreservesAndReports(t *testing.T) {
|
|
doc := page("Start", "setup", 10, "Before.\n\n```go src=pkg/lib.go#Greeting\nold one\n```\n\nMiddle `x`.\n\n"+
|
|
"```go src=pkg/lib_test.go#ExampleGreeting\nold two\n```\n\n```go src=pkg/lib.go#Greeting\n// Greeting returns a greeting.\n"+
|
|
"func Greeting(name string) string {\n\treturn \"Hello, \" + name\n}\n```\n\nAfter.")
|
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
|
path := filepath.Join(root, "docs/setup/start.md")
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 || res != (SyncResult{Snippets: 2, Files: 1}) {
|
|
t.Fatalf("Sync = %+v %q %v", res, problemLines(problems), err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := strings.Replace(strings.Replace(doc, "old one", "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}", 1),
|
|
"old two", "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog", 1)
|
|
if string(got) != want {
|
|
t.Fatalf("synced =\n%s\nwant\n%s", got, want)
|
|
}
|
|
if st, err := os.Stat(path); err != nil || st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("mode after Sync = %v, %v; want 0600", st.Mode().Perm(), err)
|
|
}
|
|
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("second Sync = %+v, %v", res, err)
|
|
}
|
|
|
|
// One broken reference anywhere means Sync writes nothing at all.
|
|
other := page("Other", "setup", 20, "```go src=pkg/lib.go#Greeting\nstale\n```\n")
|
|
broken := page("Broken", "setup", 30, "```go src=pkg/lib.go#Missing\n```\n")
|
|
root = snippetTree(t, map[string]string{"docs/setup/other.md": other, "docs/setup/broken.md": broken})
|
|
res, problems, err = Sync(Options{Root: root})
|
|
if err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("Sync with a broken ref = %+v, %v", res, err)
|
|
}
|
|
assertProblems(t, problemLines(problems), []string{"docs/setup/broken.md:9: snippet: pkg/lib.go#Missing not found"})
|
|
if got, _ := os.ReadFile(filepath.Join(root, "docs/setup/other.md")); string(got) != other {
|
|
t.Fatal("Sync wrote a file although another reference was broken")
|
|
}
|
|
if _, _, err := Sync(Options{Root: root, Src: filepath.Join(root, "missing")}); err == nil {
|
|
t.Fatal("Sync on a missing source directory returned no error")
|
|
}
|
|
}
|
|
|
|
func TestSnippetTestGraph(t *testing.T) {
|
|
root := writeTree(t, map[string]string{
|
|
"p/p.go": "package p\n",
|
|
"p/a_test.go": "package p\n\nimport \"testing\"\n\nfunc TestA(t *testing.T) { helperOne() }\n\nfunc helperOne() { helperTwo() }\n",
|
|
"p/b_test.go": "package p\n\nfunc helperTwo() {\n\t// docs:start deep\n\t_ = 1\n\t// docs:end deep\n}\n\nfunc (s *suite) run() {}\n\ntype suite struct{}\n\nfunc lonely() {}\n",
|
|
})
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{"p/b_test.go", "deep"}, ""},
|
|
{Ref{"p/b_test.go", "helperTwo"}, ""},
|
|
{Ref{"p/b_test.go", "lonely"}, notRunMessage},
|
|
{Ref{"p/b_test.go", "suite.run"}, notRunMessage},
|
|
{Ref{"p/b_test.go", "suite"}, notRunMessage},
|
|
} {
|
|
_, err := Extract(root, tc.ref)
|
|
if (tc.want == "") != (err == nil) || (err != nil && err.Error() != tc.want) {
|
|
t.Errorf("Extract(%s) = %v, want %q", tc.ref, err, tc.want)
|
|
}
|
|
}
|
|
// A test file that does not parse is reported, not skipped.
|
|
writeFile(t, root, "p/c_test.go", "package p\n\nfunc {\n")
|
|
if _, err := Extract(root, Ref{"p/b_test.go", "deep"}); err == nil || !strings.Contains(err.Error(), "cannot parse c_test.go") {
|
|
t.Errorf("Extract with an unparsable sibling test = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSnippetRootsAndBuild(t *testing.T) {
|
|
other := "windows"
|
|
if runtime.GOOS == "windows" {
|
|
other = "linux"
|
|
}
|
|
root := writeTree(t, map[string]string{
|
|
"p/p.go": "package p\n",
|
|
"p/p_test.go": `package p
|
|
|
|
import "testing"
|
|
|
|
func Test(t *testing.T) { fromTest() }
|
|
func Test_x(t *testing.T) { fromTestX() }
|
|
func TestMain(m *testing.M) { fromMain() }
|
|
func Testable() { fromTestable() }
|
|
|
|
func fromTest() {}
|
|
func fromTestX() {}
|
|
func fromMain() {}
|
|
func fromTestable() {}
|
|
func onlyFromIgnored() {}
|
|
|
|
func ExampleOut() {
|
|
fromOut()
|
|
// Output: out
|
|
}
|
|
|
|
func ExampleEmpty() {
|
|
fromEmpty()
|
|
// Output:
|
|
}
|
|
|
|
func ExampleNone() {
|
|
fromNone()
|
|
}
|
|
|
|
func fromOut() {}
|
|
func fromEmpty() {}
|
|
func fromNone() {}
|
|
`,
|
|
"p/ignored_test.go": "//go:build ignore\n\npackage p\n\nimport \"testing\"\n\nfunc TestIgnored(t *testing.T) { onlyFromIgnored() }\n",
|
|
"p/broken.go": "//go:build ignore\n\npackage p\n\nfunc Broken() {}\n",
|
|
"p/broken_test.go": "//go:build ignore\n\npackage p\n\nfunc IgnoredTestTarget() {}\n",
|
|
"p/only_" + other + ".go": "package p\n\nfunc OnlyOS() {}\n",
|
|
"p/testdata/skip.go": "package p\n\nfunc Skip() {}\n",
|
|
"p/_old/old.go": "package p\n\nfunc Old() {}\n",
|
|
"p/testdata/app.yaml": "# docs:start db\nhost: localhost\n# docs:end db\n",
|
|
})
|
|
g, err := loadTestGraph(filepath.Join(root, "p"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"Test", "Test_x", "TestMain", "ExampleOut", "ExampleEmpty", "fromTest", "fromTestX", "fromMain", "fromOut", "fromEmpty"} {
|
|
if !g.reachable[name] {
|
|
t.Errorf("%s is not a root or reachable from one", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"Testable", "ExampleNone", "fromTestable", "fromNone", "onlyFromIgnored", "TestIgnored"} {
|
|
if g.reachable[name] {
|
|
t.Errorf("%s is reachable, want it not run", name)
|
|
}
|
|
}
|
|
mustExtract := func(ref Ref, wantErr string) {
|
|
t.Helper()
|
|
_, err := Extract(root, ref)
|
|
if wantErr == "" {
|
|
if err != nil {
|
|
t.Errorf("Extract(%s) = %v, want nil", ref, err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), wantErr) {
|
|
t.Errorf("Extract(%s) = %v, want %q", ref, err, wantErr)
|
|
}
|
|
}
|
|
skipped := "file is in a directory go test ./... skips"
|
|
excluded := "file is excluded from the default build"
|
|
mustExtract(Ref{"p/p_test.go", "fromOut"}, "")
|
|
mustExtract(Ref{"p/p_test.go", "fromEmpty"}, "")
|
|
mustExtract(Ref{"p/p_test.go", "fromNone"}, notRunMessage)
|
|
mustExtract(Ref{"p/p_test.go", "fromTestable"}, notRunMessage)
|
|
mustExtract(Ref{"p/p_test.go", "onlyFromIgnored"}, notRunMessage)
|
|
mustExtract(Ref{"p/broken.go", "Broken"}, excluded)
|
|
mustExtract(Ref{"p/broken_test.go", "IgnoredTestTarget"}, excluded)
|
|
mustExtract(Ref{"p/only_" + other + ".go", "OnlyOS"}, excluded)
|
|
mustExtract(Ref{"p/testdata/skip.go", "Skip"}, skipped)
|
|
mustExtract(Ref{"p/_old/old.go", "Old"}, skipped)
|
|
got, err := Extract(root, Ref{"p/testdata/app.yaml", "db"})
|
|
if err != nil || got != "host: localhost" {
|
|
t.Fatalf("Extract yaml = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestSyncParsedFences(t *testing.T) {
|
|
callout := "> [!NOTE]\n> Keep this callout text.\n"
|
|
doc := page("Start", "setup", 10, "Intro.\n\n```go src=pkg/lib.go#Greeting\nfunc stale() {}\n```\n\n"+callout+"\nOutro.\n")
|
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
|
path := filepath.Join(root, "docs/setup/start.md")
|
|
res, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Sync: %v %v", err, problems)
|
|
}
|
|
if res.Files != 1 || res.Snippets != 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.\nfunc 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 !strings.HasPrefix(string(got), "---\ntitle: Start\n") || !strings.Contains(string(got), callout) {
|
|
t.Fatal("frontmatter or callout text changed")
|
|
}
|
|
|
|
nested := page("Start", "setup", 10, "Intro.\n\n> [!NOTE]\n> ```go src=pkg/lib.go#Greeting\n> func stale() {}\n> ```\n\nOutro.\n")
|
|
root = snippetTree(t, map[string]string{"docs/setup/start.md": nested})
|
|
before := map[string]string{}
|
|
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before[p] = string(b)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, problems, err = Sync(Options{Root: root})
|
|
if err != nil || res != (SyncResult{}) || len(problems) != 1 || !strings.Contains(problems[0].Message, nestedSrcMessage) {
|
|
t.Fatalf("nested Sync = %+v %q %v", res, problemLines(problems), err)
|
|
}
|
|
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(b) != before[p] {
|
|
t.Errorf("Sync changed %s", p)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|