package gonexapp_test import ( "bytes" "fmt" "go/ast" "go/doc" "go/doc/comment" "go/parser" "go/printer" "go/token" "os" "strings" "testing" ) const apiModulePath = "gitea.neitzel.de/konrad/go-nc-exapp" func TestAPIDoc(t *testing.T) { got, err := renderAPIDoc(".", apiModulePath) if err != nil { t.Fatal(err) } const path = "docs/api.md" if os.Getenv("UPDATE_API_DOCS") == "1" { if err := os.MkdirAll("docs", 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(got), 0o644); err != nil { t.Fatal(err) } return } want, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } if string(want) != got { t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got)) } } func renderAPIDoc(dir, modulePath string) (string, error) { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { return !strings.HasSuffix(fi.Name(), "_test.go") }, parser.ParseComments) if err != nil { return "", err } if len(pkgs) != 1 { return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs)) } var astPkg *ast.Package for _, p := range pkgs { astPkg = p } pkg := doc.New(astPkg, modulePath, 0) lookup := symLookup(pkg) var b strings.Builder b.WriteString("# API\n\n") b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n") b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n") fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name) writeDoc(&b, pkg.Doc, lookup) writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup) writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup) writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup) if len(pkg.Types) > 0 { b.WriteString("## Types\n\n") for _, typ := range pkg.Types { writeType(&b, fset, typ, lookup) } } return b.String(), nil } func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) { fmt.Fprintf(b, "### %s\n\n", typ.Name) sig, err := formatGenDecl(fset, typ.Decl) if err != nil { fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) } else { writeCode(b, sig) } writeDoc(b, typ.Doc, lookup) writeValues(b, fset, "", typ.Consts, "#### ", lookup) writeValues(b, fset, "", typ.Vars, "#### ", lookup) writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup) if !isInterface(typ) { writeFuncs(b, fset, "", typ.Methods, "#### ", lookup) } } func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) { if len(vals) == 0 { return } if title != "" { fmt.Fprintf(b, "## %s\n\n", title) } for _, v := range vals { for _, name := range v.Names { fmt.Fprintf(b, "%s%s\n\n", heading, name) } sig, err := formatGenDecl(fset, v.Decl) if err != nil { fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) } else { writeCode(b, sig) } writeDoc(b, v.Doc, lookup) } } func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) { if len(fns) == 0 { return } if title != "" { fmt.Fprintf(b, "## %s\n\n", title) } for _, fn := range fns { name := fn.Name if fn.Recv != "" { name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name } fmt.Fprintf(b, "%s%s\n\n", heading, name) sig, err := formatFunc(fset, fn.Decl) if err != nil { fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) } else { writeCode(b, sig) } writeDoc(b, fn.Doc, lookup) } } func writeCode(b *strings.Builder, sig string) { b.WriteString("```go\n") b.WriteString(sig) b.WriteString("\n```\n\n") } func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) { text := renderComment(raw, lookup) if text == "" { return } b.WriteString(text) if !strings.HasSuffix(text, "\n") { b.WriteByte('\n') } b.WriteByte('\n') } func symLookup(pkg *doc.Package) func(string, string) bool { syms := map[string]bool{} methods := map[string]bool{} addNames := func(vals []*doc.Value) { for _, v := range vals { for _, name := range v.Names { syms[name] = true } } } addFuncs := func(fns []*doc.Func) { for _, fn := range fns { syms[fn.Name] = true if fn.Recv != "" { recv := strings.TrimPrefix(fn.Recv, "*") methods[recv+"."+fn.Name] = true } } } addNames(pkg.Consts) addNames(pkg.Vars) addFuncs(pkg.Funcs) for _, typ := range pkg.Types { syms[typ.Name] = true addNames(typ.Consts) addNames(typ.Vars) addFuncs(typ.Funcs) addFuncs(typ.Methods) } return func(recv, name string) bool { if recv == "" { return syms[name] } return methods[recv+"."+name] } } func renderComment(raw string, lookup func(string, string) bool) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } var parser comment.Parser parser.LookupSym = lookup var printer comment.Printer printer.HeadingLevel = 4 printer.DocLinkURL = func(link *comment.DocLink) string { if link.ImportPath != "" { return link.DefaultURL("https://pkg.go.dev") } name := link.Name if link.Recv != "" { name = link.Recv + "." + link.Name } return "#" + githubAnchor(name) } return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw)))) } // githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep // letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat". func githubAnchor(name string) string { var b strings.Builder for _, r := range strings.ToLower(name) { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { b.WriteRune(r) } } return b.String() } func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) { if decl == nil { return "", fmt.Errorf("nil decl") } copyDecl := *decl copyDecl.Doc = nil return formatNode(fset, ©Decl) } func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) { if decl == nil { return "", fmt.Errorf("nil decl") } copyDecl := *decl copyDecl.Doc = nil copyDecl.Body = nil return formatNode(fset, ©Decl) } func formatNode(fset *token.FileSet, node ast.Node) (string, error) { var buf bytes.Buffer if err := printer.Fprint(&buf, fset, node); err != nil { return "", err } return strings.TrimSpace(buf.String()), nil } func isInterface(typ *doc.Type) bool { if typ.Decl == nil || len(typ.Decl.Specs) != 1 { return false } spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec) if !ok { return false } _, ok = spec.Type.(*ast.InterfaceType) return ok } func firstDiff(want, got string) string { w := strings.Split(want, "\n") g := strings.Split(got, "\n") n := min(len(g), len(w)) for i := 0; i < n; i++ { if w[i] != g[i] { return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i]) } } return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w)) }