internal/lsp: enable -mod=readonly in workspace module mode

Now that workspace module mode generates a combined go.sum there are
relatively few blockers to enabling -mod=readonly. Fix them and do it.

This CL is a bit of a grab bag, but the fixes are relatively separate. I
can split it into multiple CLs if desired.

- If module A depends on module B at v1.0.0, the go command will want to
upgrade the workspace module from v0.0.0-goplsworkspace to v1.0.0. To
prevent that, use vN.999999.0 as the base pseudoversion, adjusting v0 to
v1 where appropriate. A few test cases needed updating as a result.
- For old Go versions, sort the generated workspace module and
synthesize a go statement from the maximum go version declared in the
workspace.
- Some regtests need go.sum files created.
- matchErrorToModule created incorrect quick fixes: it would try to
download the top-level module mentioned in the error message, not the
one that actually caused the problem. Now it issues quick fixes for the
lowest-level module.
- TestMultiModuleModDiagnostics accidentally included the same module
in the workspace twice. Fix it, and make that an error.

Fixes golang/go#43346.

Change-Id: I605f762a4d23bedd914241525e64c1b3ecc42150
Reviewed-on: https://go-review.googlesource.com/c/tools/+/287032
Trust: Heschi Kreinick <heschi@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/gopls/internal/regtest/modfile/modfile_test.go b/gopls/internal/regtest/modfile/modfile_test.go
index c7c3d63..86154db 100644
--- a/gopls/internal/regtest/modfile/modfile_test.go
+++ b/gopls/internal/regtest/modfile/modfile_test.go
@@ -701,19 +701,22 @@
 
 	const mod = `
 -- a/go.mod --
-module mod.com
+module moda.com
 
 go 1.14
 
 require (
 	example.com v1.2.3
 )
+-- a/go.sum --
+example.com v1.2.3 h1:Yryq11hF02fEf2JlOS2eph+ICE2/ceevGV3C9dl5V/c=
+example.com v1.2.3/go.mod h1:Y2Rc5rVWjWur0h3pd9aEvK5Pof8YKDANh9gHA2Maujo=
 -- a/main.go --
 package main
 
 func main() {}
 -- b/go.mod --
-module mod.com
+module modb.com
 
 go 1.14
 -- b/main.go --
@@ -730,8 +733,8 @@
 		Modes(Experimental),
 	).Run(t, mod, func(t *testing.T, env *Env) {
 		env.Await(
-			env.DiagnosticAtRegexp("a/go.mod", "example.com v1.2.3"),
-			env.DiagnosticAtRegexp("b/go.mod", "module mod.com"),
+			env.DiagnosticAtRegexpWithMessage("a/go.mod", "example.com v1.2.3", "is not used"),
+			env.DiagnosticAtRegexpWithMessage("b/go.mod", "module modb.com", "not in your go.mod file"),
 		)
 	})
 }
diff --git a/gopls/internal/regtest/workspace/workspace_test.go b/gopls/internal/regtest/workspace/workspace_test.go
index 534174e..cad358e 100644
--- a/gopls/internal/regtest/workspace/workspace_test.go
+++ b/gopls/internal/regtest/workspace/workspace_test.go
@@ -204,7 +204,9 @@
 module a.com
 
 require b.com v1.2.3
-
+-- moda/a/go.sum --
+b.com v1.2.3 h1:tXrlXP0rnjRpKNmkbLYoWBdq0ikb3C3bKK9//moAWBI=
+b.com v1.2.3/go.mod h1:D+J7pfFBZK5vdIdZEFquR586vKKIkqG7Qjw9AxG5BQ8=
 -- moda/a/a.go --
 package a
 
@@ -246,7 +248,9 @@
 module a.com
 
 require b.com v1.2.3
-
+-- moda/a/go.sum --
+b.com v1.2.3 h1:tXrlXP0rnjRpKNmkbLYoWBdq0ikb3C3bKK9//moAWBI=
+b.com v1.2.3/go.mod h1:D+J7pfFBZK5vdIdZEFquR586vKKIkqG7Qjw9AxG5BQ8=
 -- moda/a/a.go --
 package a
 
@@ -311,7 +315,9 @@
 module a.com
 
 require b.com v1.2.3
-
+-- moda/a/go.sum --
+b.com v1.2.3 h1:tXrlXP0rnjRpKNmkbLYoWBdq0ikb3C3bKK9//moAWBI=
+b.com v1.2.3/go.mod h1:D+J7pfFBZK5vdIdZEFquR586vKKIkqG7Qjw9AxG5BQ8=
 -- moda/a/a.go --
 package a
 
@@ -414,7 +420,9 @@
 module a.com
 
 require b.com v1.2.3
-
+-- moda/a/go.sum --
+b.com v1.2.3 h1:tXrlXP0rnjRpKNmkbLYoWBdq0ikb3C3bKK9//moAWBI=
+b.com v1.2.3/go.mod h1:D+J7pfFBZK5vdIdZEFquR586vKKIkqG7Qjw9AxG5BQ8=
 -- moda/a/a.go --
 package a
 
@@ -430,6 +438,9 @@
 module b.com
 
 require example.com v1.2.3
+-- modb/go.sum --
+example.com v1.2.3 h1:Yryq11hF02fEf2JlOS2eph+ICE2/ceevGV3C9dl5V/c=
+example.com v1.2.3/go.mod h1:Y2Rc5rVWjWur0h3pd9aEvK5Pof8YKDANh9gHA2Maujo=
 -- modb/b/b.go --
 package b
 
@@ -477,8 +488,8 @@
 		env.WriteWorkspaceFile("gopls.mod", fmt.Sprintf(`module gopls-workspace
 
 require (
-	a.com v0.0.0-goplsworkspace
-	b.com v0.0.0-goplsworkspace
+	a.com v1.9999999.0-goplsworkspace
+	b.com v1.9999999.0-goplsworkspace
 )
 
 replace a.com => %s/moda/a
@@ -493,7 +504,7 @@
 		var d protocol.PublishDiagnosticsParams
 		env.Await(
 			OnceMet(
-				env.DiagnosticAtRegexp("modb/go.mod", `require example.com v1.2.3`),
+				env.DiagnosticAtRegexpWithMessage("modb/go.mod", `require example.com v1.2.3`, "has not been downloaded"),
 				ReadDiagnostics("modb/go.mod", &d),
 			),
 		)
@@ -648,7 +659,7 @@
 			t.Fatalf("reading expected workspace modfile: %v", err)
 		}
 		got := string(gotb)
-		for _, want := range []string{"a.com v0.0.0-goplsworkspace", "b.com v0.0.0-goplsworkspace"} {
+		for _, want := range []string{"a.com v1.9999999.0-goplsworkspace", "b.com v1.9999999.0-goplsworkspace"} {
 			if !strings.Contains(got, want) {
 				// want before got here, since the go.mod is multi-line
 				t.Fatalf("workspace go.mod missing %q. got:\n%s", want, got)
@@ -659,7 +670,7 @@
 				module gopls-workspace
 
 				require (
-					a.com v0.0.0-goplsworkspace
+					a.com v1.9999999.0-goplsworkspace
 				)
 
 				replace a.com => %s/moda/a
@@ -670,7 +681,7 @@
 			t.Fatalf("reading expected workspace modfile: %v", err)
 		}
 		got = string(gotb)
-		want := "b.com v0.0.0-goplsworkspace"
+		want := "b.com v1.9999999.0-goplsworkspace"
 		if strings.Contains(got, want) {
 			t.Fatalf("workspace go.mod contains unexpected %q. got:\n%s", want, got)
 		}
diff --git a/internal/lsp/cache/mod.go b/internal/lsp/cache/mod.go
index 28bf692..7949918 100644
--- a/internal/lsp/cache/mod.go
+++ b/internal/lsp/cache/mod.go
@@ -10,7 +10,6 @@
 	"path/filepath"
 	"regexp"
 	"strings"
-	"unicode"
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/mod/module"
@@ -217,8 +216,6 @@
 	return mwh.why(ctx, s)
 }
 
-var moduleAtVersionRe = regexp.MustCompile(`^(?P<module>.*)@(?P<version>.*)$`)
-
 // extractGoCommandError tries to parse errors that come from the go command
 // and shape them into go.mod diagnostics.
 func (s *snapshot) extractGoCommandErrors(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, goCmdError string) []*source.Error {
@@ -236,6 +233,8 @@
 	return srcErrs
 }
 
+var moduleVersionInErrorRe = regexp.MustCompile(`[:\s]([+-._~0-9A-Za-z]+)@([+-._~0-9A-Za-z]+)[:\s]`)
+
 // matchErrorToModule attempts to match module version in error messages.
 // Some examples:
 //
@@ -243,99 +242,99 @@
 //    go: github.com/cockroachdb/apd/v2@v2.0.72: reading github.com/cockroachdb/apd/go.mod at revision v2.0.72: unknown revision v2.0.72
 //    go: example.com@v1.2.3 requires\n\trandom.org@v1.2.3: parsing go.mod:\n\tmodule declares its path as: bob.org\n\tbut was required as: random.org
 //
-// We split on colons and whitespace, and attempt to match on something
-// that matches module@version. If we're able to find a match, we try to
-// find anything that matches it in the go.mod file.
+// We search for module@version, starting from the end to find the most
+// relevant module, e.g. random.org@v1.2.3 above. Then we associate the error
+// with a directive that references any of the modules mentioned.
 func (s *snapshot) matchErrorToModule(ctx context.Context, fh source.FileHandle, goCmdError string) *source.Error {
-	var v module.Version
-	fields := strings.FieldsFunc(goCmdError, func(r rune) bool {
-		return unicode.IsSpace(r) || r == ':'
-	})
-	for _, field := range fields {
-		match := moduleAtVersionRe.FindStringSubmatch(field)
-		if match == nil {
-			continue
-		}
-		path, version := match[1], match[2]
-		// Any module versions that come from the workspace module should not
-		// be shown to the user.
-		if source.IsWorkspaceModuleVersion(version) {
-			continue
-		}
-		if err := module.Check(path, version); err != nil {
-			continue
-		}
-		v.Path, v.Version = path, version
-		break
-	}
 	pm, err := s.ParseMod(ctx, fh)
 	if err != nil {
 		return nil
 	}
-	toSourceError := func(line *modfile.Line) *source.Error {
-		rng, err := rangeFromPositions(pm.Mapper, line.Start, line.End)
+
+	var innermost *module.Version
+	var reference *modfile.Line
+	matches := moduleVersionInErrorRe.FindAllStringSubmatch(goCmdError, -1)
+
+outer:
+	for i := len(matches) - 1; i >= 0; i-- {
+		ver := module.Version{Path: matches[i][1], Version: matches[i][2]}
+		// Any module versions that come from the workspace module should not
+		// be shown to the user.
+		if source.IsWorkspaceModuleVersion(ver.Version) {
+			continue
+		}
+		if err := module.Check(ver.Path, ver.Version); err != nil {
+			continue
+		}
+		if innermost == nil {
+			innermost = &ver
+		}
+
+		for _, req := range pm.File.Require {
+			if req.Mod == ver {
+				reference = req.Syntax
+				break outer
+			}
+		}
+		for _, ex := range pm.File.Exclude {
+			if ex.Mod == ver {
+				reference = ex.Syntax
+				break outer
+			}
+		}
+		for _, rep := range pm.File.Replace {
+			if rep.New == ver || rep.Old == ver {
+				reference = rep.Syntax
+				break outer
+			}
+		}
+	}
+
+	if reference == nil {
+		// No match for the module path was found in the go.mod file.
+		// Show the error on the module declaration, if one exists.
+		if pm.File.Module == nil {
+			return nil
+		}
+		reference = pm.File.Module.Syntax
+	}
+
+	rng, err := rangeFromPositions(pm.Mapper, reference.Start, reference.End)
+	if err != nil {
+		return nil
+	}
+	disabledByGOPROXY := strings.Contains(goCmdError, "disabled by GOPROXY=off")
+	shouldAddDep := strings.Contains(goCmdError, "to add it")
+	if innermost != nil && (disabledByGOPROXY || shouldAddDep) {
+		args, err := source.MarshalArgs(fh.URI(), false, []string{fmt.Sprintf("%v@%v", innermost.Path, innermost.Version)})
 		if err != nil {
 			return nil
 		}
-		disabledByGOPROXY := strings.Contains(goCmdError, "disabled by GOPROXY=off")
-		shouldAddDep := strings.Contains(goCmdError, "to add it")
-		if v.Path != "" && (disabledByGOPROXY || shouldAddDep) {
-			args, err := source.MarshalArgs(fh.URI(), false, []string{fmt.Sprintf("%v@%v", v.Path, v.Version)})
-			if err != nil {
-				return nil
-			}
-			msg := goCmdError
-			if disabledByGOPROXY {
-				msg = fmt.Sprintf("%v@%v has not been downloaded", v.Path, v.Version)
-			}
-			return &source.Error{
-				Message: msg,
-				Kind:    source.ListError,
-				Range:   rng,
-				URI:     fh.URI(),
-				SuggestedFixes: []source.SuggestedFix{{
-					Title: fmt.Sprintf("Download %v@%v", v.Path, v.Version),
-					Command: &protocol.Command{
-						Title:     source.CommandAddDependency.Title,
-						Command:   source.CommandAddDependency.ID(),
-						Arguments: args,
-					},
-				}},
-			}
+		msg := goCmdError
+		if disabledByGOPROXY {
+			msg = fmt.Sprintf("%v@%v has not been downloaded", innermost.Path, innermost.Version)
 		}
 		return &source.Error{
-			Message: goCmdError,
+			Message: msg,
+			Kind:    source.ListError,
 			Range:   rng,
 			URI:     fh.URI(),
-			Kind:    source.ListError,
+			SuggestedFixes: []source.SuggestedFix{{
+				Title: fmt.Sprintf("Download %v@%v", innermost.Path, innermost.Version),
+				Command: &protocol.Command{
+					Title:     source.CommandAddDependency.Title,
+					Command:   source.CommandAddDependency.ID(),
+					Arguments: args,
+				},
+			}},
 		}
 	}
-	// Check if there are any require, exclude, or replace statements that
-	// match this module version.
-	for _, req := range pm.File.Require {
-		if req.Mod != v {
-			continue
-		}
-		return toSourceError(req.Syntax)
+	return &source.Error{
+		Message: goCmdError,
+		Range:   rng,
+		URI:     fh.URI(),
+		Kind:    source.ListError,
 	}
-	for _, ex := range pm.File.Exclude {
-		if ex.Mod != v {
-			continue
-		}
-		return toSourceError(ex.Syntax)
-	}
-	for _, rep := range pm.File.Replace {
-		if rep.New != v && rep.Old != v {
-			continue
-		}
-		return toSourceError(rep.Syntax)
-	}
-	// No match for the module path was found in the go.mod file.
-	// Show the error on the module declaration, if one exists.
-	if pm.File.Module == nil {
-		return nil
-	}
-	return toSourceError(pm.File.Module.Syntax)
 }
 
 // errorPositionRe matches errors messages of the form <filename>:<line>:<col>,
diff --git a/internal/lsp/cache/snapshot.go b/internal/lsp/cache/snapshot.go
index 55bb910..7bea58e 100644
--- a/internal/lsp/cache/snapshot.go
+++ b/internal/lsp/cache/snapshot.go
@@ -21,6 +21,7 @@
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/mod/module"
+	"golang.org/x/mod/semver"
 	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/internal/event"
@@ -323,12 +324,8 @@
 	case source.LoadWorkspace, source.Normal:
 		if vendorEnabled {
 			inv.ModFlag = "vendor"
-		} else if s.workspaceMode()&usesWorkspaceModule == 0 && !allowModfileModificationOption {
+		} else if !allowModfileModificationOption {
 			inv.ModFlag = "readonly"
-		} else {
-			// Temporarily allow updates for multi-module workspace mode:
-			// it doesn't create a go.sum at all. golang/go#42509.
-			inv.ModFlag = mutableModFlag
 		}
 	case source.UpdateUserModFile, source.WriteTemporaryModFile:
 		inv.ModFlag = mutableModFlag
@@ -1721,6 +1718,9 @@
 func buildWorkspaceModFile(ctx context.Context, modFiles map[span.URI]struct{}, fs source.FileSource) (*modfile.File, error) {
 	file := &modfile.File{}
 	file.AddModuleStmt("gopls-workspace")
+	// Track the highest Go version, to be set on the workspace module.
+	// Fall back to 1.12 -- old versions insist on having some version.
+	goVersion := "1.12"
 
 	paths := make(map[string]span.URI)
 	for modURI := range modFiles {
@@ -1739,7 +1739,13 @@
 		if file == nil || parsed.Module == nil {
 			return nil, fmt.Errorf("no module declaration for %s", modURI)
 		}
+		if parsed.Go != nil && semver.Compare(goVersion, parsed.Go.Version) < 0 {
+			goVersion = parsed.Go.Version
+		}
 		path := parsed.Module.Mod.Path
+		if _, ok := paths[path]; ok {
+			return nil, fmt.Errorf("found module %q twice in the workspace", path)
+		}
 		paths[path] = modURI
 		// If the module's path includes a major version, we expect it to have
 		// a matching major version.
@@ -1753,6 +1759,9 @@
 			return nil, err
 		}
 	}
+	if goVersion != "" {
+		file.AddGoStmt(goVersion)
+	}
 	// Go back through all of the modules to handle any of their replace
 	// statements.
 	for modURI := range modFiles {
@@ -1792,6 +1801,7 @@
 			}
 		}
 	}
+	file.SortBlocks()
 	return file, nil
 }
 
diff --git a/internal/lsp/source/view.go b/internal/lsp/source/view.go
index c07e31b..7a576a6d 100644
--- a/internal/lsp/source/view.go
+++ b/internal/lsp/source/view.go
@@ -617,12 +617,22 @@
 // sure not to show this version to end users in error messages, to avoid
 // confusion.
 // The major version is not included, as that depends on the module path.
-const workspaceModuleVersion = ".0.0-goplsworkspace"
+//
+// If workspace module A is dependent on workspace module B, we need our
+// nonexistant version to be greater than the version A mentions.
+// Otherwise, the go command will try to update to that version. Use a very
+// high minor version to make that more likely.
+const workspaceModuleVersion = ".9999999.0-goplsworkspace"
 
 func IsWorkspaceModuleVersion(version string) bool {
 	return strings.HasSuffix(version, workspaceModuleVersion)
 }
 
 func WorkspaceModuleVersion(majorVersion string) string {
+	// Use the highest compatible major version to avoid unwanted upgrades.
+	// See the comment on workspaceModuleVersion.
+	if majorVersion == "v0" {
+		majorVersion = "v1"
+	}
 	return majorVersion + workspaceModuleVersion
 }