devtools/cmd/evaldoc: support fetching modules from proxy

This CL augments the evaldoc program by taking a module@version
argument and fetching it from the proxy.

I tested this manually. The final version will have more extensive
tests.

For golang/go#80385.

Change-Id: I26f413abad242378eb45b5abf52fba7f0c2ddeed
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/811080
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Ethan Lee <ethanalee@google.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
diff --git a/devtools/cmd/evaldoc/main.go b/devtools/cmd/evaldoc/main.go
index 6de4316..916f596 100644
--- a/devtools/cmd/evaldoc/main.go
+++ b/devtools/cmd/evaldoc/main.go
@@ -2,8 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// The evaldoc command takes a local directory path, loads the module,
-// and prints symbols and whether they have documentation to standard output.
+// The evaldoc command takes a module_path@version or a local directory
+// path, loads the module, and prints symbols and whether they have
+// documentation to standard output.
 //
 // It can be used to better understand the "documentation coverage" score
 // on a package's evaluations page (pkg.go.dev/IMPORT/PATH?tab=evals).
@@ -12,6 +13,7 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"flag"
 	"fmt"
@@ -20,6 +22,7 @@
 	"go/token"
 	"io/fs"
 	"log"
+	"net/http"
 	"os"
 	"path"
 	"path/filepath"
@@ -27,11 +30,17 @@
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/proxy"
+)
+
+var (
+	proxyURL = flag.String("proxy", "", "module proxy URL (defaults to GOPROXY or https://proxy.golang.org)")
 )
 
 func main() {
 	flag.Usage = func() {
-		fmt.Fprintf(flag.CommandLine.Output(), "usage: %s local_dir_path\n", os.Args[0])
+		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()
@@ -65,29 +74,79 @@
 	return filepath.Abs(arg)
 }
 
-// getContentDir uses arg to find a module at localPath, and returns an fs.FS whose root is the
+// 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.
 func getContentDir(arg string) (modulePath string, contentDir fs.FS, err error) {
 	if len(arg) == 0 {
 		return "", nil, errors.New("empty argument")
 	}
-	localPath, err := resolveLocalPath(arg)
+	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, 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("failed to resolve path %s: %w", arg, err)
+		return "", nil, fmt.Errorf("proxy.New(%q): %w", pURL, 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"))
+	proxyClient = proxyClient.WithFetchDisabled()
+
+	verInfo, err := proxyClient.Info(ctx, modulePath, reqVer)
 	if err != nil {
-		return "", nil, fmt.Errorf("failed to read go.mod in %s: %w", localPath, err)
+		return "", nil, fmt.Errorf("proxyClient.Info(%q, %q): %w", modulePath, reqVer, err)
 	}
-	modulePath = modfile.ModulePath(modBytes)
-	if modulePath == "" {
-		return "", nil, fmt.Errorf("go.mod in %s contains no module path", localPath)
+	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)
 	}
-	return modulePath, os.DirFS(localPath), nil
+
+	contentDir, err = fs.Sub(zipReader, modulePath+"@"+resolvedVersion)
+	if err != nil {
+		return "", nil, fmt.Errorf("fs.Sub: %w", err)
+	}
+
+	return modulePath, contentDir, nil
 }
 
 // packageDirs walks contentDir to find directories corresponding to valid import paths,