blob: b1c1f2d56575c2cc7369cb2beaf9490274864d3c [file]
// 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)
}