package docsite import ( "os" "path/filepath" "slices" "strings" "testing" ) // fixtureModule is a small package exercising every declaration kind the // identifier index records. const fixtureModule = `package fixture import "context" // Bus is a generic-method host with a field and an embedded type. type Bus struct { Name string Base } // Base is embedded in Bus. type Base struct{ ID int } // Ping is promoted to Bus. func (Base) Ping() {} // Handler is an interface. type Handler interface { Handle(ctx context.Context) error } // Mode is a const. const Mode = 1 // Default is a var. var Default = &Bus{} // New builds a Bus. func New() *Bus { return &Bus{} } // Fire is a generic method. func (b *Bus) Fire[T any](v T) {} // Close is a value-receiver method. func (b Bus) Close() error { return nil } ` func identFixture(t *testing.T, indexBody, readme string) string { t.Helper() return writeTree(t, map[string]string{ "docs/site.yaml": fixtureSite, "docs/index.md": page("Acme docs", "index", 0, indexBody), "docs/setup/start.md": page("Start", "setup", 10, "Text.\n"), "modules/fixture/fixture.go": fixtureModule, "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n" + readme, "modules/fixture/sub/sub.go": "package sub\n\n// Thing is exported.\ntype Thing struct{}\n", "modules/fixture/testdata/x.go": "package x\n\n// Hidden is never indexed.\nfunc Hidden() {}\n", }) } // writeFile writes one file under root, creating its directory. func writeFile(t *testing.T, root, name, body string) { t.Helper() p := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } func TestIdentifierChecker(t *testing.T) { passing := strings.Join([]string{ "- `fixture.New()` and `fixture.New`", "- `*fixture.Bus` and `fixture.Bus.Close`", "- `fixture.Bus.Fire[string](\"x\")` (generic method)", "- `fixture.Bus.Name` and `fixture.Bus.Base` (field, embedded)", "- `fixture.Bus.ID` and `fixture.Bus.Ping()` (promoted through Base)", "- `fixture.Handler.Handle` (interface method)", "- `fixture.Mode`, `fixture.Default`, `fixture.Bus.lowercase`", "- `sub.Thing` (sub-package)", "- `http.Handler`, `fields.yaml`, `acme.blog`, `summer.yaml`, `fixture.lower`", "", "```text", "fixture.NotChecked() // fenced blocks are skipped", "```", "", }, "\n") root := identFixture(t, passing, "## Usage\n\nCall `fixture.New()`.\n") problems, err := Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } if len(problems) > 0 { t.Fatalf("passing fixture: %q", problemLines(problems)) } failing := passing + "Then `fixture.Missing` and `fixture.Bus.Nope`.\n\n`fixture.Hidden` lives in testdata.\n" root = identFixture(t, failing, "## Usage\n\nCall `fixture.Gone()`.\n") writeFile(t, root, "README.md", "# Root\n\nSee `x.Y`.\n\n`sub.Nothing`\n") problems, err = Check(Options{Root: root, Commands: fixtureCommands}) if err != nil { t.Fatal(err) } want := []string{ "README.md:5: identifier: sub.Nothing does not exist in modules/fixture/sub", "docs/index.md:22: identifier: fixture.Missing does not exist in modules/fixture", "docs/index.md:22: identifier: fixture.Bus.Nope does not exist in modules/fixture", "docs/index.md:24: identifier: fixture.Hidden does not exist in modules/fixture", "modules/fixture/README.md:7: identifier: fixture.Gone does not exist in modules/fixture", } if got := problemLines(problems); !slices.Equal(got, want) { t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } func TestIdentifierIndexDuplicateName(t *testing.T) { root := writeTree(t, map[string]string{ "modules/alpha/alpha.go": "package alpha\n", "modules/alpha/util/util.go": "package util\n", "modules/beta/util/util.go": "package util\n", }) _, problems, err := buildIdentIndex(root) if err != nil { t.Fatal(err) } want := `modules/beta/util: identifier: package name "util" is used by modules/alpha/util and modules/beta/util; spans cannot tell them apart` if got := problemLines(problems); !slices.Equal(got, []string{want}) { t.Fatalf("problems = %q, want [%q]", got, want) } } // checkFixture writes a fixture tree around one index body and one // fixture README section and returns its problem lines. func checkFixture(t *testing.T, cmds *Commands, files map[string]string) []string { t.Helper() tree := map[string]string{ "docs/site.yaml": fixtureSite, "docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nText.\n"), "modules/fixture/fixture.go": "package fixture\n", "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\nText.\n", } for k, v := range files { tree[k] = v } root := writeTree(t, tree) problems, err := Check(Options{Root: root, Commands: cmds}) if err != nil { t.Fatal(err) } return problemLines(problems) } func assertProblems(t *testing.T, got, want []string) { t.Helper() if !slices.Equal(got, want) { t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } func TestLinkChecker(t *testing.T) { good := "## Alpha\n\nSee [alpha](#alpha), [start](setup/start.md#first-steps), " + "[usage](../modules/fixture/README.md#usage), [web](https://example.com) and [mail](mailto:a@example.com).\n\n" + "![logo](https://example.com/logo.png)\n" assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, good), "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" + "See [start](../../docs/setup/start.md) and [source](fixture.go).\n", }), nil) bad := good + "\n[a](#nope) [b](setup/missing.md) [c](setup/start.md#nope)\n\n" + "[d](../modules/fixture/fixture.go) [e](/abs.html)\n" assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, bad), "modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" + "See [other](../other/README.md) and [anchor](#missing).\n", }), []string{ "docs/index.md:15: link: #nope not found in docs/index.md", "docs/index.md:15: link: setup/missing.md does not resolve", "docs/index.md:15: link: #nope not found in docs/setup/start.md", "docs/index.md:17: link: ../modules/fixture/fixture.go does not resolve", "docs/index.md:17: link: /abs.html does not resolve", "modules/fixture/README.md:7: link: ../other/README.md does not resolve", "modules/fixture/README.md:7: link: #missing not found in modules/fixture/README.md", }) } func TestCommandChecker(t *testing.T) { example := "package main\n\nimport \"example.com/bonfire\"\n\n" + "var one = bonfire.Command{Name: \"acme:greet\"}\n\n" + "var many = []bonfire.Command{{Name: \"acme:list\"}, {Name: \"acme:sync\"}}\n" good := "Run `summer docs:build` or `$ summer make:plugin acme.blog`.\n\n" + "```sh\n$ summer docs:build --out site\nsummer --help\ncd app && summer make:plugin acme.blog\n" + "./bin/acme serve --addr :8080\n./bin/acme acme:greet blog\n./bin/acme acme:sync\n```\n\n" + "```text\nsummer not:checked\n```\n\n`summer.yaml` and `go install ./cmd/summer` are not commands.\n" files := map[string]string{ "docs/index.md": page("Acme docs", "index", 0, good), "docs/examples/greet/main.go": example, "examples/app/plugins/p/plugin.go": example, } assertProblems(t, checkFixture(t, fixtureCommands, files), nil) files["docs/index.md"] = page("Acme docs", "index", 0, good+ "\nThen `summer no:such`.\n\n```bash\n./bin/acme docs:build\nsummer migrate\n```\n") files["modules/fixture/README.md"] = "# fixture\n\nFixture does one thing.\n\n## Usage\n\n```sh\n./bin/acme fixture:run\n```\n" assertProblems(t, checkFixture(t, fixtureCommands, files), []string{ `docs/index.md:26: command: "no:such" is not a summer or application command`, `docs/index.md:29: command: "docs:build" is not a summer or application command`, `docs/index.md:30: command: "migrate" is not a summer or application command`, `modules/fixture/README.md:8: command: "fixture:run" is not a summer or application command`, }) assertProblems(t, checkFixture(t, nil, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, "Text.\n"), }), []string{"docs: command: no command set supplied"}) } func TestForbiddenChecker(t *testing.T) { // The forbidden words are built at run time so no test source names a // consuming application. name := "Fono" + "teka" accented := "P" + "Ł" + "Ý" + "tarium" assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, "The host application.\n"), }), nil) got := checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, "The "+name+" app.\n"), "docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nSee "+accented+".\n"), }) assertProblems(t, got, []string{ "docs/index.md:9: forbidden: consuming-application name in output", "docs/setup/start.md:11: forbidden: consuming-application name in output", }) site := strings.Replace(fixtureSite, "description: Acme docs.", "description: Docs for "+strings.ToLower(name)+".", 1) got = checkFixture(t, fixtureCommands, map[string]string{ "docs/site.yaml": site, "docs/index.md": page("Acme docs", "index", 0, "Text.\n"), }) if !slices.Contains(got, "llms.txt:3: forbidden: consuming-application name in output") { t.Fatalf("output check missed llms.txt: %q", got) } for _, line := range got { if !strings.HasSuffix(line, ": forbidden: consuming-application name in output") || strings.Contains(strings.ToLower(line), strings.ToLower(name)) { t.Fatalf("unexpected problem line %q", line) } } } func TestFencePolicy(t *testing.T) { readme := "# fixture\n\nFixture does one thing.\n\n## Usage of `fixture`\n\n```go\nfixture.Run()\n```\n\n> [!TIP]\n> Fine.\n" good := "## Plain heading\n\n> [!NOTE]\n> A note.\n\n> [!WARNING]\n> Careful.\n\n```text\nplain\n```\n\n" + "```yaml\nkey: value\n```\n\n```md\n> [!DANGER]\n```\n" assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, good), "modules/fixture/README.md": readme, }), nil) bad := good + "\n```go\nfmt.Println()\n```\n\n> [!DANGER]\n> Boom.\n\n## Use `fixture`\n\n## Zażółć\n\n## See [start](setup/start.md)\n" assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ "docs/index.md": page("Acme docs", "index", 0, bad), "modules/fixture/README.md": readme + "\n> [!CAUTION]\n> No.\n", }), []string{ "docs/index.md:29: snippet: go code block has no src= reference", "docs/index.md:33: callout: unknown type DANGER (use NOTE, TIP or WARNING)", "docs/index.md:36: heading: headings must be plain ASCII text without links or code", "docs/index.md:38: heading: headings must be plain ASCII text without links or code", "docs/index.md:40: heading: headings must be plain ASCII text without links or code", "modules/fixture/README.md:14: callout: unknown type CAUTION (use NOTE, TIP or WARNING)", }) }