gopls/internal/golang: RenderPackageDoc: emit anchors for var/const

This CL causes the package rendering to emit an <a id=...> anchor
for each name defined by a var or const declaration.

Also, choose the initial fragment id (e.g. "#Buffer.Len") based
on the selection.

+ Test that anchors are emitted.

Change-Id: Idb4838a6d2741a26cd9dbb5ad31a76d6f811ff26
Reviewed-on: https://go-review.googlesource.com/c/tools/+/575697
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/gopls/doc/release/v0.16.0.md b/gopls/doc/release/v0.16.0.md
index 0fc67ed..04306e8 100644
--- a/gopls/doc/release/v0.16.0.md
+++ b/gopls/doc/release/v0.16.0.md
@@ -10,11 +10,13 @@
 
 Gopls now offers a "View package documentation" code action that opens
 a local web page displaying the generated documentation for the
-current Go package in a form similar to https://pkg.go.dev. Use it to
-preview the marked-up documentation as you prepare API changes, or to
-read the documentation for locally edited packages, even ones that
-have not yet been saved. Reload the page after an edit to see updated
-documentation.
+current Go package in a form similar to https://pkg.go.dev.
+The page will be initially scrolled to the documentation for the
+declaration containing the cursor.
+Use this feature to preview the marked-up documentation as you prepare API
+changes, or to read the documentation for locally edited packages,
+even ones that have not yet been saved. Reload the page after an edit
+to see updated documentation.
 
 TODO: demo in VS Code.
 
diff --git a/gopls/internal/golang/pkgdoc.go b/gopls/internal/golang/pkgdoc.go
index ff76e4a..b3525f8 100644
--- a/gopls/internal/golang/pkgdoc.go
+++ b/gopls/internal/golang/pkgdoc.go
@@ -20,6 +20,8 @@
 // - abbreviate long signatures by replacing parameters 4 onwards with "...".
 // - style the <li> bullets in the index as invisible.
 // - add push notifications such as didChange -> reload.
+// - there appears to be a maximum file size beyond which the
+//   "source.doc" code action is not offered. Remove that.
 
 import (
 	"bytes"
@@ -254,8 +256,6 @@
 						emit(n.Pos())
 						pos = n.End()
 						if url := linkify(n); url != "" {
-							// TODO(adonovan): emit anchors (not hrefs)
-							// for package-level defining idents.
 							fmt.Fprintf(&buf, "<a class='id' href='%s'>%s</a>", url, escape(n.Name))
 						} else {
 							buf.WriteString(escape(n.Name)) // plain
@@ -355,6 +355,11 @@
 	// constants and variables
 	values := func(vals []*doc.Value) {
 		for _, v := range vals {
+			// anchors
+			for _, name := range v.Names {
+				fmt.Fprintf(&buf, "<a id='%s'></a>\n", escape(name))
+			}
+
 			// declaration
 			decl2 := *v.Decl // shallow copy
 			decl2.Doc = nil
diff --git a/gopls/internal/server/command.go b/gopls/internal/server/command.go
index 079c795..a6a3cdd 100644
--- a/gopls/internal/server/command.go
+++ b/gopls/internal/server/command.go
@@ -10,6 +10,7 @@
 	"encoding/json"
 	"errors"
 	"fmt"
+	"go/ast"
 	"io"
 	"os"
 	"path/filepath"
@@ -507,16 +508,16 @@
 		progress: "", // the operation should be fast
 		forURI:   loc.URI,
 	}, func(ctx context.Context, deps commandDeps) error {
-		pkg, _, err := golang.NarrowestPackageForFile(ctx, deps.snapshot, loc.URI)
+		pkg, pgf, err := golang.NarrowestPackageForFile(ctx, deps.snapshot, loc.URI)
 		if err != nil {
 			return err
 		}
 
 		// When invoked from a _test.go file, show the
 		// documentation of the package under test.
-		path := pkg.Metadata().PkgPath
+		pkgpath := pkg.Metadata().PkgPath
 		if pkg.Metadata().ForTest != "" {
-			path = pkg.Metadata().ForTest
+			pkgpath = pkg.Metadata().ForTest
 		}
 
 		// Start web server.
@@ -525,12 +526,59 @@
 			return err
 		}
 
+		// Compute fragment (e.g. "#Buffer.Len") based on
+		// enclosing top-level declaration, if exported.
+		var fragment string
+		pos, err := pgf.PositionPos(loc.Range.Start)
+		if err != nil {
+			return err
+		}
+		path, _ := astutil.PathEnclosingInterval(pgf.File, pos, pos)
+		if n := len(path); n > 1 {
+			switch decl := path[n-2].(type) {
+			case *ast.FuncDecl:
+				if decl.Name.IsExported() {
+					// e.g. "#Println"
+					fragment = decl.Name.Name
+
+					// method?
+					if decl.Recv != nil && len(decl.Recv.List) > 0 {
+						recv := decl.Recv.List[0].Type
+						if star, ok := recv.(*ast.StarExpr); ok {
+							recv = star.X // *N -> N
+						}
+						if id, ok := recv.(*ast.Ident); ok && id.IsExported() {
+							// e.g. "#Buffer.Len"
+							fragment = id.Name + "." + fragment
+						} else {
+							fragment = ""
+						}
+					}
+				}
+
+			case *ast.GenDecl:
+				// path=[... Spec? GenDecl File]
+				for _, spec := range decl.Specs {
+					if n > 2 && spec == path[n-3] {
+						var name *ast.Ident
+						switch spec := spec.(type) {
+						case *ast.ValueSpec:
+							// var, const: use first name
+							name = spec.Names[0]
+						case *ast.TypeSpec:
+							name = spec.Name
+						}
+						if name != nil && name.IsExported() {
+							fragment = name.Name
+						}
+						break
+					}
+				}
+			}
+		}
+
 		// Direct the client to open the /pkg page.
-		//
-		// TODO(adonovan): compute fragment (e.g. "#fmt.Println") based on loc.Range.
-		// (Should it document the selected symbol, or the enclosing decl?)
-		fragment := ""
-		url := web.pkgURL(deps.snapshot.View(), path, fragment)
+		url := web.pkgURL(deps.snapshot.View(), pkgpath, fragment)
 		openClientBrowser(ctx, c.s.client, url)
 
 		return nil
diff --git a/gopls/internal/test/integration/misc/webserver_test.go b/gopls/internal/test/integration/misc/webserver_test.go
index 20fdba4..66cf52a 100644
--- a/gopls/internal/test/integration/misc/webserver_test.go
+++ b/gopls/internal/test/integration/misc/webserver_test.go
@@ -81,6 +81,8 @@
 	// that would corrupt the AST while filtering out unexported
 	// symbols such as b, causing nodeHTML to panic.
 	// Now it doesn't crash.
+	//
+	// We also check cross-reference anchors for all symbols.
 	const files = `
 -- go.mod --
 module example.com
@@ -88,27 +90,52 @@
 -- a/a.go --
 package a
 
-var A, b = 0, 0
+// The '1' suffix is to reduce risk of spurious matches with other HTML substrings.
 
-func ExportedFunc()
-func unexportedFunc()
-type ExportedType int
-type unexportedType int
+var V1, v1 = 0, 0
+const C1, c1 = 0, 0
+
+func F1()
+func f1()
+
+type T1 int
+type t1 int
+
+func (T1) M1() {}
+func (T1) m1() {}
+
+func (t1) M1() {}
+func (t1) m1() {}
 `
 	Run(t, files, func(t *testing.T, env *Env) {
 		uri1 := viewPkgDoc(t, env, "a/a.go")
-		doc1 := get(t, uri1)
+		doc := get(t, uri1)
 		// (Ideally our code rendering would also
 		// eliminate unexported symbols...)
-		// TODO(adonovan): when we emit var anchors,
-		// check that #A exists and #b does not.
-		checkMatch(t, true, doc1, "var A, b = .*0.*0")
+		checkMatch(t, true, doc, "var V1, v1 = .*0.*0")
+		checkMatch(t, true, doc, "const C1, c1 = .*0.*0")
 
 		// Unexported funcs/types/... must still be discarded.
-		checkMatch(t, true, doc1, "ExportedFunc")
-		checkMatch(t, false, doc1, "unexportedFunc")
-		checkMatch(t, true, doc1, "ExportedType")
-		checkMatch(t, false, doc1, "unexportedType")
+		checkMatch(t, true, doc, "F1")
+		checkMatch(t, false, doc, "f1")
+		checkMatch(t, true, doc, "T1")
+		checkMatch(t, false, doc, "t1")
+
+		// Also, check that anchors exist (only) for exported symbols.
+		// exported:
+		checkMatch(t, true, doc, "<a id='V1'")
+		checkMatch(t, true, doc, "<a id='C1'")
+		checkMatch(t, true, doc, "<h3 id='T1'")
+		checkMatch(t, true, doc, "<h3 id='F1'")
+		checkMatch(t, true, doc, "<h4 id='T1.M1'")
+		// unexported:
+		checkMatch(t, false, doc, "<a id='v1'")
+		checkMatch(t, false, doc, "<a id='c1'")
+		checkMatch(t, false, doc, "<h3 id='t1'")
+		checkMatch(t, false, doc, "<h3 id='f1'")
+		checkMatch(t, false, doc, "<h4 id='T1.m1'")
+		checkMatch(t, false, doc, "<h4 id='t1.M1'")
+		checkMatch(t, false, doc, "<h4 id='t1.m1'")
 	})
 }