package docsite import ( "os" "path/filepath" "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) } }