gopls/internal/golang:  Hover: use internal pkg doc viewer

This change changes the linksInHover option from bool to
a sum of false | true | "gopls".
The "gopls" setting causes Hover(SynopsisDocumentation)
to generate links to gopls' internal web-based doc viewer.

Thanks to Hana for the idea.

+ Test, release note

Fixes golang/go#67949

Change-Id: I384796780436b191a0711c60085d67363d00e5f6
Reviewed-on: https://go-review.googlesource.com/c/tools/+/572037
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Alan Donovan <adonovan@google.com>
diff --git a/gopls/doc/release/v0.16.0.md b/gopls/doc/release/v0.16.0.md
index dcb6005..8060d21 100644
--- a/gopls/doc/release/v0.16.0.md
+++ b/gopls/doc/release/v0.16.0.md
@@ -107,6 +107,11 @@
 
 - TODO: test in vim, neovim, sublime, helix.
 
+The `linksInHover` setting now supports a new value, `"gopls"`,
+that causes documentation links in the the Markdown output
+of the Hover operation to link to gopls' internal doc viewer.
+
+
 ### Browse free symbols
 
 Gopls offers another web-based code action, "Browse free symbols",
diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md
index 83f6280..1a08c56 100644
--- a/gopls/doc/settings.md
+++ b/gopls/doc/settings.md
@@ -439,9 +439,15 @@
 Default: `"pkg.go.dev"`.
 
 <a id='linksInHover'></a>
-### `linksInHover` *bool*
+### `linksInHover` *any*
 
-linksInHover toggles the presence of links to documentation in hover.
+linksInHover controls the presence of documentation links
+in hover markdown.
+
+Its legal values are:
+- `false`, for no links;
+- `true`, for links to the `linkTarget` domain; or
+- `"gopls"`, for links to gopls' internal documentation viewer.
 
 Default: `true`.
 
diff --git a/gopls/internal/cache/errors.go b/gopls/internal/cache/errors.go
index 7aa1e2c..26dc0c4 100644
--- a/gopls/internal/cache/errors.go
+++ b/gopls/internal/cache/errors.go
@@ -388,7 +388,7 @@
 }
 
 // BuildLink constructs a URL with the given target, path, and anchor.
-func BuildLink(target, path, anchor string) string {
+func BuildLink(target, path, anchor string) protocol.URI {
 	link := fmt.Sprintf("https://%s/%s", target, path)
 	if anchor == "" {
 		return link
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index a789483..96ff054 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -154,8 +154,8 @@
 			},
 			{
 				"Name": "linksInHover",
-				"Type": "bool",
-				"Doc": "linksInHover toggles the presence of links to documentation in hover.\n",
+				"Type": "any",
+				"Doc": "linksInHover controls the presence of documentation links\nin hover markdown.\n\nIts legal values are:\n- `false`, for no links;\n- `true`, for links to the `linkTarget` domain; or\n- `\"gopls\"`, for links to gopls' internal documentation viewer.\n",
 				"EnumKeys": {
 					"ValueType": "",
 					"Keys": null
diff --git a/gopls/internal/golang/hover.go b/gopls/internal/golang/hover.go
index ac92fcf..81594a6 100644
--- a/gopls/internal/golang/hover.go
+++ b/gopls/internal/golang/hover.go
@@ -98,7 +98,9 @@
 }
 
 // Hover implements the "textDocument/hover" RPC for Go files.
-func Hover(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, position protocol.Position) (*protocol.Hover, error) {
+//
+// If pkgURL is non-nil, it should be used to generate doc links.
+func Hover(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, position protocol.Position, pkgURL func(path PackagePath, fragment string) protocol.URI) (*protocol.Hover, error) {
 	ctx, done := event.Start(ctx, "golang.Hover")
 	defer done()
 
@@ -109,7 +111,7 @@
 	if h == nil {
 		return nil, nil
 	}
-	hover, err := formatHover(h, snapshot.Options())
+	hover, err := formatHover(h, snapshot.Options(), pkgURL)
 	if err != nil {
 		return nil, err
 	}
@@ -1088,7 +1090,8 @@
 	return pgf, fullPos, nil
 }
 
-func formatHover(h *hoverJSON, options *settings.Options) (string, error) {
+// If pkgURL is non-nil, it should be used to generate doc links.
+func formatHover(h *hoverJSON, options *settings.Options, pkgURL func(path PackagePath, fragment string) protocol.URI) (string, error) {
 	maybeMarkdown := func(s string) string {
 		if s != "" && options.PreferredContentFormat == protocol.Markdown {
 			s = fmt.Sprintf("```go\n%s\n```", strings.Trim(s, "\n"))
@@ -1123,7 +1126,7 @@
 			formatDoc(h, options),
 			maybeMarkdown(h.promotedFields),
 			maybeMarkdown(h.methods),
-			formatLink(h, options),
+			formatLink(h, options, pkgURL),
 		}
 		if h.typeDecl != "" {
 			parts[0] = "" // type: suppress redundant Signature
@@ -1148,18 +1151,30 @@
 	}
 }
 
-func formatLink(h *hoverJSON, options *settings.Options) string {
-	if !options.LinksInHover || options.LinkTarget == "" || h.LinkPath == "" {
+// If pkgURL is non-nil, it should be used to generate doc links.
+func formatLink(h *hoverJSON, options *settings.Options, pkgURL func(path PackagePath, fragment string) protocol.URI) string {
+	if options.LinksInHover == false || h.LinkPath == "" {
 		return ""
 	}
-	plainLink := cache.BuildLink(options.LinkTarget, h.LinkPath, h.LinkAnchor)
+	var url protocol.URI
+	var caption string
+	if pkgURL != nil { // LinksInHover == "gopls"
+		url = pkgURL(PackagePath(h.LinkPath), h.LinkAnchor)
+		caption = "in gopls doc viewer"
+	} else {
+		if options.LinkTarget == "" {
+			return ""
+		}
+		url = cache.BuildLink(options.LinkTarget, h.LinkPath, h.LinkAnchor)
+		caption = "on " + options.LinkTarget
+	}
 	switch options.PreferredContentFormat {
 	case protocol.Markdown:
-		return fmt.Sprintf("[`%s` on %s](%s)", h.SymbolName, options.LinkTarget, plainLink)
+		return fmt.Sprintf("[`%s` %s](%s)", h.SymbolName, caption, url)
 	case protocol.PlainText:
 		return ""
 	default:
-		return plainLink
+		return url
 	}
 }
 
diff --git a/gopls/internal/server/hover.go b/gopls/internal/server/hover.go
index c359825..1470210 100644
--- a/gopls/internal/server/hover.go
+++ b/gopls/internal/server/hover.go
@@ -37,7 +37,18 @@
 	case file.Mod:
 		return mod.Hover(ctx, snapshot, fh, params.Position)
 	case file.Go:
-		return golang.Hover(ctx, snapshot, fh, params.Position)
+		var pkgURL func(path golang.PackagePath, fragment string) protocol.URI
+		if snapshot.Options().LinksInHover == "gopls" {
+			web, err := s.getWeb()
+			if err != nil {
+				event.Error(ctx, "failed to start web server", err)
+			} else {
+				pkgURL = func(path golang.PackagePath, fragment string) protocol.URI {
+					return web.PkgURL(snapshot.View().ID(), path, fragment)
+				}
+			}
+		}
+		return golang.Hover(ctx, snapshot, fh, params.Position, pkgURL)
 	case file.Tmpl:
 		return template.Hover(ctx, snapshot, fh, params.Position)
 	case file.Work:
diff --git a/gopls/internal/settings/settings.go b/gopls/internal/settings/settings.go
index 18674c9..a5e9518 100644
--- a/gopls/internal/settings/settings.go
+++ b/gopls/internal/settings/settings.go
@@ -244,8 +244,14 @@
 	// documentation links in hover.
 	LinkTarget string
 
-	// LinksInHover toggles the presence of links to documentation in hover.
-	LinksInHover bool
+	// LinksInHover controls the presence of documentation links
+	// in hover markdown.
+	//
+	// Its legal values are:
+	// - `false`, for no links;
+	// - `true`, for links to the `linkTarget` domain; or
+	// - `"gopls"`, for links to gopls' internal documentation viewer.
+	LinksInHover any
 }
 
 // Note: FormattingOptions must be comparable with reflect.DeepEqual.
@@ -810,7 +816,13 @@
 		return setString(&o.LinkTarget, value)
 
 	case "linksInHover":
-		return setBool(&o.LinksInHover, value)
+		switch value {
+		case false, true, "gopls":
+			o.LinksInHover = value
+		default:
+			return fmt.Errorf(`invalid value %s; expect false, true, or "gopls"`,
+				value)
+		}
 
 	case "importShortcut":
 		return setEnum(&o.ImportShortcut, value,
diff --git a/gopls/internal/test/integration/misc/hover_test.go b/gopls/internal/test/integration/misc/hover_test.go
index 3853938..d3445dd 100644
--- a/gopls/internal/test/integration/misc/hover_test.go
+++ b/gopls/internal/test/integration/misc/hover_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"fmt"
+	"regexp"
 	"strings"
 	"testing"
 
@@ -514,3 +515,41 @@
 		_, _, _ = env.Editor.Hover(env.Ctx, env.RegexpSearch("p.go", "foo[.]"))
 	})
 }
+
+func TestHoverInternalLinks(t *testing.T) {
+	const src = `
+-- main.go --
+package main
+
+import "errors"
+
+func main() {
+	errors.New("oops")
+}
+`
+	for _, test := range []struct {
+		linksInHover any    // JSON configuration value
+		wantRE       string // pattern to match the Hover Markdown output
+	}{
+		{
+			true, // default: use options.LinkTarget domain
+			regexp.QuoteMeta("[`errors.New` on pkg.go.dev](https://pkg.go.dev/errors#New)"),
+		},
+		{
+			"gopls", // use gopls' internal viewer
+			"\\[`errors.New` in gopls doc viewer\\]\\(http://127.0.0.1:[0-9]+/gopls/[^/]+/pkg/errors\\?view=[0-9]+#New\\)",
+		},
+	} {
+		WithOptions(
+			Settings{"linksInHover": test.linksInHover},
+		).Run(t, src, func(t *testing.T, env *Env) {
+			env.OpenFile("main.go")
+			got, _ := env.Hover(env.RegexpSearch("main.go", "New"))
+			if m, err := regexp.MatchString(test.wantRE, got.Value); err != nil {
+				t.Fatalf("bad regexp in test: %v", err)
+			} else if !m {
+				t.Fatalf("hover output does not match %q; got:\n\n%s", test.wantRE, got.Value)
+			}
+		})
+	}
+}