devtools/cmd/evaldoc: turn into HTTP web server with plain text rendering

Instead of printing to the terminal, bring up a web server to display
the information.

This will pay off in the next CL, when we can render the entire module
with useful links and colors.

For golang/go#80385.

Change-Id: I6e3c18bec776923bb5464c17f975d98a222c6dcc
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/813060
kokoro-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Ethan Lee <ethanalee@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/devtools/cmd/evaldoc/main.go b/devtools/cmd/evaldoc/main.go
index 916f596..31dd61f 100644
--- a/devtools/cmd/evaldoc/main.go
+++ b/devtools/cmd/evaldoc/main.go
@@ -22,6 +22,7 @@
 	"go/token"
 	"io/fs"
 	"log"
+	"net"
 	"net/http"
 	"os"
 	"path"
@@ -34,9 +35,14 @@
 )
 
 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)")
 )
 
+type server struct {
+	text string
+}
+
 func main() {
 	flag.Usage = func() {
 		fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [flags] (module_path@version | local_dir_path)\n", os.Args[0])
@@ -51,14 +57,34 @@
 	}
 
 	arg := flag.Arg(0)
-	modulePath, contentDir, err := getContentDir(arg)
+	modulePath, resolvedVersion, contentDir, err := getContentDir(arg)
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	if err := evalDoc(modulePath, contentDir); err != nil {
+	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)
+	}
+}
+
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	fmt.Fprint(w, s.text)
 }
 
 // resolveLocalPath expands a leading "~" to the user's home directory and returns
@@ -76,29 +102,29 @@
 
 // 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) {
+// 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")
+		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)
+			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)
+			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)
+			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 "", "", nil, fmt.Errorf("go.mod in %s contains no module path", localPath)
 		}
-		return modulePath, os.DirFS(localPath), nil
+		return modulePath, "local", os.DirFS(localPath), nil
 	}
 
 	var reqVer string
@@ -113,7 +139,7 @@
 		pURL = os.Getenv("GOPROXY")
 	}
 	if pURL == "off" {
-		return "", nil, errors.New("GOPROXY is off")
+		return "", "", nil, errors.New("GOPROXY is off")
 	}
 	if pURL == "" {
 		pURL = "https://proxy.golang.org"
@@ -126,27 +152,27 @@
 	ctx := context.Background()
 	proxyClient, err := proxy.New(pURL, http.DefaultTransport)
 	if err != nil {
-		return "", nil, fmt.Errorf("proxy.New(%q): %w", pURL, err)
+		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)
+		return "", "", nil, fmt.Errorf("proxyClient.Info(%q, %q): %w", modulePath, reqVer, err)
 	}
-	resolvedVersion := verInfo.Version
+	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 "", "", 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 "", "", nil, fmt.Errorf("fs.Sub: %w", err)
 	}
 
-	return modulePath, contentDir, nil
+	return modulePath, resolvedVersion, contentDir, nil
 }
 
 // packageDirs walks contentDir to find directories corresponding to valid import paths,
@@ -178,12 +204,13 @@
 	return dirs, nil
 }
 
-func evalDoc(modulePath string, contentDir fs.FS) error {
+func newServer(modulePath, resolvedVersion string, contentDir fs.FS) (*server, error) {
 	dirs, err := packageDirs(contentDir)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
+	var buf strings.Builder
 	for _, relDir := range dirs {
 		importPath := modulePath
 		if relDir != "." && relDir != "" {
@@ -192,7 +219,7 @@
 
 		entries, err := fs.ReadDir(contentDir, relDir)
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		fset := token.NewFileSet()
@@ -224,10 +251,11 @@
 			continue
 		}
 
-		fmt.Println(importPath)
+		fmt.Fprintf(&buf, "%s@%s\n", importPath, resolvedVersion)
 		frontend.CollectSymbols(astFiles, func(id *ast.Ident, has bool) {
-			fmt.Printf("  %s: %t\n", id.Name, has)
+			fmt.Fprintf(&buf, "  %s: %t\n", id.Name, has)
 		})
 	}
-	return nil
+
+	return &server{text: buf.String()}, nil
 }