gopls/mod: show hint for discordant module versions

Sometimes a go.mod file specifies a version of a module that
is different from what Go ends up using. This CL adds
inlay hints to the go.mod file when that happens.

It can happen in a require or replace directive, when some other
module needs a different version.

Accepting the inlay hint will change the specified version to
the one that is used.

There is a new test.

And here is a real-life example:
Create a directory and initialize a go.work file with
go work init ./delve ./tools
git clone https://github.com/go-delve/delve.git
(cd delve; git checkout 4303ae45a8e2996b30d2318f239677a771aef9c1)
git clone https://go.googlesource.com/tools
(cd tools; git checkout 8111118043894a9c0eab886fc370ca123dcf48f1)
Then the delve go.mod file has a hint in the 'require' section.

Fixes: golang/go#57026

Change-Id: Ifb131ed852efad3e2f29524a529f8b23db0d9ee9
Reviewed-on: https://go-review.googlesource.com/c/tools/+/466976
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Peter Weinberger <pjw@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/gopls/internal/lsp/inlay_hint.go b/gopls/internal/lsp/inlay_hint.go
index 6aceecb..7efe98b 100644
--- a/gopls/internal/lsp/inlay_hint.go
+++ b/gopls/internal/lsp/inlay_hint.go
@@ -7,15 +7,22 @@
 import (
 	"context"
 
+	"golang.org/x/tools/gopls/internal/lsp/mod"
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	"golang.org/x/tools/gopls/internal/lsp/source"
 )
 
 func (s *Server) inlayHint(ctx context.Context, params *protocol.InlayHintParams) ([]protocol.InlayHint, error) {
-	snapshot, fh, ok, release, err := s.beginFileRequest(ctx, params.TextDocument.URI, source.Go)
+	snapshot, fh, ok, release, err := s.beginFileRequest(ctx, params.TextDocument.URI, source.UnknownKind)
 	defer release()
 	if !ok {
 		return nil, err
 	}
-	return source.InlayHint(ctx, snapshot, fh, params.Range)
+	switch snapshot.View().FileKind(fh) {
+	case source.Mod:
+		return mod.InlayHint(ctx, snapshot, fh, params.Range)
+	case source.Go:
+		return source.InlayHint(ctx, snapshot, fh, params.Range)
+	}
+	return nil, nil
 }
diff --git a/gopls/internal/lsp/mod/inlayhint.go b/gopls/internal/lsp/mod/inlayhint.go
new file mode 100644
index 0000000..e494cc7
--- /dev/null
+++ b/gopls/internal/lsp/mod/inlayhint.go
@@ -0,0 +1,100 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+package mod
+
+import (
+	"context"
+	"fmt"
+
+	"golang.org/x/mod/modfile"
+	"golang.org/x/tools/gopls/internal/lsp/protocol"
+	"golang.org/x/tools/gopls/internal/lsp/source"
+)
+
+func InlayHint(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, rng protocol.Range) ([]protocol.InlayHint, error) {
+	// Inlay hints are enabled if the client supports them.
+	pm, err := snapshot.ParseMod(ctx, fh)
+	if err != nil {
+		return nil, err
+	}
+	return unexpectedVersion(ctx, snapshot, pm), nil
+}
+
+// Compare the version of the module used in the snapshot's metadata with the
+// version requested by the module, in both cases, taking replaces into account.
+// Produce an InlayHint when the version is the module is not the one usedd.
+func unexpectedVersion(ctx context.Context, snapshot source.Snapshot, pm *source.ParsedModule) []protocol.InlayHint {
+	var ans []protocol.InlayHint
+	if pm.File == nil {
+		return nil
+	}
+	replaces := make(map[string]*modfile.Replace)
+	requires := make(map[string]*modfile.Require)
+	for _, x := range pm.File.Replace {
+		replaces[x.Old.Path] = x
+	}
+	for _, x := range pm.File.Require {
+		requires[x.Mod.Path] = x
+	}
+	am, _ := snapshot.AllMetadata(ctx)
+	seen := make(map[string]bool)
+	for _, meta := range am {
+		if meta == nil || meta.Module == nil || seen[meta.Module.Path] {
+			continue
+		}
+		seen[meta.Module.Path] = true
+		metaMod := meta.Module
+		metaVersion := metaMod.Version
+		if metaMod.Replace != nil {
+			metaVersion = metaMod.Replace.Version
+		}
+		// These versions can be blank, as in gopls/go.mod's local replace
+		if oldrepl, ok := replaces[metaMod.Path]; ok && oldrepl.New.Version != metaVersion {
+			ih := genHint(oldrepl.Syntax, oldrepl.New.Version, metaVersion, pm.Mapper)
+			if ih != nil {
+				ans = append(ans, *ih)
+			}
+		} else if oldreq, ok := requires[metaMod.Path]; ok && oldreq.Mod.Version != metaVersion {
+			// maybe it was replaced:
+			if _, ok := replaces[metaMod.Path]; ok {
+				continue
+			}
+			ih := genHint(oldreq.Syntax, oldreq.Mod.Version, metaVersion, pm.Mapper)
+			if ih != nil {
+				ans = append(ans, *ih)
+			}
+		}
+	}
+	return ans
+}
+
+func genHint(mline *modfile.Line, oldVersion, newVersion string, m *protocol.Mapper) *protocol.InlayHint {
+	x := mline.End.Byte // the parser has removed trailing whitespace and comments (see modfile_test.go)
+	x -= len(mline.Token[len(mline.Token)-1])
+	line, err := m.OffsetPosition(x)
+	if err != nil {
+		return nil
+	}
+	part := protocol.InlayHintLabelPart{
+		Value: newVersion,
+		Tooltip: &protocol.OrPTooltipPLabel{
+			Value: fmt.Sprintf("used metadata's version %s rather than go.mod's version %s", newVersion, oldVersion),
+		},
+	}
+	rng, err := m.OffsetRange(x, mline.End.Byte)
+	if err != nil {
+		return nil
+	}
+	te := protocol.TextEdit{
+		Range:   rng,
+		NewText: newVersion,
+	}
+	return &protocol.InlayHint{
+		Position:     line,
+		Label:        []protocol.InlayHintLabelPart{part},
+		Kind:         protocol.Parameter,
+		PaddingRight: true,
+		TextEdits:    []protocol.TextEdit{te},
+	}
+}
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index e884695..92639d2 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -857,6 +857,7 @@
 	Govulncheck              DiagnosticSource = "govulncheck"
 	TemplateError            DiagnosticSource = "template"
 	WorkFileError            DiagnosticSource = "go.work file"
+	ConsistencyInfo          DiagnosticSource = "consistency"
 )
 
 func AnalyzerErrorKind(name string) DiagnosticSource {
diff --git a/gopls/internal/regtest/modfile/modfile_test.go b/gopls/internal/regtest/modfile/modfile_test.go
index 483118d..268f109 100644
--- a/gopls/internal/regtest/modfile/modfile_test.go
+++ b/gopls/internal/regtest/modfile/modfile_test.go
@@ -1186,3 +1186,78 @@
 		env.SaveBuffer("go.work") // doesn't fail
 	})
 }
+
+func TestInconsistentMod(t *testing.T) {
+	const proxy = `
+-- golang.org/x/mod@v0.7.0/go.mod --
+go 1.20
+module golang.org/x/mod
+-- golang.org/x/mod@v0.7.0/a.go --
+package mod
+func AutoQuote(string) string { return ""}
+-- golang.org/x/mod@v0.9.0/go.mod --
+go 1.20
+module golang.org/x/mod
+-- golang.org/x/mod@v0.9.0/a.go --
+package mod
+func AutoQuote(string) string { return ""}
+`
+	const files = `
+-- go.work --
+go 1.20
+use (
+	./a
+	./b
+)
+
+-- a/go.mod --
+module a.mod.com
+go 1.20
+require golang.org/x/mod v0.6.0 // yyy
+replace golang.org/x/mod v0.6.0 => golang.org/x/mod v0.7.0
+-- a/main.go --
+package main
+import "golang.org/x/mod"
+import "fmt"
+func main() {fmt.Println(mod.AutoQuote(""))}
+
+-- b/go.mod --
+module b.mod.com
+go 1.20
+require golang.org/x/mod v0.9.0 // xxx
+-- b/main.go --
+package aaa
+import "golang.org/x/mod"
+import "fmt"
+func main() {fmt.Println(mod.AutoQuote(""))}
+var A int
+
+-- b/c/go.mod --
+module c.b.mod.com
+go 1.20
+require b.mod.com v0.4.2
+replace b.mod.com => ../
+-- b/c/main.go --
+package main
+import "b.mod.com/aaa"
+import "fmt"
+func main() {fmt.Println(aaa.A)}
+`
+	testenv.NeedsGo1Point(t, 18)
+	WithOptions(
+		ProxyFiles(proxy),
+		Modes(Default),
+	).Run(t, files, func(t *testing.T, env *Env) {
+		env.OpenFile("a/go.mod")
+		ahints := env.InlayHints("a/go.mod")
+		if len(ahints) != 1 {
+			t.Errorf("expected exactly one hint, got %d: %#v", len(ahints), ahints)
+		}
+		env.OpenFile("b/c/go.mod")
+		bhints := env.InlayHints("b/c/go.mod")
+		if len(bhints) != 0 {
+			t.Errorf("expected no hints, got %d: %#v", len(bhints), bhints)
+		}
+	})
+
+}