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) + } + }) + +}