internal/godoc: simplify, isolate pkg docs tree code

- IsGOROOT is always true (the only docs we serve)
- SummarizePackage is unused
- newDirectory is only called on the root with unlimited depth
- Name is redundant with Path
- Directory.Depth is unused
- only DirEntry is assigned to dirEntryOrFileInfo
- parallel walk is only needed for huge trees (GOROOT is not)
- separate entirely from Corpus
- pull new DocTree out of handlerServer

This will let us better separate the package doc tree from HTTP service
and from the general document server.

Change-Id: Iac667a0ff624306cdd535a33dd22dcb04062b46c
Reviewed-on: https://go-review.googlesource.com/c/website/+/296378
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/_content/lib/godoc/packageroot.html b/_content/lib/godoc/packageroot.html
index 8655dcd..2b5c5e6 100644
--- a/_content/lib/godoc/packageroot.html
+++ b/_content/lib/godoc/packageroot.html
@@ -45,7 +45,6 @@
 
 						{{range .List}}
 							<tr>
-							{{if .IsGOROOT}}
 							{{if $.DirFlat}}
 								{{if .HasPkg}}
 										<td class="pkg-name">
@@ -57,10 +56,9 @@
 										<a href="{{html .Path}}/{{modeQueryString $.Mode | html}}">{{html .Name}}</a>
 									</td>
 							{{end}}
-								<td class="pkg-synopsis">
-									{{html .Synopsis}}
-								</td>
-							{{end}}
+							<td class="pkg-synopsis">
+								{{html .Synopsis}}
+							</td>
 							</tr>
 						{{end}}
 					</table>
diff --git a/internal/godoc/corpus.go b/internal/godoc/corpus.go
index 6de01d1..7c403f6 100644
--- a/internal/godoc/corpus.go
+++ b/internal/godoc/corpus.go
@@ -8,7 +8,6 @@
 package godoc
 
 import (
-	"errors"
 	"io/fs"
 	"sync"
 	"time"
@@ -27,25 +26,12 @@
 	// Verbose logging.
 	Verbose bool
 
-	// SummarizePackage optionally specifies a function to
-	// summarize a package. It exists as an optimization to
-	// avoid reading files to parse package comments.
-	//
-	// If SummarizePackage returns false for ok, the caller
-	// ignores all return values and parses the files in the package
-	// as if SummarizePackage were nil.
-	//
-	// If showList is false, the package is hidden from the
-	// package listing.
-	SummarizePackage func(pkg string) (summary string, showList, ok bool)
-
 	// Send a value on this channel to trigger a metadata refresh.
 	// It is buffered so that if a signal is not lost if sent
 	// during a refresh.
 	refreshMetadataSignal chan bool
 
 	// file system information
-	fsTree      rwValue // *Directory tree of packages, updated with each sync (but sync code is removed now)
 	fsModified  rwValue // timestamp of last call to invalidateIndex
 	docMetadata rwValue // mapping from paths to *Metadata
 
@@ -77,9 +63,6 @@
 // Init initializes Corpus, once options on Corpus are set.
 // It must be called before any subsequent method calls.
 func (c *Corpus) Init() error {
-	if err := c.initFSTree(); err != nil {
-		return err
-	}
 	c.updateMetadata()
 	go c.refreshMetadataLoop()
 
@@ -88,12 +71,3 @@
 	c.initMu.Unlock()
 	return nil
 }
-
-func (c *Corpus) initFSTree() error {
-	dir := c.newDirectory("/", -1)
-	if dir == nil {
-		return errors.New("godoc: corpus fstree is nil")
-	}
-	c.fsTree.Set(dir)
-	return nil
-}
diff --git a/internal/godoc/dirtrees.go b/internal/godoc/dirtrees.go
index f7adf99..ddc2ae8 100644
--- a/internal/godoc/dirtrees.go
+++ b/internal/godoc/dirtrees.go
@@ -16,104 +16,45 @@
 	"io/fs"
 	"log"
 	pathpkg "path"
-	"runtime"
 	"sort"
 	"strings"
 )
 
-// Conventional name for directories containing test data.
-// Excluded from directory trees.
-//
-const testdataDirName = "testdata"
-
 type Directory struct {
-	Depth    int
-	Path     string       // directory path; includes Name
-	Name     string       // directory name
+	Path     string       // directory path
 	HasPkg   bool         // true if the directory contains at least one package
 	Synopsis string       // package documentation, if any
-	IsGOROOT bool         // is this GOROOT
 	Dirs     []*Directory // subdirectories
 }
 
-type dirEntryOrFileInfo interface {
-	Name() string
-	IsDir() bool
+func (d *Directory) Name() string {
+	return pathpkg.Base(d.Path)
 }
 
-func isGoFile(fi dirEntryOrFileInfo) bool {
+func isPkgFile(fi fs.DirEntry) bool {
 	name := fi.Name()
 	return !fi.IsDir() &&
-		len(name) > 0 && name[0] != '.' && // ignore .files
-		pathpkg.Ext(name) == ".go"
-}
-
-func isPkgFile(fi dirEntryOrFileInfo) bool {
-	return isGoFile(fi) &&
+		pathpkg.Ext(name) == ".go" &&
 		!strings.HasSuffix(fi.Name(), "_test.go") // ignore test files
 }
 
-func isPkgDir(fi dirEntryOrFileInfo) bool {
+func isPkgDir(fi fs.DirEntry) bool {
 	name := fi.Name()
-	return fi.IsDir() && len(name) > 0 &&
-		name[0] != '_' && name[0] != '.' // ignore _files and .files
+	return fi.IsDir() &&
+		name != "testdata" &&
+		len(name) > 0 && name[0] != '_' && name[0] != '.' // ignore _files and .files
 }
 
-type treeBuilder struct {
-	c        *Corpus
-	maxDepth int
-}
-
-// ioGate is a semaphore controlling VFS activity (ReadDir, parseFile, etc).
-// Send before an operation and receive after.
-var ioGate = make(chan struct{}, 20)
-
-// workGate controls the number of concurrent workers. Too many concurrent
-// workers and performance degrades and the race detector gets overwhelmed. If
-// we cannot check out a concurrent worker, work is performed by the main thread
-// instead of spinning up another goroutine.
-var workGate = make(chan struct{}, runtime.NumCPU()*4)
-
-func (b *treeBuilder) newDirTree(fset *token.FileSet, path, name string, depth int) *Directory {
-	if name == testdataDirName {
-		return nil
-	}
-
-	if depth >= b.maxDepth {
-		// return a dummy directory so that the parent directory
-		// doesn't get discarded just because we reached the max
-		// directory depth
-		return &Directory{
-			Depth: depth,
-			Path:  path,
-			Name:  name,
-		}
-	}
-
+func newDirTree(fsys fs.FS, fset *token.FileSet, path string) *Directory {
 	var synopses [3]string // prioritized package documentation (0 == highest priority)
 
-	show := true // show in package listing
 	hasPkgFiles := false
 	haveSummary := false
 
-	if hook := b.c.SummarizePackage; hook != nil {
-		if summary, show0, ok := hook(strings.TrimPrefix(path, "/src/")); ok {
-			hasPkgFiles = true
-			show = show0
-			synopses[0] = summary
-			haveSummary = true
-		}
-	}
-
-	ioGate <- struct{}{}
-	list, err := fs.ReadDir(b.c.fs, toFS(path))
-	<-ioGate
+	list, err := fs.ReadDir(fsys, toFS(path))
 	if err != nil {
 		// TODO: propagate more. See golang.org/issue/14252.
-		// For now:
-		if b.c.Verbose {
-			log.Printf("newDirTree reading %s: %v", path, err)
-		}
+		log.Printf("newDirTree reading %s: %v", path, err)
 	}
 
 	// determine number of subdirectories and if there are package files
@@ -121,38 +62,24 @@
 	var dirs []*Directory
 
 	for _, d := range list {
-		filename := pathpkg.Join(path, d.Name())
+		name := d.Name()
+		filename := pathpkg.Join(path, name)
 		switch {
 		case isPkgDir(d):
-			name := d.Name()
-			select {
-			case workGate <- struct{}{}:
-				ch := make(chan *Directory, 1)
-				dirchs = append(dirchs, ch)
-				go func() {
-					ch <- b.newDirTree(fset, filename, name, depth+1)
-					<-workGate
-				}()
-			default:
-				// no free workers, do work synchronously
-				dir := b.newDirTree(fset, filename, name, depth+1)
-				if dir != nil {
-					dirs = append(dirs, dir)
-				}
+			dir := newDirTree(fsys, fset, filename)
+			if dir != nil {
+				dirs = append(dirs, dir)
 			}
+
 		case !haveSummary && isPkgFile(d):
 			// looks like a package file, but may just be a file ending in ".go";
 			// don't just count it yet (otherwise we may end up with hasPkgFiles even
 			// though the directory doesn't contain any real package files - was bug)
 			// no "optimal" package synopsis yet; continue to collect synopses
-			ioGate <- struct{}{}
 			const flags = parser.ParseComments | parser.PackageClauseOnly
-			file, err := b.c.parseFile(fset, filename, flags)
-			<-ioGate
+			file, err := parseFile(fsys, fset, filename, flags)
 			if err != nil {
-				if b.c.Verbose {
-					log.Printf("Error parsing %v: %v", filename, err)
-				}
+				log.Printf("parsing %v: %v", filename, err)
 				break
 			}
 
@@ -186,7 +113,7 @@
 	// We need to sort the dirs slice because
 	// it is appended again after reading from dirchs.
 	sort.Slice(dirs, func(i, j int) bool {
-		return dirs[i].Name < dirs[j].Name
+		return dirs[i].Path < dirs[j].Path
 	})
 
 	// if there are no package files and no subdirectories
@@ -204,21 +131,13 @@
 	}
 
 	return &Directory{
-		Depth:    depth,
 		Path:     path,
-		Name:     name,
-		HasPkg:   hasPkgFiles && show, // TODO(bradfitz): add proper Hide field?
+		HasPkg:   hasPkgFiles,
 		Synopsis: synopsis,
-		IsGOROOT: isGOROOT(b.c.fs),
 		Dirs:     dirs,
 	}
 }
 
-func isGOROOT(fsys fs.FS) bool {
-	_, err := fs.Stat(fsys, "src/math/abs.go")
-	return err == nil
-}
-
 // toFS returns the io/fs name for path (no leading slash).
 func toFS(path string) string {
 	if path == "/" {
@@ -227,112 +146,68 @@
 	return pathpkg.Clean(strings.TrimPrefix(path, "/"))
 }
 
-// newDirectory creates a new package directory tree with at most maxDepth
-// levels, anchored at root. The result tree is pruned such that it only
-// contains directories that contain package files or that contain
-// subdirectories containing package files (transitively). If a non-nil
-// pathFilter is provided, directory paths additionally must be accepted
-// by the filter (i.e., pathFilter(path) must be true). If a value >= 0 is
-// provided for maxDepth, nodes at larger depths are pruned as well; they
-// are assumed to contain package files even if their contents are not known
-// (i.e., in this case the tree may contain directories w/o any package files).
-//
-func (c *Corpus) newDirectory(root string, maxDepth int) *Directory {
-	// The root could be a symbolic link so use Stat not Lstat.
-	d, err := fs.Stat(c.fs, toFS(root))
-	// If we fail here, report detailed error messages; otherwise
-	// is is hard to see why a directory tree was not built.
-	switch {
-	case err != nil:
-		log.Printf("newDirectory(%s): %s", root, err)
-		return nil
-	case root != "/" && !isPkgDir(d):
-		log.Printf("newDirectory(%s): not a package directory", root)
-		return nil
-	case root == "/" && !d.IsDir():
-		log.Printf("newDirectory(%s): not a directory", root)
-		return nil
-	}
-	if maxDepth < 0 {
-		maxDepth = 1e6 // "infinity"
-	}
-	b := treeBuilder{c, maxDepth}
-	// the file set provided is only for local parsing, no position
-	// information escapes and thus we don't need to save the set
-	return b.newDirTree(token.NewFileSet(), root, d.Name(), 0)
+// walk calls f(d, depth) for each directory d in the tree rooted at dir, including dir itself.
+// The depth argument specifies the depth of d in the tree.
+// The depth of dir itself is 0.
+func (dir *Directory) walk(f func(d *Directory, depth int)) {
+	walkDirs(f, dir, 0)
 }
 
-func (dir *Directory) walk(c chan<- *Directory, skipRoot bool) {
-	if dir != nil {
-		if !skipRoot {
-			c <- dir
-		}
-		for _, d := range dir.Dirs {
-			d.walk(c, false)
-		}
+func walkDirs(f func(d *Directory, depth int), d *Directory, depth int) {
+	f(d, depth)
+	for _, sub := range d.Dirs {
+		walkDirs(f, sub, depth+1)
 	}
 }
 
-func (dir *Directory) iter(skipRoot bool) <-chan *Directory {
-	c := make(chan *Directory)
-	go func() {
-		dir.walk(c, skipRoot)
-		close(c)
-	}()
-	return c
-}
-
-func (dir *Directory) lookupLocal(name string) *Directory {
-	for _, d := range dir.Dirs {
-		if d.Name == name {
-			return d
-		}
-	}
-	return nil
-}
-
-func splitPath(p string) []string {
-	p = strings.TrimPrefix(p, "/")
-	if p == "" {
-		return nil
-	}
-	return strings.Split(p, "/")
-}
-
 // lookup looks for the *Directory for a given path, relative to dir.
 func (dir *Directory) lookup(path string) *Directory {
-	d := splitPath(dir.Path)
-	p := splitPath(path)
-	i := 0
-	for i < len(d) {
-		if i >= len(p) || d[i] != p[i] {
+	path = pathpkg.Join(dir.Path, path)
+	if path == dir.Path {
+		return dir
+	}
+	dirPathLen := len(dir.Path)
+	if dir.Path == "/" {
+		dirPathLen = 0 // so path[dirPathLen] is a slash
+	}
+	if !strings.HasPrefix(path, dir.Path) || path[dirPathLen] != '/' {
+		println("NO", path, dir.Path)
+		return nil
+	}
+	d := dir
+Walk:
+	for i := dirPathLen + 1; i <= len(path); i++ {
+		if i == len(path) || path[i] == '/' {
+			// Find next child along path.
+			for _, sub := range d.Dirs {
+				if sub.Path == path[:i] {
+					d = sub
+					continue Walk
+				}
+			}
+			println("LOST", path[:i])
 			return nil
 		}
-		i++
 	}
-	for dir != nil && i < len(p) {
-		dir = dir.lookupLocal(p[i])
-		i++
-	}
-	return dir
+	return d
 }
 
-// DirEntry describes a directory entry. The Depth and Height values
-// are useful for presenting an entry in an indented fashion.
-//
+// DirEntry describes a directory entry.
+// The Depth gives the directory depth relative to the overall list,
+// for use in presenting a hierarchical directory entry.
 type DirEntry struct {
 	Depth    int    // >= 0
-	Height   int    // = DirList.MaxHeight - Depth, > 0
-	Path     string // directory path; includes Name, relative to DirList root
-	Name     string // directory name
+	Path     string // relative path to directory from listing start
 	HasPkg   bool   // true if the directory contains at least one package
 	Synopsis string // package documentation, if any
-	IsGOROOT bool   // root type of the filesystem containing the direntry
+}
+
+func (d *DirEntry) Name() string {
+	return pathpkg.Base(d.Path)
 }
 
 type DirList struct {
-	MaxHeight int // directory tree height, > 0
-	List      []DirEntry
+	List []DirEntry
 }
 
 // listing creates a (linear) directory listing from a directory tree.
@@ -340,52 +215,32 @@
 // If filter is set, only the directory entries whose paths match the filter
 // are included.
 //
-func (dir *Directory) listing(skipRoot bool, filter func(string) bool) *DirList {
+func (dir *Directory) listing(filter func(string) bool) *DirList {
 	if dir == nil {
 		return nil
 	}
 
-	// determine number of entries n and maximum height
-	n := 0
-	minDepth := 1 << 30 // infinity
-	maxDepth := 0
-	for d := range dir.iter(skipRoot) {
-		n++
-		if minDepth > d.Depth {
-			minDepth = d.Depth
+	var list []DirEntry
+	dir.walk(func(d *Directory, depth int) {
+		if depth == 0 || filter != nil && !filter(d.Path) {
+			return
 		}
-		if maxDepth < d.Depth {
-			maxDepth = d.Depth
-		}
-	}
-	maxHeight := maxDepth - minDepth + 1
-
-	if n == 0 {
-		return nil
-	}
-
-	// create list
-	list := make([]DirEntry, 0, n)
-	for d := range dir.iter(skipRoot) {
-		if filter != nil && !filter(d.Path) {
-			continue
-		}
-		var p DirEntry
-		p.Depth = d.Depth - minDepth
-		p.Height = maxHeight - p.Depth
 		// the path is relative to root.Path - remove the root.Path
 		// prefix (the prefix should always be present but avoid
 		// crashes and check)
 		path := strings.TrimPrefix(d.Path, dir.Path)
 		// remove leading separator if any - path must be relative
 		path = strings.TrimPrefix(path, "/")
-		p.Path = path
-		p.Name = d.Name
-		p.HasPkg = d.HasPkg
-		p.Synopsis = d.Synopsis
-		p.IsGOROOT = d.IsGOROOT
-		list = append(list, p)
-	}
+		list = append(list, DirEntry{
+			Depth:    depth,
+			Path:     path,
+			HasPkg:   d.HasPkg,
+			Synopsis: d.Synopsis,
+		})
+	})
 
-	return &DirList{maxHeight, list}
+	if len(list) == 0 {
+		return nil
+	}
+	return &DirList{list}
 }
diff --git a/internal/godoc/dirtrees_test.go b/internal/godoc/dirtrees_test.go
index 766bc55..e228901 100644
--- a/internal/godoc/dirtrees_test.go
+++ b/internal/godoc/dirtrees_test.go
@@ -8,6 +8,7 @@
 package godoc
 
 import (
+	"go/token"
 	"os"
 	"runtime"
 	"sort"
@@ -15,17 +16,14 @@
 )
 
 func TestNewDirTree(t *testing.T) {
-	c := NewCorpus(os.DirFS(runtime.GOROOT()))
-	// 3 levels deep is enough for testing
-	dir := c.newDirectory("/", 3)
-
+	dir := newDirTree(os.DirFS(runtime.GOROOT()), token.NewFileSet(), "/src")
 	processDir(t, dir)
 }
 
 func processDir(t *testing.T, dir *Directory) {
 	var list []string
 	for _, d := range dir.Dirs {
-		list = append(list, d.Name)
+		list = append(list, d.Name())
 		// recursively process the lower level
 		processDir(t, d)
 	}
@@ -45,24 +43,6 @@
 	b.ResetTimer()
 	b.ReportAllocs()
 	for tries := 0; tries < b.N; tries++ {
-		corpus := NewCorpus(fs)
-		corpus.newDirectory("/", -1)
-	}
-}
-
-func TestIsGOROOT(t *testing.T) {
-	tests := []struct {
-		path     string
-		isGOROOT bool
-	}{
-		{runtime.GOROOT(), true},
-		{"/tmp/", false},
-	}
-
-	for _, item := range tests {
-		fs := os.DirFS(item.path)
-		if isGOROOT(fs) != item.isGOROOT {
-			t.Errorf("%s: isGOROOT = %v, want %v", item.path, !item.isGOROOT, item.isGOROOT)
-		}
+		newDirTree(fs, token.NewFileSet(), "/src")
 	}
 }
diff --git a/internal/godoc/godoc.go b/internal/godoc/godoc.go
index 25c3cd7..6031666 100644
--- a/internal/godoc/godoc.go
+++ b/internal/godoc/godoc.go
@@ -17,15 +17,8 @@
 	"strconv"
 	"strings"
 	"text/template"
-	"time"
 )
 
-// Fake relative package path for built-ins. Documentation for all globals
-// (not just exported ones) will be shown for packages in this directory,
-// and there will be no association of consts, vars, and factory functions
-// with types (see issue 6645).
-const builtinPkgPath = "builtin"
-
 // FuncMap defines template functions used in godoc templates.
 //
 // Convention: template function names ending in "_html" or "_url" produce
@@ -105,9 +98,8 @@
 	IsFiltered bool                 // true if results were filtered
 
 	// directory info
-	Dirs    *DirList  // nil if no directory information
-	DirTime time.Time // directory time stamp
-	DirFlat bool      // if set, show directory in a flat (non-indented) manner
+	Dirs    *DirList // nil if no directory information
+	DirFlat bool     // if set, show directory in a flat (non-indented) manner
 }
 
 func (info *PageInfo) IsEmpty() bool {
diff --git a/internal/godoc/parser.go b/internal/godoc/parser.go
index f742596..ec03639 100644
--- a/internal/godoc/parser.go
+++ b/internal/godoc/parser.go
@@ -48,8 +48,8 @@
 	}
 }
 
-func (c *Corpus) parseFile(fset *token.FileSet, filename string, mode parser.Mode) (*ast.File, error) {
-	src, err := fs.ReadFile(c.fs, toFS(filename))
+func parseFile(fsys fs.FS, fset *token.FileSet, filename string, mode parser.Mode) (*ast.File, error) {
+	src, err := fs.ReadFile(fsys, toFS(filename))
 	if err != nil {
 		return nil, err
 	}
@@ -61,11 +61,11 @@
 	return parser.ParseFile(fset, filename, src, mode)
 }
 
-func (c *Corpus) parseFiles(fset *token.FileSet, relpath string, abspath string, localnames []string) (map[string]*ast.File, error) {
+func parseFiles(fsys fs.FS, fset *token.FileSet, relpath string, abspath string, localnames []string) (map[string]*ast.File, error) {
 	files := make(map[string]*ast.File)
 	for _, f := range localnames {
 		absname := pathpkg.Join(abspath, f)
-		file, err := c.parseFile(fset, absname, parser.ParseComments)
+		file, err := parseFile(fsys, fset, absname, parser.ParseComments)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/godoc/pres.go b/internal/godoc/pres.go
index 688400b..2689c4a 100644
--- a/internal/godoc/pres.go
+++ b/internal/godoc/pres.go
@@ -19,8 +19,6 @@
 
 	mux        *http.ServeMux
 	fileServer http.Handler
-	cmdHandler handlerServer
-	pkgHandler handlerServer
 
 	DirlistHTML,
 	ErrorHTML,
@@ -52,22 +50,12 @@
 		mux:        http.NewServeMux(),
 		fileServer: http.FileServer(http.FS(c.fs)),
 	}
-	p.cmdHandler = handlerServer{
-		p:       p,
-		c:       c,
-		pattern: "/cmd/",
-		fsRoot:  "/src",
+	docs := &docServer{
+		p: p,
+		d: NewDocTree(c.fs),
 	}
-	p.pkgHandler = handlerServer{
-		p:           p,
-		c:           c,
-		pattern:     "/pkg/",
-		stripPrefix: "pkg/",
-		fsRoot:      "/src",
-		exclude:     []string{"/src/cmd"},
-	}
-	p.cmdHandler.registerWithMux(p.mux)
-	p.pkgHandler.registerWithMux(p.mux)
+	p.mux.Handle("/cmd/", docs)
+	p.mux.Handle("/pkg/", docs)
 	p.mux.HandleFunc("/", p.ServeFile)
 	return p
 }
@@ -80,26 +68,6 @@
 	p.mux.ServeHTTP(w, r)
 }
 
-func (p *Presentation) PkgFSRoot() string {
-	return p.pkgHandler.fsRoot
-}
-
-func (p *Presentation) CmdFSRoot() string {
-	return p.cmdHandler.fsRoot
-}
-
-// TODO(bradfitz): move this to be a method on Corpus. Just moving code around for now,
-// but this doesn't feel right.
-func (p *Presentation) GetPkgPageInfo(abspath, relpath string, mode PageInfoMode) *PageInfo {
-	return p.pkgHandler.GetPageInfo(abspath, relpath, mode, "", "")
-}
-
-// TODO(bradfitz): move this to be a method on Corpus. Just moving code around for now,
-// but this doesn't feel right.
-func (p *Presentation) GetCmdPageInfo(abspath, relpath string, mode PageInfoMode) *PageInfo {
-	return p.cmdHandler.GetPageInfo(abspath, relpath, mode, "", "")
-}
-
 func (p *Presentation) googleCN(r *http.Request) bool {
 	return p.GoogleCN != nil && p.GoogleCN(r)
 }
diff --git a/internal/godoc/server.go b/internal/godoc/server.go
index 6640d97..a16a95c 100644
--- a/internal/godoc/server.go
+++ b/internal/godoc/server.go
@@ -10,7 +10,6 @@
 import (
 	"bytes"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"go/ast"
 	"go/build"
@@ -35,19 +34,27 @@
 	"golang.org/x/website/internal/texthtml"
 )
 
-// handlerServer is a migration from an old godoc http Handler type.
-// This should probably merge into something else.
-type handlerServer struct {
-	p           *Presentation
-	c           *Corpus  // copy of p.Corpus
-	pattern     string   // url pattern; e.g. "/pkg/"
-	stripPrefix string   // prefix to strip from import path; e.g. "pkg/"
-	fsRoot      string   // file system root to which the pattern is mapped; e.g. "/src"
-	exclude     []string // file system paths to exclude; e.g. "/src/cmd"
+type DocTree struct {
+	fs   fs.FS
+	root *Directory
 }
 
-func (s *handlerServer) registerWithMux(mux *http.ServeMux) {
-	mux.Handle(s.pattern, s)
+func NewDocTree(fsys fs.FS) *DocTree {
+	src := newDirTree(fsys, token.NewFileSet(), "/src")
+	root := &Directory{
+		Path: "/",
+		Dirs: []*Directory{src},
+	}
+	return &DocTree{
+		fs:   fsys,
+		root: root,
+	}
+}
+
+// docServer serves a package doc tree (/cmd or /pkg).
+type docServer struct {
+	p *Presentation
+	d *DocTree
 }
 
 // GetPageInfo returns the PageInfo for a package directory abspath. If the
@@ -57,8 +64,7 @@
 // directory, PageInfo.PAst and PageInfo.PDoc are nil. If there are no sub-
 // directories, PageInfo.Dirs is nil. If an error occurred, PageInfo.Err is
 // set to the respective error but the error is not logged.
-//
-func (h *handlerServer) GetPageInfo(abspath, relpath string, mode PageInfoMode, goos, goarch string) *PageInfo {
+func (d *DocTree) GetPageInfo(abspath, relpath string, mode PageInfoMode, goos, goarch string) *PageInfo {
 	info := &PageInfo{Dirname: abspath, Mode: mode}
 
 	// Restrict to the package files that would be used when building
@@ -70,11 +76,11 @@
 	ctxt := build.Default
 	ctxt.IsAbsPath = pathpkg.IsAbs
 	ctxt.IsDir = func(path string) bool {
-		fi, err := fs.Stat(h.c.fs, toFS(filepath.ToSlash(path)))
+		fi, err := fs.Stat(d.fs, toFS(filepath.ToSlash(path)))
 		return err == nil && fi.IsDir()
 	}
 	ctxt.ReadDir = func(dir string) ([]os.FileInfo, error) {
-		f, err := fs.ReadDir(h.c.fs, toFS(filepath.ToSlash(dir)))
+		f, err := fs.ReadDir(d.fs, toFS(filepath.ToSlash(dir)))
 		filtered := make([]os.FileInfo, 0, len(f))
 		for _, i := range f {
 			if mode&NoFiltering != 0 || i.Name() != "internal" {
@@ -87,7 +93,7 @@
 		return filtered, err
 	}
 	ctxt.OpenFile = func(name string) (r io.ReadCloser, err error) {
-		data, err := fs.ReadFile(h.c.fs, toFS(filepath.ToSlash(name)))
+		data, err := fs.ReadFile(d.fs, toFS(filepath.ToSlash(name)))
 		if err != nil {
 			return nil, err
 		}
@@ -133,7 +139,7 @@
 	if len(pkgfiles) > 0 {
 		// build package AST
 		fset := token.NewFileSet()
-		files, err := h.c.parseFiles(fset, relpath, abspath, pkgfiles)
+		files, err := parseFiles(d.fs, fset, relpath, abspath, pkgfiles)
 		if err != nil {
 			info.Err = err
 			return info
@@ -170,11 +176,11 @@
 
 			// collect examples
 			testfiles := append(pkginfo.TestGoFiles, pkginfo.XTestGoFiles...)
-			files, err = h.c.parseFiles(fset, relpath, abspath, testfiles)
+			files, err = parseFiles(d.fs, fset, relpath, abspath, testfiles)
 			if err != nil {
 				log.Println("parsing examples:", err)
 			}
-			info.Examples = collectExamples(h.c, pkg, files)
+			info.Examples = collectExamples(pkg, files)
 			info.Bugs = info.PDoc.Notes["BUG"]
 		} else {
 			// show source code
@@ -188,37 +194,13 @@
 		info.IsMain = pkgname == "main"
 	}
 
-	// get directory information, if any
-	var dir *Directory
-	if tree, _ := h.c.fsTree.Get(); tree != nil && tree.(*Directory) != nil {
-		// directory tree is present; lookup respective directory
-		// (may still fail if the file system was updated and the
-		// new directory tree has not yet been computed)
-		dir = tree.(*Directory).lookup(abspath)
-	}
-	if dir == nil {
-		// TODO(agnivade): handle this case better, now since there is no CLI mode.
-		// no directory tree present (happens in command-line mode);
-		// compute 2 levels for this page. The second level is to
-		// get the synopses of sub-directories.
-		// note: cannot use path filter here because in general
-		// it doesn't contain the FSTree path
-		dir = h.c.newDirectory(abspath, 2)
-	}
-	info.Dirs = dir.listing(true, func(path string) bool { return h.includePath(path, mode) })
+	info.Dirs = d.root.lookup(abspath).listing(func(path string) bool { return d.includePath(path, mode) })
 	info.DirFlat = mode&FlatDir != 0
 
 	return info
 }
 
-func (h *handlerServer) includePath(path string, mode PageInfoMode) (r bool) {
-	// if the path is under one of the exclusion paths, don't list.
-	for _, e := range h.exclude {
-		if strings.HasPrefix(path, e) {
-			return false
-		}
-	}
-
+func (d *DocTree) includePath(path string, mode PageInfoMode) (r bool) {
 	// if the path includes 'internal', don't list unless we are in the NoFiltering mode.
 	if mode&NoFiltering != 0 {
 		return true
@@ -239,27 +221,23 @@
 func (s funcsByName) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
 func (s funcsByName) Less(i, j int) bool { return s[i].Name < s[j].Name }
 
-func (h *handlerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (h *docServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	if redirect(w, r) {
 		return
 	}
 
-	relpath := pathpkg.Clean(r.URL.Path[len(h.stripPrefix)+1:])
+	// TODO(rsc): URL should be clean already.
+	relpath := pathpkg.Clean(strings.TrimPrefix(r.URL.Path, "/pkg/"))
 
-	if !h.corpusInitialized() {
-		h.p.ServeError(w, r, relpath, errors.New("Scan is not yet complete. Please retry after a few moments"))
-		return
-	}
-
-	abspath := pathpkg.Join(h.fsRoot, relpath)
+	abspath := pathpkg.Join("/src", relpath)
 	mode := GetPageInfoMode(r.FormValue("m"))
-	if relpath == builtinPkgPath {
+	if relpath == "builtin" {
 		// The fake built-in package contains unexported identifiers,
 		// but we want to show them. Also, disable type association,
 		// since it's not helpful for this fake package (see issue 6645).
 		mode |= NoFiltering | NoTypeAssoc
 	}
-	info := h.GetPageInfo(abspath, relpath, mode, r.FormValue("GOOS"), r.FormValue("GOARCH"))
+	info := h.d.GetPageInfo(abspath, relpath, mode, r.FormValue("GOOS"), r.FormValue("GOARCH"))
 	if info.Err != nil {
 		log.Print(info.Err)
 		h.p.ServeError(w, r, relpath, info.Err)
@@ -316,48 +294,45 @@
 	})
 }
 
-func (h *handlerServer) corpusInitialized() bool {
-	h.c.initMu.RLock()
-	defer h.c.initMu.RUnlock()
-	return h.c.initDone
-}
-
 type PageInfoMode uint
 
 const (
 	NoFiltering PageInfoMode = 1 << iota // do not filter exports
+	FlatDir                              // show directory in a flat (non-indented) manner
 	AllMethods                           // show all embedded methods
 	ShowSource                           // show source code, do not extract documentation
-	FlatDir                              // show directory in a flat (non-indented) manner
 	NoTypeAssoc                          // don't associate consts, vars, and factory functions with types (not exposed via ?m= query parameter, used for package builtin, see issue 6645)
 )
 
 // modeNames defines names for each PageInfoMode flag.
-var modeNames = map[string]PageInfoMode{
-	"all":     NoFiltering,
-	"methods": AllMethods,
-	"src":     ShowSource,
-	"flat":    FlatDir,
+// The order here must match the order of the constants above.
+var modeNames = []string{
+	"all",
+	"flat",
+	"methods",
+	"src",
 }
 
 // generate a query string for persisting PageInfoMode between pages.
-func modeQueryString(mode PageInfoMode) string {
-	if modeNames := mode.names(); len(modeNames) > 0 {
-		return "?m=" + strings.Join(modeNames, ",")
-	}
-	return ""
-}
-
-// alphabetically sorted names of active flags for a PageInfoMode.
-func (m PageInfoMode) names() []string {
-	var names []string
-	for name, mode := range modeNames {
-		if m&mode != 0 {
-			names = append(names, name)
+func (m PageInfoMode) String() string {
+	s := ""
+	for i, name := range modeNames {
+		if m&(1<<i) != 0 && name != "" {
+			if s != "" {
+				s += ","
+			}
+			s += name
 		}
 	}
-	sort.Strings(names)
-	return names
+	return s
+}
+
+func modeQueryString(m PageInfoMode) string {
+	s := m.String()
+	if s == "" {
+		return ""
+	}
+	return "?m=" + s
 }
 
 // GetPageInfoMode computes the PageInfoMode flags by analyzing the request
@@ -365,8 +340,11 @@
 func GetPageInfoMode(text string) PageInfoMode {
 	var mode PageInfoMode
 	for _, k := range strings.Split(text, ",") {
-		if m, found := modeNames[strings.TrimSpace(k)]; found {
-			mode |= m
+		k = strings.TrimSpace(k)
+		for i, name := range modeNames {
+			if name == k {
+				mode |= 1 << i
+			}
 		}
 	}
 	return mode
@@ -402,7 +380,7 @@
 }
 
 // collectExamples collects examples for pkg from testfiles.
-func collectExamples(c *Corpus, pkg *ast.Package, testfiles map[string]*ast.File) []*doc.Example {
+func collectExamples(pkg *ast.Package, testfiles map[string]*ast.File) []*doc.Example {
 	var files []*ast.File
 	for _, f := range testfiles {
 		files = append(files, f)
@@ -414,8 +392,6 @@
 		name := stripExampleSuffix(e.Name)
 		if name == "" || globals[name] {
 			examples = append(examples, e)
-		} else if c.Verbose {
-			log.Printf("skipping example 'Example%s' because '%s' is not a known function or type", e.Name, e.Name)
 		}
 	}
 
diff --git a/internal/godoc/server_test.go b/internal/godoc/server_test.go
index ea3f8fb..934850e 100644
--- a/internal/godoc/server_test.go
+++ b/internal/godoc/server_test.go
@@ -23,19 +23,14 @@
 	packagePath := "github.com/package"
 	packageComment := "main is documented in an ignored .go file"
 
-	c := NewCorpus(fstest.MapFS{
+	fs := fstest.MapFS{
 		"src/" + packagePath + "/ignored.go": {Data: []byte(`// +build ignore
 
 // ` + packageComment + `
 package main`)},
-	})
-	srv := &handlerServer{
-		p: &Presentation{
-			Corpus: c,
-		},
-		c: c,
 	}
-	pInfo := srv.GetPageInfo("/src/"+packagePath, packagePath, NoFiltering, "linux", "amd64")
+	d := NewDocTree(fs)
+	pInfo := d.GetPageInfo("/src/"+packagePath, packagePath, NoFiltering, "linux", "amd64")
 
 	if pInfo.PDoc == nil {
 		t.Error("pInfo.PDoc = nil; want non-nil.")
@@ -57,7 +52,7 @@
 
 func TestIssue5247(t *testing.T) {
 	const packagePath = "example.com/p"
-	c := NewCorpus(fstest.MapFS{
+	fs := fstest.MapFS{
 		"src/" + packagePath + "/p.go": {Data: []byte(`package p
 
 //line notgen.go:3
@@ -65,13 +60,10 @@
 // line 2 should appear
 func F()
 //line foo.go:100`)}, // No newline at end to check corner cases.
-	})
-
-	srv := &handlerServer{
-		p: &Presentation{Corpus: c},
-		c: c,
 	}
-	pInfo := srv.GetPageInfo("/src/"+packagePath, packagePath, 0, "linux", "amd64")
+
+	d := NewDocTree(fs)
+	pInfo := d.GetPageInfo("/src/"+packagePath, packagePath, 0, "linux", "amd64")
 	if got, want := pInfo.PDoc.Funcs[0].Doc, "F doc //line 1 should appear\nline 2 should appear\n"; got != want {
 		t.Errorf("pInfo.PDoc.Funcs[0].Doc = %q; want %q", got, want)
 	}