| // Copyright 2026 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The evaldoc command is an HTTP server that takes a module_path@version or a local directory path, |
| // loads the module, and serves: |
| // - Root (/): A list of all import paths in the module with counts of symbols that have and need |
| // documentation. |
| // - /<importPath>: A list of files in that package directory with counts of symbols that have |
| // and need documentation. |
| // - /<importPath>/<filename>: The contents of that file with symbol documentation highlighting. |
| // |
| // It can be used to better understand the "documentation coverage" score |
| // on a package's evaluations page (pkg.go.dev/IMPORT/PATH?tab=evals). |
| // Run it on your module to get a list of symbols which need documentation, |
| // and whether they have documentation. |
| package main |
| |
| import ( |
| "cmp" |
| "context" |
| "errors" |
| "flag" |
| "fmt" |
| "go/ast" |
| "go/parser" |
| "go/token" |
| "html/template" |
| "io/fs" |
| "log" |
| "math" |
| "net" |
| "net/http" |
| "os" |
| "path" |
| "path/filepath" |
| "slices" |
| "strings" |
| |
| "golang.org/x/mod/modfile" |
| "golang.org/x/pkgsite/internal/frontend" |
| "golang.org/x/pkgsite/internal/proxy" |
| ) |
| |
| var ( |
| httpAddr = flag.String("http", ":0", "address to listen on") |
| proxyURL = flag.String("proxy", "", "module proxy URL (defaults to GOPROXY or https://proxy.golang.org)") |
| ) |
| |
| // highlight is a byte range [start, end) in a source file corresponding to a symbol |
| // identifier, and whether that symbol has documentation. |
| type highlight struct { |
| start int |
| end int |
| has bool |
| } |
| |
| // importPathItem contains documentation coverage statistics for a package import |
| // path. |
| // Fields are exported because this struct appears in a template argument. |
| type importPathItem struct { |
| ImportPath string |
| NumHave, NumNeed int // number of symbols that have/need doc |
| NeedPct int // percentage of symbols that need doc |
| } |
| |
| // fileItem contains documentation coverage statistics for an individual source file. |
| // Fields are exported because this struct appears in a template argument. |
| type fileItem struct { |
| Filename string |
| NumHave, NumNeed int // number of symbols that have/need doc |
| NeedPct int // percentage of symbols that need doc |
| } |
| |
| // server holds state and pre-rendered HTML data for serving the evaldoc web UI. |
| type server struct { |
| modulePath string |
| resolvedVersion string |
| importPaths []importPathItem |
| dirFiles map[string][]fileItem // importPath -> list of file items |
| fileHTML map[string]template.HTML // importPath + "/" + filename -> HTML content |
| } |
| |
| func main() { |
| flag.Usage = func() { |
| fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [flags] (module_path@version | local_dir_path)\n", os.Args[0]) |
| fmt.Fprintln(flag.CommandLine.Output(), "local_dir_path must start with one of: / . ~") |
| flag.PrintDefaults() |
| } |
| flag.Parse() |
| |
| if flag.NArg() == 0 { |
| flag.Usage() |
| os.Exit(1) |
| } |
| |
| arg := flag.Arg(0) |
| modulePath, resolvedVersion, contentDir, err := getContentDir(arg) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| s, err := newServer(modulePath, resolvedVersion, contentDir) |
| if err != nil { |
| log.Fatalf("newServer: %v", err) |
| } |
| |
| addr := *httpAddr |
| if addr == "" { |
| addr = ":0" |
| } |
| ln, err := net.Listen("tcp", addr) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| log.Printf("Serving %s@%s on %s", modulePath, resolvedVersion, ln.Addr()) |
| if err := http.Serve(ln, s); err != nil { |
| log.Fatal(err) |
| } |
| } |
| |
| // resolveLocalPath expands a leading "~" to the user's home directory and returns |
| // the absolute path of arg. |
| func resolveLocalPath(arg string) (string, error) { |
| if strings.HasPrefix(arg, "~") { |
| home, err := os.UserHomeDir() |
| if err != nil { |
| return "", fmt.Errorf("arg is local path, but cannot get home directory: %v", err) |
| } |
| arg = filepath.Join(home, arg[1:]) |
| } |
| return filepath.Abs(arg) |
| } |
| |
| // getContentDir uses arg to find a module, and returns an fs.FS whose root is the |
| // content directory of that module (the directory containing the go.mod file). |
| // It also returns the module path and the version, with "latest" resolved to a specific version. |
| func getContentDir(arg string) (modulePath, resolvedVersion string, contentDir fs.FS, err error) { |
| if len(arg) == 0 { |
| return "", "", nil, errors.New("empty argument") |
| } |
| if arg[0] == '/' || arg[0] == '.' || arg[0] == '~' { |
| localPath, err := resolveLocalPath(arg) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("failed to resolve path %s: %w", arg, err) |
| } |
| fi, err := os.Stat(localPath) |
| if err != nil || !fi.IsDir() { |
| return "", "", nil, fmt.Errorf("%s is not a directory", localPath) |
| } |
| modBytes, err := os.ReadFile(filepath.Join(localPath, "go.mod")) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("failed to read go.mod in %s: %w", localPath, err) |
| } |
| modulePath = modfile.ModulePath(modBytes) |
| if modulePath == "" { |
| return "", "", nil, fmt.Errorf("go.mod in %s contains no module path", localPath) |
| } |
| return modulePath, "local", os.DirFS(localPath), nil |
| } |
| |
| var reqVer string |
| var found bool |
| modulePath, reqVer, found = strings.Cut(arg, "@") |
| if !found || reqVer == "" { |
| reqVer = "latest" |
| } |
| |
| pURL := *proxyURL |
| if pURL == "" { |
| pURL = os.Getenv("GOPROXY") |
| } |
| if pURL == "off" { |
| return "", "", nil, errors.New("GOPROXY is off") |
| } |
| if pURL == "" { |
| pURL = "https://proxy.golang.org" |
| } |
| // Take the first URL from the GOPROXY list. |
| if idx := strings.IndexAny(pURL, ",|"); idx != -1 { |
| pURL = pURL[:idx] |
| } |
| |
| ctx := context.Background() |
| proxyClient, err := proxy.New(pURL, http.DefaultTransport) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("proxy.New(%q): %w", pURL, err) |
| } |
| proxyClient = proxyClient.WithFetchDisabled() |
| |
| verInfo, err := proxyClient.Info(ctx, modulePath, reqVer) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("proxyClient.Info(%q, %q): %w", modulePath, reqVer, err) |
| } |
| resolvedVersion = verInfo.Version |
| |
| zipReader, err := proxyClient.Zip(ctx, modulePath, resolvedVersion) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("proxyClient.Zip(%q, %q): %w", modulePath, resolvedVersion, err) |
| } |
| |
| contentDir, err = fs.Sub(zipReader, modulePath+"@"+resolvedVersion) |
| if err != nil { |
| return "", "", nil, fmt.Errorf("fs.Sub: %w", err) |
| } |
| |
| return modulePath, resolvedVersion, contentDir, nil |
| } |
| |
| // packageDirs walks contentDir to find directories corresponding to valid import paths, |
| // and returns their relative paths. |
| func packageDirs(contentDir fs.FS) ([]string, error) { |
| var dirs []string |
| err := fs.WalkDir(contentDir, ".", func(p string, d fs.DirEntry, err error) error { |
| if err != nil { |
| return err |
| } |
| if !d.IsDir() { |
| return nil |
| } |
| // Skip directory trees under internal, vendor and testdata. |
| name := d.Name() |
| if name == "internal" || name == "vendor" || name == "testdata" { |
| return fs.SkipDir |
| } |
| // Skip directory trees if the name starts with a ".", other than "." itself. |
| if len(name) >= 2 && name[0] == '.' { |
| return fs.SkipDir |
| } |
| dirs = append(dirs, p) |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| return dirs, nil |
| } |
| |
| // newServer parses and analyzes all non-internal Go package files in contentDir, |
| // calculates symbol documentation statistics, pre-renders HTML views, and returns |
| // a server for modulePath and resolvedVersion. |
| func newServer(modulePath, resolvedVersion string, contentDir fs.FS) (*server, error) { |
| // Phase 1: Walk contentDir to get the set of valid package directories. |
| dirs, err := packageDirs(contentDir) |
| if err != nil { |
| return nil, err |
| } |
| |
| // Phase 2: For each package directory, parse its files and collect symbols. |
| dirFiles := make(map[string][]fileItem) |
| fileHTML := make(map[string]template.HTML) |
| dirHaveCounts := make(map[string]int) |
| dirNeedCounts := make(map[string]int) |
| var rawImportPaths []string |
| |
| for _, relDir := range dirs { |
| importPath := modulePath |
| if relDir != "." && relDir != "" { |
| importPath = path.Join(modulePath, relDir) |
| } |
| entries, err := fs.ReadDir(contentDir, relDir) |
| if err != nil { |
| return nil, err |
| } |
| |
| var fileNames []string |
| fset := token.NewFileSet() |
| var astFiles []*ast.File |
| fileSrcs := make(map[string][]byte) |
| |
| for _, entry := range entries { |
| if entry.IsDir() { |
| continue |
| } |
| fname := entry.Name() |
| // Ignore test files and files that aren't go. |
| if !strings.HasSuffix(fname, ".go") || strings.HasSuffix(fname, "_test.go") { |
| continue |
| } |
| fileNames = append(fileNames, fname) |
| |
| fp := path.Join(relDir, fname) |
| content, err := fs.ReadFile(contentDir, fp) |
| if err != nil { |
| fmt.Fprintf(os.Stderr, "%s: reading: %v\n", fp, err) |
| continue |
| } |
| fileSrcs[fname] = content |
| |
| f, err := parser.ParseFile(fset, fname, content, parser.ParseComments) |
| if err == nil { |
| astFiles = append(astFiles, f) |
| } else { |
| fmt.Fprintf(os.Stderr, "%s: parsing: %v\n", fp, err) |
| } |
| } |
| |
| if len(astFiles) == 0 { |
| continue |
| } |
| |
| rawImportPaths = append(rawImportPaths, importPath) |
| |
| fileHighlights := make(map[string][]highlight) |
| fileNumHave := make(map[string]int) |
| fileNumNeed := make(map[string]int) |
| pkgNumHave := 0 |
| pkgNumNeed := 0 |
| |
| // Call the same function used by the evals page to summarize documentation. |
| frontend.CollectSymbols(astFiles, func(id *ast.Ident, has bool) { |
| pos := fset.Position(id.Pos()) |
| end := fset.Position(id.End()) |
| |
| fileHighlights[pos.Filename] = append(fileHighlights[pos.Filename], highlight{ |
| start: pos.Offset, |
| end: end.Offset, |
| has: has, |
| }) |
| if has { |
| fileNumHave[pos.Filename]++ |
| pkgNumHave++ |
| } else { |
| fileNumNeed[pos.Filename]++ |
| pkgNumNeed++ |
| } |
| }) |
| |
| var fileItems []fileItem |
| for _, fname := range fileNames { |
| src := fileSrcs[fname] |
| key := importPath + "/" + fname |
| fileHTML[key] = formatHighlightedFile(src, fileHighlights[fname]) |
| |
| g := fileNumHave[fname] |
| r := fileNumNeed[fname] |
| fileItems = append(fileItems, fileItem{ |
| Filename: fname, |
| NumHave: g, |
| NumNeed: r, |
| NeedPct: percent(g, r), |
| }) |
| } |
| slices.SortFunc(fileItems, func(a, b fileItem) int { |
| if c := cmp.Compare(b.NumNeed, a.NumNeed); c != 0 { |
| return c |
| } |
| return cmp.Compare(a.Filename, b.Filename) |
| }) |
| dirFiles[importPath] = fileItems |
| dirHaveCounts[importPath] = pkgNumHave |
| dirNeedCounts[importPath] = pkgNumNeed |
| } |
| |
| var importPathItems []importPathItem |
| for _, ip := range rawImportPaths { |
| g := dirHaveCounts[ip] |
| r := dirNeedCounts[ip] |
| importPathItems = append(importPathItems, importPathItem{ |
| ImportPath: ip, |
| NumHave: g, |
| NumNeed: r, |
| NeedPct: percent(g, r), |
| }) |
| } |
| |
| slices.SortFunc(importPathItems, func(a, b importPathItem) int { |
| if c := cmp.Compare(b.NumNeed, a.NumNeed); c != 0 { |
| return c |
| } |
| return cmp.Compare(a.ImportPath, b.ImportPath) |
| }) |
| |
| return &server{ |
| modulePath: modulePath, |
| resolvedVersion: resolvedVersion, |
| importPaths: importPathItems, |
| dirFiles: dirFiles, |
| fileHTML: fileHTML, |
| }, nil |
| } |
| |
| // percent returns the percentage of m/ (n + m), rounded to the nearest integer. |
| // It returns 0 if n + m is 0. |
| func percent(n, m int) int { |
| total := n + m |
| if total == 0 { |
| return 0 |
| } |
| return int(math.Round(float64(m) * 100 / float64(total))) |
| } |
| |
| func formatHighlightedFile(src []byte, highlights []highlight) template.HTML { |
| slices.SortFunc(highlights, func(a, b highlight) int { |
| return cmp.Compare(a.start, b.start) |
| }) |
| |
| var buf strings.Builder |
| last := 0 |
| for _, h := range highlights { |
| if h.start < last || h.start > len(src) || h.end > len(src) { |
| continue |
| } |
| buf.WriteString(template.HTMLEscapeString(string(src[last:h.start]))) |
| if h.has { |
| buf.WriteString(`<span class="has-doc">`) |
| } else { |
| buf.WriteString(`<span class="need-doc">`) |
| } |
| buf.WriteString(template.HTMLEscapeString(string(src[h.start:h.end]))) |
| buf.WriteString(`</span>`) |
| last = h.end |
| } |
| if last < len(src) { |
| buf.WriteString(template.HTMLEscapeString(string(src[last:]))) |
| } |
| |
| return template.HTML(buf.String()) |
| } |
| |
| func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path == "/" { |
| s.handleRoot(w) |
| return |
| } |
| if r.URL.Path == "/styles.css" { |
| w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| fmt.Fprint(w, stylesCSS) |
| return |
| } |
| |
| p := strings.TrimPrefix(r.URL.Path, "/") |
| if files, ok := s.dirFiles[p]; ok { |
| s.handleDir(w, p, files) |
| return |
| } |
| |
| if contentHTML, ok := s.fileHTML[p]; ok { |
| s.handleFile(w, p, contentHTML) |
| return |
| } |
| |
| http.NotFound(w, r) |
| } |
| |
| const stylesCSS = ` |
| body { font-family: sans-serif; margin: 20px; } |
| .has-doc { color: #1a7f37; font-weight: bold; margin-left: 4px; } |
| .need-doc { color: #cf222e; font-weight: bold; margin-left: 4px; } |
| .need-pct { color: #000; font-weight: bold; margin-left: 4px; } |
| pre { |
| font-family: monospace; background-color: #f6f8fa; |
| padding: 16px; border-radius: 6px; overflow: auto; |
| } |
| .legend { font-family: sans-serif; margin-bottom: 1em; } |
| pre .has-doc, .legend .has-doc { |
| background-color: #dafbe1; padding: 0 2px; border-radius: 2px; margin-left: 0; |
| } |
| pre .need-doc, .legend .need-doc { |
| background-color: #ffebe9; padding: 0 2px; border-radius: 2px; margin-left: 0; |
| } |
| ` |
| |
| var rootTmpl = template.Must(template.New("root").Parse(`<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <title>Module {{.ModulePath}}@{{.Version}}</title> |
| <link rel="stylesheet" href="/styles.css"> |
| </head> |
| <body> |
| <h1>Module {{.ModulePath}}@{{.Version}} |
| <span class="has-doc">{{.NumHave}}</span> |
| <span class="need-doc">{{.NumNeed}}</span> |
| <span class="need-pct">{{.NeedPct}}%</span> |
| </h1> |
| <h2>Packages</h2> |
| <ul> |
| {{range .ImportPaths}} |
| <li> |
| <a href="/{{.ImportPath}}">{{.ImportPath}}</a> |
| <span class="has-doc">{{.NumHave}}</span> |
| <span class="need-doc">{{.NumNeed}}</span> |
| <span class="need-pct">{{.NeedPct}}%</span> |
| </li> |
| {{end}} |
| </ul> |
| </body> |
| </html>`)) |
| |
| func (s *server) handleRoot(w http.ResponseWriter) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| var have, need int |
| for _, item := range s.importPaths { |
| have += item.NumHave |
| need += item.NumNeed |
| } |
| data := struct { |
| ModulePath string |
| Version string |
| NumHave, NumNeed int |
| NeedPct int |
| ImportPaths []importPathItem |
| }{ |
| ModulePath: s.modulePath, |
| Version: s.resolvedVersion, |
| NumHave: have, |
| NumNeed: need, |
| NeedPct: percent(have, need), |
| ImportPaths: s.importPaths, |
| } |
| rootTmpl.Execute(w, data) |
| } |
| |
| var dirTmpl = template.Must(template.New("dir").Parse(`<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <title>Files in {{.ImportPath}}</title> |
| <link rel="stylesheet" href="/styles.css"> |
| </head> |
| <body> |
| <p><a href="/"> |
| Back to Packages</a></p> |
| <h1>Files in {{.ImportPath}}</h1> |
| <ul> |
| {{range .Files}} |
| <li> |
| <a href="/{{$.ImportPath}}/{{.Filename}}">{{.Filename}}</a> |
| <span class="has-doc">{{.NumHave}}</span> |
| <span class="need-doc">{{.NumNeed}}</span> |
| <span class="need-pct">{{.NeedPct}}%</span> |
| </li> |
| {{end}} |
| </ul> |
| </body> |
| </html>`)) |
| |
| func (s *server) handleDir(w http.ResponseWriter, importPath string, files []fileItem) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| data := struct { |
| ImportPath string |
| Files []fileItem |
| }{ |
| ImportPath: importPath, |
| Files: files, |
| } |
| dirTmpl.Execute(w, data) |
| } |
| |
| var fileTmpl = template.Must(template.New("file").Parse(`<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <title>{{.Filename}} - {{.ImportPath}}</title> |
| <link rel="stylesheet" href="/styles.css"> |
| </head> |
| <body> |
| <p><a href="/{{.ImportPath}}">← Back to {{.ImportPath}}</a></p> |
| <div class="legend"> |
| Symbol status: |
| <span class="has-doc">Documented (Green)</span> | |
| <span class="need-doc">Undocumented (Red)</span> |
| </div> |
| <h1>{{.Filename}}</h1> |
| <pre><code>{{.Contents}}</code></pre> |
| </body> |
| </html>`)) |
| |
| func (s *server) handleFile(w http.ResponseWriter, key string, contentHTML template.HTML) { |
| var importPath, filename string |
| for ip := range s.dirFiles { |
| if after, ok := strings.CutPrefix(key, ip+"/"); ok { |
| if len(ip) > len(importPath) { |
| importPath = ip |
| filename = after |
| } |
| } |
| } |
| |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| fileData := struct { |
| ImportPath string |
| Filename string |
| Contents template.HTML |
| }{ |
| ImportPath: importPath, |
| Filename: filename, |
| Contents: contentHTML, |
| } |
| fileTmpl.Execute(w, fileData) |
| } |