internal/lsp: try to parse diagnostics out of `go list` errors

This change attempts to parse diagnostics out of `go list` error
messages so that we can present them in a better way to the user. This
approach is definitely tailored to the unknown revision error described
in golang/go#38232, but we can modify it to handle other cases as well.

Fixes golang/go#38232

Change-Id: I0b0a8c39a189a127dc36894a25614535c804a3f0
Reviewed-on: https://go-review.googlesource.com/c/tools/+/242477
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/internal/lsp/cache/load.go b/internal/lsp/cache/load.go
index 1ce17fc..09b8f0c 100644
--- a/internal/lsp/cache/load.go
+++ b/internal/lsp/cache/load.go
@@ -124,7 +124,7 @@
 		event.Log(ctx, "go/packages.Load", tag.Snapshot.Of(s.ID()), tag.Directory.Of(cfg.Dir), tag.Query.Of(query), tag.PackageCount.Of(len(pkgs)))
 	}
 	if len(pkgs) == 0 {
-		return err
+		return errors.Errorf("%v: %w", err, source.PackagesLoadError)
 	}
 
 	for _, pkg := range pkgs {
diff --git a/internal/lsp/diagnostics.go b/internal/lsp/diagnostics.go
index e5a83ad..fa62dae 100644
--- a/internal/lsp/diagnostics.go
+++ b/internal/lsp/diagnostics.go
@@ -16,6 +16,7 @@
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/xcontext"
+	"golang.org/x/xerrors"
 )
 
 type diagnosticKey struct {
@@ -61,13 +62,13 @@
 	var wg sync.WaitGroup
 
 	// Diagnose the go.mod file.
-	reports, err := mod.Diagnostics(ctx, snapshot)
-	if err != nil {
-		event.Error(ctx, "warning: diagnose go.mod", err, tag.Directory.Of(snapshot.View().Folder().Filename()))
-	}
+	reports, modErr := mod.Diagnostics(ctx, snapshot)
 	if ctx.Err() != nil {
 		return nil, nil
 	}
+	if modErr != nil {
+		event.Error(ctx, "warning: diagnose go.mod", modErr, tag.Directory.Of(snapshot.View().Folder().Filename()))
+	}
 	for id, diags := range reports {
 		if id.URI == "" {
 			event.Error(ctx, "missing URI for module diagnostics", fmt.Errorf("empty URI"), tag.Directory.Of(snapshot.View().Folder().Filename()))
@@ -83,7 +84,19 @@
 	// Diagnose all of the packages in the workspace.
 	wsPackages, err := snapshot.WorkspacePackages(ctx)
 	if err != nil {
-		s.handleFatalErrors(ctx, snapshot, err)
+		// Try constructing a more helpful error message out of this error.
+		if s.handleFatalErrors(ctx, snapshot, modErr, err) {
+			return nil, nil
+		}
+		msg := `The code in the workspace failed to compile (see the error message below).
+If you believe this is a mistake, please file an issue: https://github.com/golang/go/issues/new.`
+		event.Error(ctx, msg, err, tag.Snapshot.Of(snapshot.ID()), tag.Directory.Of(snapshot.View().Folder()))
+		if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
+			Type:    protocol.Error,
+			Message: fmt.Sprintf("%s\n%v", msg, err),
+		}); err != nil {
+			event.Error(ctx, "ShowMessage failed", err, tag.Directory.Of(snapshot.View().Folder()))
+		}
 		return nil, nil
 	}
 	var shows *protocol.ShowMessageParams
@@ -229,8 +242,15 @@
 	return reports
 }
 
-func (s *Server) handleFatalErrors(ctx context.Context, snapshot source.Snapshot, err error) {
-	switch err {
+func (s *Server) handleFatalErrors(ctx context.Context, snapshot source.Snapshot, modErr, loadErr error) bool {
+	modURI := snapshot.View().ModFile()
+
+	// We currently only have workarounds for errors associated with modules.
+	if modURI == "" {
+		return false
+	}
+
+	switch loadErr {
 	case source.InconsistentVendoring:
 		item, err := s.client.ShowMessageRequest(ctx, &protocol.ShowMessageRequestParams{
 			Type: protocol.Error,
@@ -240,27 +260,45 @@
 				{Title: "go mod vendor"},
 			},
 		})
-		if item == nil || err != nil {
-			event.Error(ctx, "go mod vendor ShowMessageRequest failed", err, tag.Directory.Of(snapshot.View().Folder()))
-			return
+		// If the user closes the pop-up, don't show them further errors.
+		if item == nil {
+			return true
 		}
-		modURI := snapshot.View().ModFile()
+		if err != nil {
+			event.Error(ctx, "go mod vendor ShowMessageRequest failed", err, tag.Directory.Of(snapshot.View().Folder()))
+			return true
+		}
 		if err := s.directGoModCommand(ctx, protocol.URIFromSpanURI(modURI), "mod", []string{"vendor"}...); err != nil {
 			if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
 				Type:    protocol.Error,
 				Message: fmt.Sprintf(`"go mod vendor" failed with %v`, err),
 			}); err != nil {
-				event.Error(ctx, "ShowMessage failed", err)
+				if err != nil {
+					event.Error(ctx, "go mod vendor ShowMessage failed", err, tag.Directory.Of(snapshot.View().Folder()))
+				}
 			}
 		}
-	default:
-		msg := "failed to load workspace packages, skipping diagnostics"
-		event.Error(ctx, msg, err, tag.Snapshot.Of(snapshot.ID()), tag.Directory.Of(snapshot.View().Folder()))
-		if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
-			Type:    protocol.Error,
-			Message: fmt.Sprintf("%s: %v", msg, err),
-		}); err != nil {
-			event.Error(ctx, "ShowMessage failed", err, tag.Directory.Of(snapshot.View().Folder()))
-		}
+		return true
 	}
+	// If there is a go.mod-related error, as well as a workspace load error,
+	// there is likely an issue with the go.mod file. Try to parse the error
+	// message and create a diagnostic.
+	if modErr == nil {
+		return false
+	}
+	if xerrors.Is(loadErr, source.PackagesLoadError) {
+		fh, err := snapshot.GetFile(ctx, modURI)
+		if err != nil {
+			return false
+		}
+		diag, err := mod.ExtractGoCommandError(ctx, snapshot, fh, loadErr)
+		if err != nil {
+			return false
+		}
+		s.publishReports(ctx, snapshot, map[diagnosticKey][]*source.Diagnostic{
+			{id: fh.Identity()}: {diag},
+		})
+		return true
+	}
+	return false
 }
diff --git a/internal/lsp/mod/diagnostics.go b/internal/lsp/mod/diagnostics.go
index cd36883..e545992 100644
--- a/internal/lsp/mod/diagnostics.go
+++ b/internal/lsp/mod/diagnostics.go
@@ -8,11 +8,17 @@
 
 import (
 	"context"
+	"fmt"
+	"regexp"
+	"strings"
 
+	"golang.org/x/mod/modfile"
+	"golang.org/x/mod/module"
 	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/internal/lsp/debug/tag"
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
+	"golang.org/x/tools/internal/span"
 )
 
 func Diagnostics(ctx context.Context, snapshot source.Snapshot) (map[source.FileIdentity][]*source.Diagnostic, error) {
@@ -32,6 +38,9 @@
 	if err == source.ErrTmpModfileUnsupported {
 		return nil, nil
 	}
+	reports := map[source.FileIdentity][]*source.Diagnostic{
+		fh.Identity(): {},
+	}
 	if err != nil {
 		return nil, err
 	}
@@ -39,9 +48,6 @@
 	if err != nil {
 		return nil, err
 	}
-	reports := map[source.FileIdentity][]*source.Diagnostic{
-		fh.Identity(): {},
-	}
 	for _, e := range diagnostics {
 		diag := &source.Diagnostic{
 			Message: e.Message,
@@ -119,3 +125,90 @@
 func sameDiagnostic(d protocol.Diagnostic, e source.Error) bool {
 	return d.Message == e.Message && protocol.CompareRange(d.Range, e.Range) == 0 && d.Source == e.Category
 }
+
+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 ExtractGoCommandError(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, loadErr error) (*source.Diagnostic, error) {
+	// We try to match module versions in error messages. Some examples:
+	//
+	//  err: exit status 1: stderr: go: example.com@v1.2.2: reading example.com/@v/v1.2.2.mod: no such file or directory
+	//  exit status 1: 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
+	//
+	// We split on colons 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.
+	var v module.Version
+	for _, s := range strings.Split(loadErr.Error(), ":") {
+		s = strings.TrimSpace(s)
+		match := moduleAtVersionRe.FindStringSubmatch(s)
+		if match == nil || len(match) < 3 {
+			continue
+		}
+		v.Path = match[1]
+		v.Version = match[2]
+		if err := module.Check(v.Path, v.Version); err == nil {
+			break
+		}
+	}
+	pmh, err := snapshot.ParseModHandle(ctx, fh)
+	if err != nil {
+		return nil, err
+	}
+	parsed, m, _, err := pmh.Parse(ctx)
+	if err != nil {
+		return nil, err
+	}
+	toDiagnostic := func(line *modfile.Line) (*source.Diagnostic, error) {
+		rng, err := rangeFromPositions(fh.URI(), m, line.Start, line.End)
+		if err != nil {
+			return nil, err
+		}
+		return &source.Diagnostic{
+			Message:  loadErr.Error(),
+			Range:    rng,
+			Severity: protocol.SeverityError,
+		}, nil
+	}
+	// Check if there are any require, exclude, or replace statements that
+	// match this module version.
+	for _, req := range parsed.Require {
+		if req.Mod != v {
+			continue
+		}
+		return toDiagnostic(req.Syntax)
+	}
+	for _, ex := range parsed.Exclude {
+		if ex.Mod != v {
+			continue
+		}
+		return toDiagnostic(ex.Syntax)
+	}
+	for _, rep := range parsed.Replace {
+		if rep.New != v && rep.Old != v {
+			continue
+		}
+		return toDiagnostic(rep.Syntax)
+	}
+	return nil, fmt.Errorf("no diagnostics for %v", loadErr)
+}
+
+func rangeFromPositions(uri span.URI, m *protocol.ColumnMapper, s, e modfile.Position) (protocol.Range, error) {
+	toPoint := func(offset int) (span.Point, error) {
+		l, c, err := m.Converter.ToPosition(offset)
+		if err != nil {
+			return span.Point{}, err
+		}
+		return span.NewPoint(l, c, offset), nil
+	}
+	start, err := toPoint(s.Byte)
+	if err != nil {
+		return protocol.Range{}, err
+	}
+	end, err := toPoint(e.Byte)
+	if err != nil {
+		return protocol.Range{}, err
+	}
+	return m.Range(span.New(uri, start, end))
+}
diff --git a/internal/lsp/regtest/modfile_test.go b/internal/lsp/regtest/modfile_test.go
index b71fdd3..2a27fe8 100644
--- a/internal/lsp/regtest/modfile_test.go
+++ b/internal/lsp/regtest/modfile_test.go
@@ -325,6 +325,8 @@
 
 // Reproduces golang/go#38232.
 func TestUnknownRevision(t *testing.T) {
+	testenv.NeedsGo1Point(t, 14)
+
 	const unknown = `
 -- go.mod --
 module mod.com
@@ -350,7 +352,7 @@
 			)
 			env.OpenFile("go.mod")
 			env.Await(
-				SomeShowMessage("failed to load workspace packages, skipping diagnostics"),
+				env.DiagnosticAtRegexp("go.mod", "example.com v1.2.2"),
 			)
 			env.RegexpReplace("go.mod", "v1.2.2", "v1.2.3")
 			env.Editor.SaveBufferWithoutActions(env.Ctx, "go.mod") // go.mod changes must be on disk
@@ -387,7 +389,7 @@
 			env.RegexpReplace("go.mod", "v1.2.3", "v1.2.2")
 			env.Editor.SaveBufferWithoutActions(env.Ctx, "go.mod") // go.mod changes must be on disk
 			env.Await(
-				SomeShowMessage("failed to load workspace packages, skipping diagnostics"),
+				env.DiagnosticAtRegexp("go.mod", "example.com v1.2.2"),
 			)
 			env.RegexpReplace("go.mod", "v1.2.2", "v1.2.3")
 			env.Editor.SaveBufferWithoutActions(env.Ctx, "go.mod") // go.mod changes must be on disk
diff --git a/internal/lsp/source/view.go b/internal/lsp/source/view.go
index 036da6c..7fa1077 100644
--- a/internal/lsp/source/view.go
+++ b/internal/lsp/source/view.go
@@ -516,4 +516,7 @@
 	return fmt.Sprintf("%s:%s: %s", e.URI, e.Range, e.Message)
 }
 
-var InconsistentVendoring = errors.New("inconsistent vendoring")
+var (
+	InconsistentVendoring = errors.New("inconsistent vendoring")
+	PackagesLoadError     = errors.New("packages.Load error")
+)