internal/lsp/cache: extract module load errors when go.work is used

When using go.work, the go command packs module-specific errors into
synthetic results corresponding to the module query ("modulepath/...").

Extract these for use in diagnostics, by packing them into a new
moduleErrorMap error type.

Fixes golang/go#50862

Change-Id: I68bf9cf4e9ebf4595acdc4da0347ed97171d637f
Reviewed-on: https://go-review.googlesource.com/c/tools/+/405259
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Robert Findley <rfindley@google.com>
diff --git a/gopls/internal/regtest/workspace/workspace_test.go b/gopls/internal/regtest/workspace/workspace_test.go
index b283cfa..5e5bcd1 100644
--- a/gopls/internal/regtest/workspace/workspace_test.go
+++ b/gopls/internal/regtest/workspace/workspace_test.go
@@ -705,7 +705,7 @@
 	WithOptions(
 		ProxyFiles(workspaceModuleProxy),
 	).Run(t, multiModule, func(t *testing.T, env *Env) {
-		// Initially, the gopls.mod should cause only the a.com module to be
+		// Initially, the go.work should cause only the a.com module to be
 		// loaded. Validate this by jumping to a definition in b.com and ensuring
 		// that we go to the module cache.
 		env.OpenFile("moda/a/a.go")
@@ -726,7 +726,7 @@
 			t.Fatal(err)
 		}
 
-		// Now, modify the gopls.mod file on disk to activate the b.com module in
+		// Now, modify the go.work file on disk to activate the b.com module in
 		// the workspace.
 		env.WriteWorkspaceFile("go.work", `
 go 1.17
@@ -742,26 +742,22 @@
 		env.OpenFile("modb/go.mod")
 		env.Await(env.DoneWithOpen())
 
-		//  TODO(golang/go#50862): the go command drops error messages when using
-		//  go.work, so we need to build our go.mod diagnostics in a different way.
-		if testenv.Go1Point() < 18 {
-			var d protocol.PublishDiagnosticsParams
-			env.Await(
-				OnceMet(
-					env.DiagnosticAtRegexpWithMessage("modb/go.mod", `require example.com v1.2.3`, "has not been downloaded"),
-					ReadDiagnostics("modb/go.mod", &d),
-				),
-			)
-			env.ApplyQuickFixes("modb/go.mod", d.Diagnostics)
-			env.Await(env.DiagnosticAtRegexp("modb/b/b.go", "x"))
-		}
+		var d protocol.PublishDiagnosticsParams
+		env.Await(
+			OnceMet(
+				env.DiagnosticAtRegexpWithMessage("modb/go.mod", `require example.com v1.2.3`, "has not been downloaded"),
+				ReadDiagnostics("modb/go.mod", &d),
+			),
+		)
+		env.ApplyQuickFixes("modb/go.mod", d.Diagnostics)
+		env.Await(env.DiagnosticAtRegexp("modb/b/b.go", "x"))
 
 		// Jumping to definition should now go to b.com in the workspace.
 		if err := checkHelloLocation("modb/b/b.go"); err != nil {
 			t.Fatal(err)
 		}
 
-		// Now, let's modify the gopls.mod *overlay* (not on disk), and verify that
+		// Now, let's modify the go.work *overlay* (not on disk), and verify that
 		// this change is only picked up once it is saved.
 		env.OpenFile("go.work")
 		env.Await(env.DoneWithOpen())
@@ -771,22 +767,26 @@
 	./moda/a
 )`)
 
-		// Editing the gopls.mod removes modb from the workspace modules, and so
-		// should clear outstanding diagnostics...
-		env.Await(OnceMet(
-			env.DoneWithChange(),
-			EmptyOrNoDiagnostics("modb/go.mod"),
-		))
-		// ...but does not yet cause a workspace reload, so we should still jump to modb.
+		// Simply modifying the go.work file does not cause a reload, so we should
+		// still jump within the workspace.
+		//
+		// TODO: should editing the go.work above cause modb diagnostics to be
+		// suppressed?
+		env.Await(env.DoneWithChange())
 		if err := checkHelloLocation("modb/b/b.go"); err != nil {
 			t.Fatal(err)
 		}
+
 		// Saving should reload the workspace.
 		env.SaveBufferWithoutActions("go.work")
 		if err := checkHelloLocation("b.com@v1.2.3/b/b.go"); err != nil {
 			t.Fatal(err)
 		}
 
+		// This fails if guarded with a OnceMet(DoneWithSave(), ...), because it is
+		// debounced (and therefore not synchronous with the change).
+		env.Await(EmptyOrNoDiagnostics("modb/go.mod"))
+
 		// Test Formatting.
 		env.SetBufferContent("go.work", `go 1.18
   use      (
diff --git a/internal/lsp/cache/load.go b/internal/lsp/cache/load.go
index f91c961..5341d5c 100644
--- a/internal/lsp/cache/load.go
+++ b/internal/lsp/cache/load.go
@@ -5,6 +5,7 @@
 package cache
 
 import (
+	"bytes"
 	"context"
 	"crypto/sha256"
 	"errors"
@@ -29,9 +30,16 @@
 
 // load calls packages.Load for the given scopes, updating package metadata,
 // import graph, and mapped files with the result.
+//
+// The resulting error may wrap the moduleErrorMap error type, representing
+// errors associated with specific modules.
 func (s *snapshot) load(ctx context.Context, allowNetwork bool, scopes ...interface{}) (err error) {
 	var query []string
 	var containsDir bool // for logging
+
+	// Keep track of module query -> module path so that we can later correlate query
+	// errors with errors.
+	moduleQueries := make(map[string]string)
 	for _, scope := range scopes {
 		if !s.shouldLoad(scope) {
 			continue
@@ -66,7 +74,9 @@
 			case "std", "cmd":
 				query = append(query, string(scope))
 			default:
-				query = append(query, fmt.Sprintf("%s/...", scope))
+				modQuery := fmt.Sprintf("%s/...", scope)
+				query = append(query, modQuery)
+				moduleQueries[modQuery] = string(scope)
 			}
 		case viewLoadScope:
 			// If we are outside of GOPATH, a module, or some other known
@@ -137,7 +147,24 @@
 		}
 		return fmt.Errorf("%v: %w", err, source.PackagesLoadError)
 	}
+
+	moduleErrs := make(map[string][]packages.Error) // module path -> errors
 	for _, pkg := range pkgs {
+		// The Go command returns synthetic list results for module queries that
+		// encountered module errors.
+		//
+		// For example, given a module path a.mod, we'll query for "a.mod/..." and
+		// the go command will return a package named "a.mod/..." holding this
+		// error. Save it for later interpretation.
+		//
+		// See golang/go#50862 for more details.
+		if mod := moduleQueries[pkg.PkgPath]; mod != "" { // a synthetic result for the unloadable module
+			if len(pkg.Errors) > 0 {
+				moduleErrs[mod] = pkg.Errors
+			}
+			continue
+		}
+
 		if !containsDir || s.view.Options().VerboseOutput {
 			event.Log(ctx, "go/packages.Load",
 				tag.Snapshot.Of(s.ID()),
@@ -180,9 +207,35 @@
 	// Rebuild the import graph when the metadata is updated.
 	s.clearAndRebuildImportGraph()
 
+	if len(moduleErrs) > 0 {
+		return &moduleErrorMap{moduleErrs}
+	}
+
 	return nil
 }
 
+type moduleErrorMap struct {
+	errs map[string][]packages.Error // module path -> errors
+}
+
+func (m *moduleErrorMap) Error() string {
+	var paths []string // sort for stability
+	for path, errs := range m.errs {
+		if len(errs) > 0 { // should always be true, but be cautious
+			paths = append(paths, path)
+		}
+	}
+	sort.Strings(paths)
+
+	var buf bytes.Buffer
+	fmt.Fprintf(&buf, "%d modules have errors:\n", len(paths))
+	for _, path := range paths {
+		fmt.Fprintf(&buf, "\t%s", m.errs[path][0].Msg)
+	}
+
+	return buf.String()
+}
+
 // workspaceLayoutErrors returns a diagnostic for every open file, as well as
 // an error message if there are no open files.
 func (s *snapshot) workspaceLayoutError(ctx context.Context) *source.CriticalError {
diff --git a/internal/lsp/cache/mod.go b/internal/lsp/cache/mod.go
index 03fdcbf..5ac199b 100644
--- a/internal/lsp/cache/mod.go
+++ b/internal/lsp/cache/mod.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"path/filepath"
 	"regexp"
@@ -297,36 +298,68 @@
 
 // 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, goCmdError string) ([]*source.Diagnostic, error) {
-	diagLocations := map[*source.ParsedModule]span.Span{}
-	backupDiagLocations := map[*source.ParsedModule]span.Span{}
-
-	// The go command emits parse errors for completely invalid go.mod files.
-	// Those are reported by our own diagnostics and can be ignored here.
-	// As of writing, we are not aware of any other errors that include
-	// file/position information, so don't even try to find it.
-	if strings.Contains(goCmdError, "errors parsing go.mod") {
-		return nil, nil
+// TODO: rename this to 'load errors'
+func (s *snapshot) extractGoCommandErrors(ctx context.Context, goCmdError error) []*source.Diagnostic {
+	if goCmdError == nil {
+		return nil
 	}
 
+	type locatedErr struct {
+		spn span.Span
+		msg string
+	}
+	diagLocations := map[*source.ParsedModule]locatedErr{}
+	backupDiagLocations := map[*source.ParsedModule]locatedErr{}
+
+	// If moduleErrs is non-nil, go command errors are scoped to specific
+	// modules.
+	var moduleErrs *moduleErrorMap
+	_ = errors.As(goCmdError, &moduleErrs)
+
 	// Match the error against all the mod files in the workspace.
 	for _, uri := range s.ModFiles() {
 		fh, err := s.GetFile(ctx, uri)
 		if err != nil {
-			return nil, err
+			event.Error(ctx, "getting modfile for Go command error", err)
+			continue
 		}
 		pm, err := s.ParseMod(ctx, fh)
 		if err != nil {
-			return nil, err
+			// Parsing errors are reported elsewhere
+			return nil
 		}
-		spn, found, err := s.matchErrorToModule(ctx, pm, goCmdError)
-		if err != nil {
-			return nil, err
-		}
-		if found {
-			diagLocations[pm] = spn
+		var msgs []string // error messages to consider
+		if moduleErrs != nil {
+			if pm.File.Module != nil {
+				for _, mes := range moduleErrs.errs[pm.File.Module.Mod.Path] {
+					msgs = append(msgs, mes.Error())
+				}
+			}
 		} else {
-			backupDiagLocations[pm] = spn
+			msgs = append(msgs, goCmdError.Error())
+		}
+		for _, msg := range msgs {
+			if strings.Contains(goCmdError.Error(), "errors parsing go.mod") {
+				// The go command emits parse errors for completely invalid go.mod files.
+				// Those are reported by our own diagnostics and can be ignored here.
+				// As of writing, we are not aware of any other errors that include
+				// file/position information, so don't even try to find it.
+				continue
+			}
+			spn, found, err := s.matchErrorToModule(ctx, pm, msg)
+			if err != nil {
+				event.Error(ctx, "matching error to module", err)
+				continue
+			}
+			le := locatedErr{
+				spn: spn,
+				msg: msg,
+			}
+			if found {
+				diagLocations[pm] = le
+			} else {
+				backupDiagLocations[pm] = le
+			}
 		}
 	}
 
@@ -336,14 +369,15 @@
 	}
 
 	var srcErrs []*source.Diagnostic
-	for pm, spn := range diagLocations {
-		diag, err := s.goCommandDiagnostic(pm, spn, goCmdError)
+	for pm, le := range diagLocations {
+		diag, err := s.goCommandDiagnostic(pm, le.spn, le.msg)
 		if err != nil {
-			return nil, err
+			event.Error(ctx, "building go command diagnostic", err)
+			continue
 		}
 		srcErrs = append(srcErrs, diag)
 	}
-	return srcErrs, nil
+	return srcErrs
 }
 
 var moduleVersionInErrorRe = regexp.MustCompile(`[:\s]([+-._~0-9A-Za-z]+)@([+-._~0-9A-Za-z]+)[:\s]`)
diff --git a/internal/lsp/cache/snapshot.go b/internal/lsp/cache/snapshot.go
index c7cd337..b4fc69f 100644
--- a/internal/lsp/cache/snapshot.go
+++ b/internal/lsp/cache/snapshot.go
@@ -1485,14 +1485,14 @@
 	}
 
 	if err := s.reloadWorkspace(ctx); err != nil {
-		diags, _ := s.extractGoCommandErrors(ctx, err.Error())
+		diags := s.extractGoCommandErrors(ctx, err)
 		return &source.CriticalError{
 			MainError: err,
 			DiagList:  diags,
 		}
 	}
 	if err := s.reloadOrphanedFiles(ctx); err != nil {
-		diags, _ := s.extractGoCommandErrors(ctx, err.Error())
+		diags := s.extractGoCommandErrors(ctx, err)
 		return &source.CriticalError{
 			MainError: err,
 			DiagList:  diags,
diff --git a/internal/lsp/cache/view.go b/internal/lsp/cache/view.go
index 17bdc40..b0390a3f 100644
--- a/internal/lsp/cache/view.go
+++ b/internal/lsp/cache/view.go
@@ -671,7 +671,7 @@
 		}
 	case err != nil:
 		event.Error(ctx, "initial workspace load failed", err)
-		extractedDiags, _ := s.extractGoCommandErrors(ctx, err.Error())
+		extractedDiags := s.extractGoCommandErrors(ctx, err)
 		criticalErr = &source.CriticalError{
 			MainError: err,
 			DiagList:  append(modDiagnostics, extractedDiags...),