728 lines
17 KiB
Go
728 lines
17 KiB
Go
// Package docgen analyses a Go project and renders audience-segmented
|
|
// documentation for go-doc.
|
|
//
|
|
// The pipeline is deliberately mechanical: Analyze walks a project tree and
|
|
// extracts facts that can be read off the filesystem (module metadata, HTTP
|
|
// routes, CLI flags, environment variables, Make targets, container services,
|
|
// Helm charts, existing Markdown, language breakdown), and Render turns those
|
|
// facts into the same three-audience layout go-doc uses natively:
|
|
//
|
|
// ## For Users what the thing does and how to use it
|
|
// ## For Operators running, configuring and deploying it
|
|
// ## For Developers contracts, layout and dependencies
|
|
//
|
|
// Import wraps the rendered page in generated markers so re-running the tool
|
|
// refreshes only the machine-written region and never clobbers prose a human
|
|
// added outside it.
|
|
package docgen
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// maxReadBytes caps how much of any single file is read into memory.
|
|
const maxReadBytes = 2 << 20
|
|
|
|
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
|
|
const maxWalkFiles = 200000
|
|
|
|
// Route is one HTTP endpoint discovered in the source.
|
|
type Route struct {
|
|
Method string
|
|
Path string
|
|
Handler string
|
|
}
|
|
|
|
// String renders the route the way it is registered.
|
|
func (r Route) String() string {
|
|
if r.Method == "" {
|
|
return r.Path
|
|
}
|
|
return r.Method + " " + r.Path
|
|
}
|
|
|
|
// SortKey orders routes by path, then method, so output is stable.
|
|
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
|
|
|
|
// Flag is a command-line flag declared with the flag package.
|
|
type Flag struct {
|
|
Name string
|
|
Type string
|
|
Default string
|
|
Usage string
|
|
}
|
|
|
|
// EnvVar is an environment variable the code reads, plus a note when it was
|
|
// discovered in a .env.example file rather than in source.
|
|
type EnvVar struct {
|
|
Name string
|
|
Example string
|
|
FromEnv bool
|
|
}
|
|
|
|
// Dependency is one requirement line from a go.mod.
|
|
type Dependency struct {
|
|
Path string
|
|
Version string
|
|
Indirect bool
|
|
}
|
|
|
|
// Module is one go.mod file found in the tree.
|
|
type Module struct {
|
|
Path string
|
|
File string
|
|
GoVersion string
|
|
Direct []Dependency
|
|
Indirect int
|
|
}
|
|
|
|
// ComposeService is a service declared in a docker-compose file.
|
|
type ComposeService struct {
|
|
Name string
|
|
Image string
|
|
Ports string
|
|
File string
|
|
}
|
|
|
|
// DockerInfo summarises the project's Dockerfile.
|
|
type DockerInfo struct {
|
|
File string
|
|
Stages []string
|
|
Bases []string
|
|
Exposed []string
|
|
}
|
|
|
|
// DocFile is an existing Markdown document that ships with the project.
|
|
type DocFile struct {
|
|
Path string
|
|
Title string
|
|
Bytes int
|
|
}
|
|
|
|
// Dir is a first-level source directory with a purpose inferred from its name.
|
|
type Dir struct {
|
|
Name string
|
|
Files int
|
|
Lines int
|
|
}
|
|
|
|
// Language is a per-extension line count.
|
|
type Language struct {
|
|
Name string
|
|
Files int
|
|
Lines int
|
|
}
|
|
|
|
// Stats are the headline counters shown in the generated page.
|
|
type Stats struct {
|
|
GoFiles int
|
|
TestFiles int
|
|
TestFuncs int
|
|
Components int
|
|
TotalLines int
|
|
OldestCommit string
|
|
}
|
|
|
|
// Project is the full analysis result for one repository.
|
|
type Project struct {
|
|
Name string
|
|
Path string
|
|
Summary string
|
|
Readme string
|
|
|
|
Modules []Module
|
|
Routes []Route
|
|
Flags []Flag
|
|
EnvVars []EnvVar
|
|
MakeTargets []string
|
|
Compose []ComposeService
|
|
Docker *DockerInfo
|
|
HelmCharts []string
|
|
Docs []DocFile
|
|
Dirs []Dir
|
|
Languages []Language
|
|
Ports []string
|
|
Stats Stats
|
|
|
|
// Signature changes whenever any analysed input changes. Watch mode uses it
|
|
// to decide whether a regeneration is needed.
|
|
Signature string
|
|
}
|
|
|
|
// Analyze walks root and extracts everything Render needs.
|
|
//
|
|
// A missing or partial project is not an error: a repository without a go.mod
|
|
// or without a README still yields a usable page describing whatever was found.
|
|
func Analyze(name, root string) (*Project, error) {
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("docgen: %s is not a directory", root)
|
|
}
|
|
|
|
p := &Project{Name: name, Path: root}
|
|
|
|
files, err := collectFiles(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Signature = signature(files)
|
|
|
|
for _, file := range files {
|
|
rel := file.rel
|
|
|
|
switch {
|
|
case strings.HasSuffix(rel, ".go"):
|
|
p.Stats.GoFiles++
|
|
p.Stats.TotalLines += file.lines
|
|
if strings.HasSuffix(rel, "_test.go") {
|
|
p.Stats.TestFiles++
|
|
p.Stats.TestFuncs += countTestFuncs(file.text())
|
|
}
|
|
if isComponentPath(rel) {
|
|
p.Stats.Components++
|
|
}
|
|
analyzeGoFile(p, file)
|
|
|
|
case filepath.Base(rel) == "go.mod":
|
|
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
|
|
|
|
case isMarkdown(rel):
|
|
p.Docs = append(p.Docs, DocFile{
|
|
Path: rel,
|
|
Title: markdownTitle(file.text()),
|
|
Bytes: int(file.size),
|
|
})
|
|
|
|
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
|
|
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
|
|
|
|
case isDockerfile(rel):
|
|
if p.Docker == nil {
|
|
p.Docker = parseDockerfile(rel, file.text())
|
|
}
|
|
|
|
case isComposeFile(rel):
|
|
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
|
|
|
|
case strings.HasPrefix(filepath.Base(rel), ".env"):
|
|
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
|
|
|
|
case isHelmFile(rel):
|
|
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
|
|
}
|
|
|
|
p.Languages = addLanguage(p.Languages, file)
|
|
if portBearingFiles(rel) {
|
|
p.Ports = append(p.Ports, portsIn(file.text())...)
|
|
}
|
|
}
|
|
|
|
finish(p)
|
|
return p, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Normalisation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func finish(p *Project) {
|
|
p.Readme = findReadme(p)
|
|
p.Summary = summarize(p)
|
|
|
|
p.Modules = dedupeModules(p.Modules)
|
|
p.Routes = dedupeRoutes(p.Routes)
|
|
p.Flags = dedupeFlags(p.Flags)
|
|
p.EnvVars = dedupeEnv(p.EnvVars)
|
|
p.MakeTargets = dedupeStrings(p.MakeTargets)
|
|
p.HelmCharts = dedupeStrings(p.HelmCharts)
|
|
p.Ports = dedupeStrings(p.Ports)
|
|
p.Dirs = topDirs(p)
|
|
p.Languages = rankLanguages(p.Languages)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Walking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type scannedFile struct {
|
|
path string
|
|
rel string
|
|
size int64
|
|
lines int
|
|
}
|
|
|
|
func (f scannedFile) text() string {
|
|
data, err := readCapped(f.path, maxReadBytes)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// ignoredDirs never contain first-party source worth documenting.
|
|
var ignoredDirs = map[string]struct{}{
|
|
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
|
|
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
|
|
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
|
|
}
|
|
|
|
func collectFiles(root string) ([]scannedFile, error) {
|
|
var files []scannedFile
|
|
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// An unreadable subtree must not abort the whole analysis.
|
|
if entry != nil && entry.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if len(files) >= maxWalkFiles {
|
|
return fs.SkipAll
|
|
}
|
|
|
|
name := entry.Name()
|
|
if entry.IsDir() {
|
|
if _, skip := ignoredDirs[name]; skip && path != root {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !entry.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
if isNoise(name) {
|
|
return nil
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
rel = name
|
|
}
|
|
files = append(files, scannedFile{
|
|
path: path,
|
|
rel: filepath.ToSlash(rel),
|
|
size: info.Size(),
|
|
lines: countLinesFast(path),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// isNoise filters artefacts that would pollute the analysis.
|
|
func isNoise(name string) bool {
|
|
switch filepath.Ext(name) {
|
|
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
|
|
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
|
|
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
|
|
return true
|
|
}
|
|
// Certificates and keys are frequently committed without extensions.
|
|
switch {
|
|
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
|
|
return true
|
|
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isMarkdown(rel string) bool {
|
|
return strings.EqualFold(filepath.Ext(rel), ".md")
|
|
}
|
|
|
|
func isDockerfile(rel string) bool {
|
|
base := filepath.Base(rel)
|
|
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
|
|
}
|
|
|
|
func isComposeFile(rel string) bool {
|
|
base := strings.ToLower(filepath.Base(rel))
|
|
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
|
|
return false
|
|
}
|
|
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
|
|
}
|
|
|
|
func isHelmChart(rel string) bool {
|
|
parts := strings.Split(rel, "/")
|
|
for i, part := range parts {
|
|
if part == "helm" || part == "charts" {
|
|
// A chart is a directory that owns a values.yaml or Chart.yaml.
|
|
if i+1 < len(parts) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// chartRoot collapses a chart file path to the directory that owns the chart,
|
|
// so the page lists one entry per chart rather than one per template.
|
|
func chartRoot(rel string) string {
|
|
dir := path.Dir(rel)
|
|
if dir == "." {
|
|
return "./ (repository root)"
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func isComponentPath(rel string) bool {
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) < 2 {
|
|
return false
|
|
}
|
|
switch parts[len(parts)-2] {
|
|
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readCapped(path string, limit int64) ([]byte, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
return io.ReadAll(io.LimitReader(file, limit))
|
|
}
|
|
|
|
func countLinesFast(path string) int {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer file.Close()
|
|
|
|
// Skip .md / .json / .lock style files where line counts are meaningless.
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
|
|
return 0
|
|
}
|
|
|
|
buf := make([]byte, 32<<10)
|
|
lines := 0
|
|
for {
|
|
n, err := file.Read(buf)
|
|
if n > 0 {
|
|
for _, b := range buf[:n] {
|
|
if b == '\n' {
|
|
lines++
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// signature hashes the identity of every analysed file. It deliberately uses
|
|
// size and mtime rather than content so watch mode can poll large trees cheaply.
|
|
func signature(files []scannedFile) string {
|
|
hash := sha256.New()
|
|
for _, file := range files {
|
|
info, err := os.Stat(file.path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil))[:32]
|
|
}
|
|
|
|
func countTestFuncs(source string) int {
|
|
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
|
|
strings.Count(source, "func Benchmark")
|
|
}
|
|
|
|
// noiseExtensions are file types that carry no language signal: build
|
|
// metadata, ignore lists and editor configuration all have extensions that would
|
|
// otherwise show up as their own "language".
|
|
var noiseExtensions = map[string]struct{}{
|
|
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
|
|
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
|
|
"env": {},
|
|
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
|
|
}
|
|
|
|
func addLanguage(langs []Language, file scannedFile) []Language {
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
|
|
if ext == "" {
|
|
return langs
|
|
}
|
|
if _, skip := noiseExtensions[ext]; skip {
|
|
return langs
|
|
}
|
|
name := languageName(ext)
|
|
lines := file.lines
|
|
if lines == 0 {
|
|
lines = countAllLinesFast(file.path)
|
|
}
|
|
for i := range langs {
|
|
if langs[i].Name == name {
|
|
langs[i].Files++
|
|
langs[i].Lines += lines
|
|
return langs
|
|
}
|
|
}
|
|
return append(langs, Language{Name: name, Files: 1, Lines: lines})
|
|
}
|
|
|
|
func countAllLinesFast(path string) int {
|
|
data, err := readCapped(path, maxReadBytes)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
lines := strings.Count(string(data), "\n")
|
|
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
|
|
lines++
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func languageName(ext string) string {
|
|
switch ext {
|
|
case "go":
|
|
return "Go"
|
|
case "js", "mjs", "cjs":
|
|
return "JavaScript"
|
|
case "jsx":
|
|
return "JSX"
|
|
case "ts", "tsx":
|
|
return "TypeScript"
|
|
case "css":
|
|
return "CSS"
|
|
case "html", "htm":
|
|
return "HTML"
|
|
case "sh", "bash":
|
|
return "Shell"
|
|
case "sql":
|
|
return "SQL"
|
|
case "yml", "yaml":
|
|
return "YAML"
|
|
case "json":
|
|
return "JSON"
|
|
case "md":
|
|
return "Markdown"
|
|
case "tf":
|
|
return "Terraform"
|
|
case "proto":
|
|
return "Protobuf"
|
|
case "tpl", "gotmpl":
|
|
return "Templates"
|
|
case "py":
|
|
return "Python"
|
|
default:
|
|
return ext
|
|
}
|
|
}
|
|
|
|
func rankLanguages(langs []Language) []Language {
|
|
sort.Slice(langs, func(i, j int) bool {
|
|
if langs[i].Lines == langs[j].Lines {
|
|
return langs[i].Name < langs[j].Name
|
|
}
|
|
return langs[i].Lines > langs[j].Lines
|
|
})
|
|
// A page does not benefit from a tail of one-file languages.
|
|
if len(langs) > maxLanguages {
|
|
langs = langs[:maxLanguages]
|
|
}
|
|
return langs
|
|
}
|
|
|
|
// maxLanguages caps the language table on a generated page.
|
|
const maxLanguages = 10
|
|
|
|
func topDirs(p *Project) []Dir {
|
|
byName := map[string]*Dir{}
|
|
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if entry.IsDir() {
|
|
if path != p.Path {
|
|
if _, skip := ignoredDirs[entry.Name()]; skip {
|
|
return fs.SkipDir
|
|
}
|
|
// Dot directories hold tooling, not first-party source.
|
|
if strings.HasPrefix(entry.Name(), ".") {
|
|
return fs.SkipDir
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(p.Path, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
dir := byName[parts[0]]
|
|
if dir == nil {
|
|
dir = &Dir{Name: parts[0]}
|
|
byName[parts[0]] = dir
|
|
}
|
|
dir.Files++
|
|
dir.Lines += countLinesFast(path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
dirs := make([]Dir, 0, len(byName))
|
|
for _, dir := range byName {
|
|
dirs = append(dirs, *dir)
|
|
}
|
|
sort.Slice(dirs, func(i, j int) bool {
|
|
if dirs[i].Files == dirs[j].Files {
|
|
return dirs[i].Name < dirs[j].Name
|
|
}
|
|
return dirs[i].Files > dirs[j].Files
|
|
})
|
|
if len(dirs) > 14 {
|
|
dirs = dirs[:14]
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func dedupeStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, value := range in {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[value]; dup {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
|
|
// whose order is meaningful (Docker build stages, published ports).
|
|
func dedupeInOrder(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, value := range in {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[value]; dup {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeModules(mods []Module) []Module {
|
|
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
|
|
return mods
|
|
}
|
|
|
|
// dedupeRoutes collapses registrations that describe the same endpoint. The
|
|
// key is method plus path, because one endpoint is often registered in several
|
|
// places (a group helper plus the router); when several carry a handler name,
|
|
// the one with a handler wins over the one without.
|
|
func dedupeRoutes(routes []Route) []Route {
|
|
best := make(map[string]Route, len(routes))
|
|
for _, route := range routes {
|
|
if route.Path == "" {
|
|
continue
|
|
}
|
|
key := route.Method + " " + route.Path
|
|
existing, seen := best[key]
|
|
switch {
|
|
case !seen:
|
|
best[key] = route
|
|
case existing.Handler == "" && route.Handler != "":
|
|
best[key] = route
|
|
}
|
|
}
|
|
|
|
out := make([]Route, 0, len(best))
|
|
for _, route := range best {
|
|
out = append(out, route)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Path == out[j].Path {
|
|
return out[i].Method < out[j].Method
|
|
}
|
|
return out[i].Path < out[j].Path
|
|
})
|
|
return out
|
|
}
|
|
|
|
func dedupeFlags(flags []Flag) []Flag {
|
|
seen := map[string]struct{}{}
|
|
out := make([]Flag, 0, len(flags))
|
|
for _, flag := range flags {
|
|
if _, dup := seen[flag.Name]; dup {
|
|
continue
|
|
}
|
|
seen[flag.Name] = struct{}{}
|
|
out = append(out, flag)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func dedupeEnv(vars []EnvVar) []EnvVar {
|
|
seen := map[string]struct{}{}
|
|
out := make([]EnvVar, 0, len(vars))
|
|
for _, v := range vars {
|
|
if _, dup := seen[v.Name]; dup {
|
|
continue
|
|
}
|
|
seen[v.Name] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// Timestamp is used inside generated markers so a reader can tell how fresh a
|
|
// machine-written region is.
|
|
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }
|