internal/lsp: restructure the way we report critical errors

The fragmentation of the critical error reporting is getting in the way
of small fixes. This change adds a GetCriticalError function that reports
critical errors, while ModTidy and WorkspacePackages no longer report
critical errors. Any function that wants to process critical errors
should call AwaitLoaded.

Some other smaller changes are made to account for these changes. One
change is that we may report multiple *source.Errors, with the
assumption that duplicate diagnostics will be caught by the diagnostic
caching. Also, any `go list` error message that ends with "to add it" is
now considered an error that gets the "add dependency" suggested fix.

Fixes golang/go#43338
Fixes golang/go#43307

Change-Id: I056b32e0a0495d9a1dcc64f9f5103649a6102645
Reviewed-on: https://go-review.googlesource.com/c/tools/+/280093
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Rebecca Stambler <rstambler@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
This commit is contained in:
Rebecca Stambler 2020-12-23 17:36:01 -05:00
parent fbbba25c5f
commit 929a8494cf
11 changed files with 248 additions and 180 deletions

View File

@ -937,5 +937,59 @@ require random.com v1.2.3
}
})
})
}
func TestSumUpdateQuickFix(t *testing.T) {
// Error messages changed in 1.16 that changed the diagnostic positions.
testenv.NeedsGo1Point(t, 16)
const mod = `
-- go.mod --
module mod.com
go 1.12
require (
example.com v1.2.3
)
-- go.sum --
-- main.go --
package main
import (
"example.com/blah"
)
func main() {
blah.Hello()
}
`
withOptions(
ProxyFiles(workspaceProxy),
Modes(Singleton),
).run(t, mod, func(t *testing.T, env *Env) {
env.OpenFile("go.mod")
pos := env.RegexpSearch("go.mod", "example.com")
params := &protocol.PublishDiagnosticsParams{}
env.Await(
OnceMet(
env.DiagnosticAtRegexp("go.mod", "example.com"),
ReadDiagnostics("go.mod", params),
),
)
var diagnostic protocol.Diagnostic
for _, d := range params.Diagnostics {
if d.Range.Start.Line == float64(pos.Line) {
diagnostic = d
break
}
}
env.ApplyQuickFixes("go.mod", []protocol.Diagnostic{diagnostic})
const want = `example.com v1.2.3 h1:Yryq11hF02fEf2JlOS2eph+ICE2/ceevGV3C9dl5V/c=
example.com v1.2.3/go.mod h1:Y2Rc5rVWjWur0h3pd9aEvK5Pof8YKDANh9gHA2Maujo=
`
if got := env.ReadWorkspaceFile("go.sum"); got != want {
t.Fatalf("unexpected go.sum contents:\n%s", tests.Diff(t, want, got))
}
})
}

View File

@ -128,13 +128,7 @@ func (s *snapshot) load(ctx context.Context, allowNetwork bool, scopes ...interf
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 {
if err != nil {
// Try to extract the load error into a structured error with
// diagnostics.
if criticalErr := s.parseLoadError(ctx, err); criticalErr != nil {
return criticalErr
}
} else {
if err == nil {
err = fmt.Errorf("no packages returned")
}
return errors.Errorf("%v: %w", err, source.PackagesLoadError)
@ -182,31 +176,9 @@ func (s *snapshot) load(ctx context.Context, allowNetwork bool, scopes ...interf
return nil
}
func (s *snapshot) parseLoadError(ctx context.Context, loadErr error) *source.CriticalError {
if strings.Contains(loadErr.Error(), "cannot find main module") {
return s.WorkspaceLayoutError(ctx)
}
criticalErr := &source.CriticalError{
MainError: loadErr,
}
// Attempt to place diagnostics in the relevant go.mod files, if any.
for _, uri := range s.ModFiles() {
fh, err := s.GetFile(ctx, uri)
if err != nil {
continue
}
srcErr := s.extractGoCommandError(ctx, s, fh, loadErr.Error())
if srcErr == nil {
continue
}
criticalErr.ErrorList = append(criticalErr.ErrorList, srcErr)
}
return criticalErr
}
// 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 {
func (s *snapshot) workspaceLayoutError(ctx context.Context) *source.CriticalError {
if len(s.workspace.getKnownModFiles()) == 0 {
return nil
}

View File

@ -73,7 +73,7 @@ func (s *snapshot) ParseMod(ctx context.Context, modFH source.FileHandle) (*sour
// Attempt to convert the error to a standardized parse error.
var parseErrors []*source.Error
if err != nil {
if parseErr, extractErr := extractErrorWithPosition(ctx, err.Error(), s); extractErr == nil {
if parseErr := extractErrorWithPosition(ctx, err.Error(), s); parseErr != nil {
parseErrors = []*source.Error{parseErr}
}
}
@ -341,29 +341,39 @@ 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) extractGoCommandError(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, goCmdError string) *source.Error {
func (s *snapshot) extractGoCommandErrors(ctx context.Context, snapshot source.Snapshot, fh source.FileHandle, goCmdError string) []*source.Error {
var srcErrs []*source.Error
if srcErr := s.parseModError(ctx, fh, goCmdError); srcErr != nil {
srcErrs = append(srcErrs, srcErr)
}
// If the error message contains a position, use that. Don't pass a file
// handle in, as it might not be the file associated with the error.
if srcErr, err := extractErrorWithPosition(ctx, goCmdError, s); err == nil {
return srcErr
if srcErr := extractErrorWithPosition(ctx, goCmdError, s); srcErr != nil {
srcErrs = append(srcErrs, srcErr)
} else if srcErr := s.matchErrorToModule(ctx, fh, goCmdError); srcErr != nil {
srcErrs = append(srcErrs, srcErr)
}
// We try to match module versions in error messages. Some examples:
//
// example.com@v1.2.2: reading example.com/@v/v1.2.2.mod: no such file or directory
// 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.
return srcErrs
}
// matchErrorToModule attempts to match module version in error messages.
// Some examples:
//
// example.com@v1.2.2: reading example.com/@v/v1.2.2.mod: no such file or directory
// 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.
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 _, s := range fields {
s = strings.TrimSpace(s)
match := moduleAtVersionRe.FindStringSubmatch(s)
if match == nil || len(match) < 3 {
for _, field := range fields {
match := moduleAtVersionRe.FindStringSubmatch(field)
if match == nil {
continue
}
path, version := match[1], match[2]
@ -378,7 +388,7 @@ func (s *snapshot) extractGoCommandError(ctx context.Context, snapshot source.Sn
v.Path, v.Version = path, version
break
}
pm, err := snapshot.ParseMod(ctx, fh)
pm, err := s.ParseMod(ctx, fh)
if err != nil {
return nil
}
@ -387,13 +397,19 @@ func (s *snapshot) extractGoCommandError(ctx context.Context, snapshot source.Sn
if err != nil {
return nil
}
if v.Path != "" && strings.Contains(goCmdError, "disabled by GOPROXY=off") {
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: fmt.Sprintf("%v@%v has not been downloaded", v.Path, v.Version),
Message: msg,
Kind: source.ListError,
Range: rng,
URI: fh.URI(),
@ -411,6 +427,7 @@ func (s *snapshot) extractGoCommandError(ctx context.Context, snapshot source.Sn
Message: goCmdError,
Range: rng,
URI: fh.URI(),
Kind: source.ListError,
}
}
// Check if there are any require, exclude, or replace statements that
@ -449,10 +466,10 @@ var errorPositionRe = regexp.MustCompile(`(?P<pos>.*:([\d]+)(:([\d]+))?): (?P<ms
// information for the given unstructured error. If a file handle is provided,
// the error position will be on that file. This is useful for parse errors,
// where we already know the file with the error.
func extractErrorWithPosition(ctx context.Context, goCmdError string, src source.FileSource) (*source.Error, error) {
func extractErrorWithPosition(ctx context.Context, goCmdError string, src source.FileSource) *source.Error {
matches := errorPositionRe.FindStringSubmatch(strings.TrimSpace(goCmdError))
if len(matches) == 0 {
return nil, fmt.Errorf("error message doesn't contain a position")
return nil
}
var pos, msg string
for i, name := range errorPositionRe.SubexpNames() {
@ -466,11 +483,11 @@ func extractErrorWithPosition(ctx context.Context, goCmdError string, src source
spn := span.Parse(pos)
fh, err := src.GetFile(ctx, spn.URI())
if err != nil {
return nil, err
return nil
}
content, err := fh.Read()
if err != nil {
return nil, err
return nil
}
m := &protocol.ColumnMapper{
URI: spn.URI(),
@ -479,7 +496,7 @@ func extractErrorWithPosition(ctx context.Context, goCmdError string, src source
}
rng, err := m.Range(spn)
if err != nil {
return nil, err
return nil
}
category := GoCommandError
if fh != nil {
@ -490,5 +507,5 @@ func extractErrorWithPosition(ctx context.Context, goCmdError string, src source
Message: msg,
Range: rng,
URI: spn.URI(),
}, nil
}
}

View File

@ -71,11 +71,13 @@ func (s *snapshot) ModTidy(ctx context.Context, pm *source.ParsedModule) (*sourc
return nil, source.ErrNoModOnDisk
}
}
if criticalErr := s.GetCriticalError(ctx); criticalErr != nil {
return &source.TidiedModule{
Errors: criticalErr.ErrorList,
}, nil
}
workspacePkgs, err := s.WorkspacePackages(ctx)
if err != nil {
if tm, ok := s.parseModErrors(ctx, fh, err); ok {
return tm, nil
}
return nil, err
}
importHash, err := hashImports(ctx, workspacePkgs)
@ -149,87 +151,77 @@ func (s *snapshot) ModTidy(ctx context.Context, pm *source.ParsedModule) (*sourc
return mth.tidy(ctx, s)
}
func (s *snapshot) parseModErrors(ctx context.Context, fh source.FileHandle, goCommandErr error) (*source.TidiedModule, bool) {
if goCommandErr == nil {
return nil, false
}
func (s *snapshot) parseModError(ctx context.Context, fh source.FileHandle, errText string) *source.Error {
// Match on common error messages. This is really hacky, but I'm not sure
// of any better way. This can be removed when golang/go#39164 is resolved.
errText := goCommandErr.Error()
isInconsistentVendor := strings.Contains(errText, "inconsistent vendoring")
isGoSumUpdates := strings.Contains(errText, "updates to go.sum needed") || strings.Contains(errText, "missing go.sum entry")
if !isInconsistentVendor && !isGoSumUpdates {
return nil, false
return nil
}
pmf, err := s.ParseMod(ctx, fh)
if err != nil {
return nil, false
return nil
}
if pmf.File.Module == nil || pmf.File.Module.Syntax == nil {
return nil, false
return nil
}
rng, err := rangeFromPositions(pmf.Mapper, pmf.File.Module.Syntax.Start, pmf.File.Module.Syntax.End)
if err != nil {
return nil, false
return nil
}
args, err := source.MarshalArgs(protocol.URIFromSpanURI(fh.URI()))
if err != nil {
return nil, false
return nil
}
switch {
case isInconsistentVendor:
return &source.TidiedModule{
Errors: []*source.Error{{
URI: fh.URI(),
Range: rng,
Kind: source.ListError,
Message: `Inconsistent vendoring detected. Please re-run "go mod vendor".
return &source.Error{
URI: fh.URI(),
Range: rng,
Kind: source.ListError,
Message: `Inconsistent vendoring detected. Please re-run "go mod vendor".
See https://github.com/golang/go/issues/39164 for more detail on this issue.`,
SuggestedFixes: []source.SuggestedFix{{
Title: source.CommandVendor.Title,
Command: &protocol.Command{
Command: source.CommandVendor.ID(),
Title: source.CommandVendor.Title,
Arguments: args,
},
}},
}},
}, true
case isGoSumUpdates:
return &source.TidiedModule{
Errors: []*source.Error{{
URI: fh.URI(),
Range: rng,
Kind: source.ListError,
Message: `go.sum is out of sync with go.mod. Please update it or run "go mod tidy".`,
SuggestedFixes: []source.SuggestedFix{
{
Title: source.CommandTidy.Title,
Command: &protocol.Command{
Command: source.CommandTidy.ID(),
Title: source.CommandTidy.Title,
Arguments: args,
},
},
{
Title: source.CommandUpdateGoSum.Title,
Command: &protocol.Command{
Command: source.CommandUpdateGoSum.ID(),
Title: source.CommandUpdateGoSum.Title,
Arguments: args,
},
},
SuggestedFixes: []source.SuggestedFix{{
Title: source.CommandVendor.Title,
Command: &protocol.Command{
Command: source.CommandVendor.ID(),
Title: source.CommandVendor.Title,
Arguments: args,
},
}},
}, true
}
}
return nil, false
case isGoSumUpdates:
return &source.Error{
URI: fh.URI(),
Range: rng,
Kind: source.ListError,
Message: `go.sum is out of sync with go.mod. Please update it or run "go mod tidy".`,
SuggestedFixes: []source.SuggestedFix{
{
Title: source.CommandTidy.Title,
Command: &protocol.Command{
Command: source.CommandTidy.ID(),
Title: source.CommandTidy.Title,
Arguments: args,
},
},
{
Title: source.CommandUpdateGoSum.Title,
Command: &protocol.Command{
Command: source.CommandUpdateGoSum.ID(),
Title: source.CommandUpdateGoSum.Title,
Arguments: args,
},
},
},
}
}
return nil
}
func hashImports(ctx context.Context, wsPackages []source.Package) (string, error) {

View File

@ -993,6 +993,84 @@ func (s *snapshot) isOpenLocked(uri span.URI) bool {
}
func (s *snapshot) awaitLoaded(ctx context.Context) error {
err := s.awaitLoadedAllErrors(ctx)
// If we still have absolutely no metadata, check if the view failed to
// initialize and return any errors.
// TODO(rstambler): Should we clear the error after we return it?
s.mu.Lock()
defer s.mu.Unlock()
if len(s.metadata) == 0 {
return err
}
return nil
}
func (s *snapshot) GetCriticalError(ctx context.Context) *source.CriticalError {
loadErr := s.awaitLoadedAllErrors(ctx)
// Even if packages didn't fail to load, we still may want to show
// additional warnings.
if loadErr == nil {
wsPkgs, _ := s.WorkspacePackages(ctx)
if msg := shouldShowAdHocPackagesWarning(s, wsPkgs); msg != "" {
return &source.CriticalError{
MainError: fmt.Errorf(msg),
}
}
// Even if workspace packages were returned, there still may be an error
// with the user's workspace layout. Workspace packages that only have the
// ID "command-line-arguments" are usually a symptom of a bad workspace
// configuration.
if containsCommandLineArguments(wsPkgs) {
return s.workspaceLayoutError(ctx)
}
return nil
}
if strings.Contains(loadErr.Error(), "cannot find main module") {
return s.workspaceLayoutError(ctx)
}
criticalErr := &source.CriticalError{
MainError: loadErr,
}
// Attempt to place diagnostics in the relevant go.mod files, if any.
for _, uri := range s.ModFiles() {
fh, err := s.GetFile(ctx, uri)
if err != nil {
continue
}
criticalErr.ErrorList = append(criticalErr.ErrorList, s.extractGoCommandErrors(ctx, s, fh, loadErr.Error())...)
}
return criticalErr
}
const adHocPackagesWarning = `You are outside of a module and outside of $GOPATH/src.
If you are using modules, please open your editor to a directory in your module.
If you believe this warning is incorrect, please file an issue: https://github.com/golang/go/issues/new.`
func shouldShowAdHocPackagesWarning(snapshot source.Snapshot, pkgs []source.Package) string {
if snapshot.ValidBuildConfiguration() {
return ""
}
for _, pkg := range pkgs {
if len(pkg.MissingDependencies()) > 0 {
return adHocPackagesWarning
}
}
return ""
}
func containsCommandLineArguments(pkgs []source.Package) bool {
for _, pkg := range pkgs {
if strings.Contains(pkg.ID(), "command-line-arguments") {
return true
}
}
return false
}
func (s *snapshot) awaitLoadedAllErrors(ctx context.Context) error {
// Do not return results until the snapshot's view has been initialized.
s.AwaitInitialized(ctx)
@ -1002,15 +1080,10 @@ func (s *snapshot) awaitLoaded(ctx context.Context) error {
if err := s.reloadOrphanedFiles(ctx); err != nil {
return err
}
// If we still have absolutely no metadata, check if the view failed to
// initialize and return any errors.
// TODO(rstambler): Should we clear the error after we return it?
s.mu.Lock()
defer s.mu.Unlock()
if len(s.metadata) == 0 {
return s.initializedErr
}
return nil
// TODO(rstambler): Should we be more careful about returning the
// initialization error? Is it possible for the initialization error to be
// corrected without a successful reinitialization?
return s.initializedErr
}
func (s *snapshot) AwaitInitialized(ctx context.Context) {

View File

@ -580,6 +580,10 @@ func (s *snapshot) initialize(ctx context.Context, firstAttempt bool) {
} else {
s.initializedErr = err
}
} else {
// Clear out the initialization error, in case it had been set
// previously.
s.initializedErr = nil
}
})
}

View File

@ -172,9 +172,9 @@ require a.com master
if parseErr == nil {
t.Fatalf("%s: expected an unparseable go.mod file", uri.Filename())
}
srcErr, err := extractErrorWithPosition(ctx, parseErr.Error(), src)
if err != nil {
t.Fatal(err)
srcErr := extractErrorWithPosition(ctx, parseErr.Error(), src)
if srcErr == nil {
t.Fatalf("unable to extract positions from %v", parseErr.Error())
}
if srcErr.URI != uri {
t.Errorf("unexpected URI: got %s, wanted %s", srcErr.URI, uri)

View File

@ -178,29 +178,13 @@ func (s *Server) diagnose(ctx context.Context, snapshot source.Snapshot, forceAn
if s.shouldIgnoreError(ctx, snapshot, err) {
return
}
// Even if packages didn't fail to load, we still may want to show
// additional warnings.
if err == nil {
if msg := shouldShowAdHocPackagesWarning(snapshot, wsPkgs); msg != "" {
err = fmt.Errorf(msg)
}
}
// Even if workspace packages were returned, there still may be an error
// with the user's workspace layout. Workspace packages that only have the
// ID "command-line-arguments" are usually a symptom of a bad workspace
// configuration.
if containsCommandLineArguments(wsPkgs) {
if criticalErr := snapshot.WorkspaceLayoutError(ctx); criticalErr != nil {
err = criticalErr
}
}
criticalErr := snapshot.GetCriticalError(ctx)
// Show the error as a progress error report so that it appears in the
// status bar. If a client doesn't support progress reports, the error
// will still be shown as a ShowMessage. If there is no error, any running
// error progress reports will be closed.
s.showCriticalErrorStatus(ctx, snapshot, err)
s.showCriticalErrorStatus(ctx, snapshot, criticalErr)
// If there are no workspace packages, there is nothing to diagnose and
// there are no orphaned files.
@ -337,27 +321,11 @@ func (s *Server) clearDiagnosticSource(dsource diagnosticSource) {
}
}
const adHocPackagesWarning = `You are outside of a module and outside of $GOPATH/src.
If you are using modules, please open your editor to a directory in your module.
If you believe this warning is incorrect, please file an issue: https://github.com/golang/go/issues/new.`
func shouldShowAdHocPackagesWarning(snapshot source.Snapshot, pkgs []source.Package) string {
if snapshot.ValidBuildConfiguration() {
return ""
}
for _, pkg := range pkgs {
if len(pkg.MissingDependencies()) > 0 {
return adHocPackagesWarning
}
}
return ""
}
const WorkspaceLoadFailure = "Error loading workspace"
// showCriticalErrorStatus shows the error as a progress report.
// If the error is nil, it clears any existing error progress report.
func (s *Server) showCriticalErrorStatus(ctx context.Context, snapshot source.Snapshot, err error) {
func (s *Server) showCriticalErrorStatus(ctx context.Context, snapshot source.Snapshot, err *source.CriticalError) {
s.criticalErrorStatusMu.Lock()
defer s.criticalErrorStatusMu.Unlock()
@ -578,12 +546,3 @@ func (s *Server) shouldIgnoreError(ctx context.Context, snapshot source.Snapshot
})
return !hasGo
}
func containsCommandLineArguments(pkgs []source.Package) bool {
for _, pkg := range pkgs {
if strings.Contains(pkg.ID(), "command-line-arguments") {
return true
}
}
return false
}

View File

@ -757,6 +757,10 @@ func (e *Editor) codeAction(ctx context.Context, path string, rng *protocol.Rang
return err
}
}
// Some commands may edit files on disk.
if err := e.sandbox.Workdir.CheckForFileChanges(ctx); err != nil {
return err
}
}
return nil
}

View File

@ -13,7 +13,6 @@ import (
"golang.org/x/tools/internal/lsp/debug/tag"
"golang.org/x/tools/internal/lsp/protocol"
"golang.org/x/tools/internal/lsp/source"
errors "golang.org/x/xerrors"
)
func Diagnostics(ctx context.Context, snapshot source.Snapshot) (map[source.VersionedFileIdentity][]*source.Diagnostic, error) {
@ -66,10 +65,6 @@ func ErrorsForMod(ctx context.Context, snapshot source.Snapshot, fh source.FileH
return nil, nil
}
if err != nil {
// Some error messages can also be displayed as diagnostics.
if criticalErr := (*source.CriticalError)(nil); errors.As(err, &criticalErr) {
return criticalErr.ErrorList, nil
}
return nil, err
}
return tidied.Errors, nil

View File

@ -147,10 +147,8 @@ type Snapshot interface {
// WorkspacePackages returns the snapshot's top-level packages.
WorkspacePackages(ctx context.Context) ([]Package, error)
// WorkspaceLayoutError reports whether there might be any problems with
// the user's workspace configuration, which would cause bad or incorrect
// diagnostics.
WorkspaceLayoutError(ctx context.Context) *CriticalError
// GetCriticalError returns any critical errors in the workspace.
GetCriticalError(ctx context.Context) *CriticalError
}
// PackageFilter sets how a package is filtered out from a set of packages