Skip to content

Commit 607b664

Browse files
committed
gopls/internal/cache: fix two bugs related to workspace packages
Fix two bugs related to workspace packages that contributed to golang/go#65801. The first is that we didn't consider packages with open files to be workspace packages. This was pre-existing behavior, but in the past we simply relied on diagnoseChangedFiles to provide diagnostics for open packages, even though we didn't consider them to be part of the workspace. That led to races and inconsistent behavior: a change in one file would wipe out diagnostics in another, and so on. It's much simpler to say that if the package is open, it is part of the workspace. This leads to consistent behavior, no matter where diagnostics originate. The second bug is related to loading std and cmd. The new workspace package heuristics relied on go/packages.Package.Module to determine if a package is included in the workspace. For std and cmd, this field is nil (golang/go#65816), apparently due to limitations of `go list`. As a result, we were finding no workspace packages for std or cmd. Fix this by falling back to searching for the relevant go.mod file in the filesystem. Unfortunately this required reinstating the lockedSnapshot file source. These bugs revealed a rather large gap in our test coverage for std. Add a test that verifies that we compute std workspace packages. Fixes golang/go#65801 Change-Id: Ic454d4a43e34af10e1224755a09d6c94c728c97d Reviewed-on: https://go-review.googlesource.com/c/tools/+/565475 Reviewed-by: Alan Donovan <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent 51dec25 commit 607b664

File tree

4 files changed

+256
-17
lines changed

4 files changed

+256
-17
lines changed

gopls/internal/cache/load.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"golang.org/x/tools/internal/event/tag"
2828
"golang.org/x/tools/internal/gocommand"
2929
"golang.org/x/tools/internal/packagesinternal"
30+
"golang.org/x/tools/internal/xcontext"
3031
)
3132

3233
var loadID uint64 // atomic identifier for loads
@@ -283,7 +284,7 @@ func (s *Snapshot) load(ctx context.Context, allowNetwork bool, scopes ...loadSc
283284
event.Log(ctx, fmt.Sprintf("%s: updating metadata for %d packages", eventName, len(updates)))
284285

285286
meta := s.meta.Update(updates)
286-
workspacePackages := computeWorkspacePackagesLocked(s, meta)
287+
workspacePackages := computeWorkspacePackagesLocked(ctx, s, meta)
287288
s.meta = meta
288289
s.workspacePackages = workspacePackages
289290
s.resetActivePackagesLocked()
@@ -563,7 +564,7 @@ func computeLoadDiagnostics(ctx context.Context, snapshot *Snapshot, mp *metadat
563564
// heuristics.
564565
//
565566
// s.mu must be held while calling this function.
566-
func isWorkspacePackageLocked(s *Snapshot, meta *metadata.Graph, pkg *metadata.Package) bool {
567+
func isWorkspacePackageLocked(ctx context.Context, s *Snapshot, meta *metadata.Graph, pkg *metadata.Package) bool {
567568
if metadata.IsCommandLineArguments(pkg.ID) {
568569
// Ad-hoc command-line-arguments packages aren't workspace packages.
569570
// With zero-config gopls (golang/go#57979) they should be very rare, as
@@ -587,6 +588,16 @@ func isWorkspacePackageLocked(s *Snapshot, meta *metadata.Graph, pkg *metadata.P
587588
return containsOpenFileLocked(s, pkg)
588589
}
589590

591+
// golang/go#65801: any (non command-line-arguments) open package is a
592+
// workspace package.
593+
//
594+
// Otherwise, we'd display diagnostics for changes in an open package (due to
595+
// the logic of diagnoseChangedFiles), but then erase those diagnostics when
596+
// we do the final diagnostics pass. Diagnostics should be stable.
597+
if containsOpenFileLocked(s, pkg) {
598+
return true
599+
}
600+
590601
// Apply filtering logic.
591602
//
592603
// Workspace packages must contain at least one non-filtered file.
@@ -624,10 +635,24 @@ func isWorkspacePackageLocked(s *Snapshot, meta *metadata.Graph, pkg *metadata.P
624635
// In module mode, a workspace package must be contained in a workspace
625636
// module.
626637
if s.view.moduleMode() {
627-
if pkg.Module == nil {
628-
return false
638+
var modURI protocol.DocumentURI
639+
if pkg.Module != nil {
640+
modURI = protocol.URIFromPath(pkg.Module.GoMod)
641+
} else {
642+
// golang/go#65816: for std and cmd, Module is nil.
643+
// Fall back to an inferior heuristic.
644+
if len(pkg.CompiledGoFiles) == 0 {
645+
return false // need at least one file to guess the go.mod file
646+
}
647+
dir := pkg.CompiledGoFiles[0].Dir()
648+
var err error
649+
modURI, err = findRootPattern(ctx, dir, "go.mod", lockedSnapshot{s})
650+
if err != nil || modURI == "" {
651+
// err != nil implies context cancellation, in which case the result of
652+
// this query does not matter.
653+
return false
654+
}
629655
}
630-
modURI := protocol.URIFromPath(pkg.Module.GoMod)
631656
_, ok := s.view.workspaceModFiles[modURI]
632657
return ok
633658
}
@@ -662,10 +687,14 @@ func containsOpenFileLocked(s *Snapshot, mp *metadata.Package) bool {
662687
// contain intermediate test variants.
663688
//
664689
// s.mu must be held while calling this function.
665-
func computeWorkspacePackagesLocked(s *Snapshot, meta *metadata.Graph) immutable.Map[PackageID, PackagePath] {
690+
func computeWorkspacePackagesLocked(ctx context.Context, s *Snapshot, meta *metadata.Graph) immutable.Map[PackageID, PackagePath] {
691+
// The provided context is used for reading snapshot files, which can only
692+
// fail due to context cancellation. Don't let this happen as it could lead
693+
// to inconsistent results.
694+
ctx = xcontext.Detach(ctx)
666695
workspacePackages := make(map[PackageID]PackagePath)
667696
for _, mp := range meta.Packages {
668-
if !isWorkspacePackageLocked(s, meta, mp) {
697+
if !isWorkspacePackageLocked(ctx, s, meta, mp) {
669698
continue
670699
}
671700

gopls/internal/cache/snapshot.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,14 +1274,26 @@ func (s *Snapshot) ReadFile(ctx context.Context, uri protocol.DocumentURI) (file
12741274
s.mu.Lock()
12751275
defer s.mu.Unlock()
12761276

1277-
fh, ok := s.files.get(uri)
1277+
return lockedSnapshot{s}.ReadFile(ctx, uri)
1278+
}
1279+
1280+
// lockedSnapshot implements the file.Source interface, while holding s.mu.
1281+
//
1282+
// TODO(rfindley): This unfortunate type had been eliminated, but it had to be
1283+
// restored to fix golang/go#65801. We should endeavor to remove it again.
1284+
type lockedSnapshot struct {
1285+
s *Snapshot
1286+
}
1287+
1288+
func (s lockedSnapshot) ReadFile(ctx context.Context, uri protocol.DocumentURI) (file.Handle, error) {
1289+
fh, ok := s.s.files.get(uri)
12781290
if !ok {
12791291
var err error
1280-
fh, err = s.view.fs.ReadFile(ctx, uri)
1292+
fh, err = s.s.view.fs.ReadFile(ctx, uri)
12811293
if err != nil {
12821294
return nil, err
12831295
}
1284-
s.files.set(uri, fh)
1296+
s.s.files.set(uri, fh)
12851297
}
12861298
return fh, nil
12871299
}
@@ -2017,7 +2029,7 @@ func (s *Snapshot) clone(ctx, bgCtx context.Context, changed StateChange, done f
20172029
// Update workspace and active packages, if necessary.
20182030
if result.meta != s.meta || anyFileOpenedOrClosed {
20192031
needsDiagnosis = true
2020-
result.workspacePackages = computeWorkspacePackagesLocked(result, result.meta)
2032+
result.workspacePackages = computeWorkspacePackagesLocked(ctx, result, result.meta)
20212033
result.resetActivePackagesLocked()
20222034
} else {
20232035
result.workspacePackages = s.workspacePackages
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package workspace
6+
7+
import (
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"testing"
13+
14+
. "golang.org/x/tools/gopls/internal/test/integration"
15+
)
16+
17+
func TestStdWorkspace(t *testing.T) {
18+
// This test checks that we actually load workspace packages when opening
19+
// GOROOT.
20+
//
21+
// In golang/go#65801, we failed to do this because go/packages returns nil
22+
// Module for std and cmd.
23+
//
24+
// Because this test loads std as a workspace, it may be slow on smaller
25+
// builders.
26+
if testing.Short() {
27+
t.Skip("skipping with -short: loads GOROOT")
28+
}
29+
30+
// The test also fails on Windows because an absolute path does not match
31+
// (likely a misspelling due to slashes).
32+
// TODO(rfindley): investigate and fix this on windows.
33+
if runtime.GOOS == "windows" {
34+
t.Skip("skipping on windows: fails to to misspelled paths")
35+
}
36+
37+
// Query GOROOT. This is slightly more precise than e.g. runtime.GOROOT, as
38+
// it queries the go command in the environment.
39+
goroot, err := exec.Command("go", "env", "GOROOT").Output()
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
stdDir := filepath.Join(strings.TrimSpace(string(goroot)), "src")
44+
WithOptions(
45+
Modes(Default), // This test may be slow. No reason to run it multiple times.
46+
WorkspaceFolders(stdDir),
47+
).Run(t, "", func(t *testing.T, env *Env) {
48+
// Find parser.ParseFile. Query with `'` to get an exact match.
49+
syms := env.Symbol("'go/parser.ParseFile")
50+
if len(syms) != 1 {
51+
t.Fatalf("got %d symbols, want exactly 1. Symbols:\n%v", len(syms), syms)
52+
}
53+
parserPath := syms[0].Location.URI.Path()
54+
env.OpenFile(parserPath)
55+
56+
// Find the reference to ast.File from the signature of ParseFile. This
57+
// helps guard against matching a comment.
58+
astFile := env.RegexpSearch(parserPath, `func ParseFile\(.*ast\.(File)`)
59+
refs := env.References(astFile)
60+
61+
// If we've successfully loaded workspace packages for std, we should find
62+
// a reference in go/types.
63+
foundGoTypesReference := false
64+
for _, ref := range refs {
65+
if strings.Contains(string(ref.URI), "go/types") {
66+
foundGoTypesReference = true
67+
}
68+
}
69+
if !foundGoTypesReference {
70+
t.Errorf("references(ast.File) did not return a go/types reference. Refs:\n%v", refs)
71+
}
72+
})
73+
}

gopls/internal/test/integration/workspace/workspace_test.go

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ package workspace
77
import (
88
"context"
99
"fmt"
10+
"sort"
1011
"strings"
1112
"testing"
1213

14+
"github.com/google/go-cmp/cmp"
1315
"golang.org/x/tools/gopls/internal/hooks"
1416
"golang.org/x/tools/gopls/internal/protocol"
1517
"golang.org/x/tools/gopls/internal/test/integration/fake"
@@ -1123,16 +1125,139 @@ import (
11231125
env.AfterChange(
11241126
Diagnostics(env.AtRegexp("a/main.go", "V"), WithMessage("not used")),
11251127
)
1128+
// Here, diagnostics are added because of zero-config gopls.
1129+
// In the past, they were added simply due to diagnosing changed files.
1130+
// (see TestClearNonWorkspaceDiagnostics_NoView below for a
1131+
// reimplementation of that test).
1132+
if got, want := len(env.Views()), 2; got != want {
1133+
t.Errorf("after opening a/main.go, got %d views, want %d", got, want)
1134+
}
11261135
env.CloseBuffer("a/main.go")
1136+
env.AfterChange(
1137+
NoDiagnostics(ForFile("a/main.go")),
1138+
)
1139+
if got, want := len(env.Views()), 1; got != want {
1140+
t.Errorf("after closing a/main.go, got %d views, want %d", got, want)
1141+
}
1142+
})
1143+
}
1144+
1145+
// This test is like TestClearNonWorkspaceDiagnostics, but bypasses the
1146+
// zero-config algorithm by opening a nested workspace folder.
1147+
//
1148+
// We should still compute diagnostics correctly for open packages.
1149+
func TestClearNonWorkspaceDiagnostics_NoView(t *testing.T) {
1150+
const ws = `
1151+
-- a/go.mod --
1152+
module example.com/a
1153+
1154+
go 1.18
1155+
1156+
require example.com/b v1.2.3
1157+
1158+
replace example.com/b => ../b
1159+
1160+
-- a/a.go --
1161+
package a
1162+
1163+
import "example.com/b"
1164+
1165+
func _() {
1166+
V := b.B // unused
1167+
}
1168+
1169+
-- b/go.mod --
1170+
module b
1171+
1172+
go 1.18
1173+
1174+
-- b/b.go --
1175+
package b
1176+
1177+
const B = 2
1178+
1179+
func _() {
1180+
var V int // unused
1181+
}
1182+
1183+
-- b/b2.go --
1184+
package b
1185+
1186+
const B2 = B
1187+
1188+
-- c/c.go --
1189+
package main
1190+
1191+
func main() {
1192+
var V int // unused
1193+
}
1194+
`
1195+
WithOptions(
1196+
WorkspaceFolders("a"),
1197+
).Run(t, ws, func(t *testing.T, env *Env) {
1198+
env.OpenFile("a/a.go")
1199+
env.AfterChange(
1200+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1201+
NoDiagnostics(ForFile("b/b.go")),
1202+
NoDiagnostics(ForFile("c/c.go")),
1203+
)
1204+
env.OpenFile("b/b.go")
1205+
env.AfterChange(
1206+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1207+
Diagnostics(env.AtRegexp("b/b.go", "V"), WithMessage("not used")),
1208+
NoDiagnostics(ForFile("c/c.go")),
1209+
)
11271210

1128-
// Make an arbitrary edit because gopls explicitly diagnoses a/main.go
1129-
// whenever it is "changed".
1211+
// Opening b/b.go should not result in a new view, because b is not
1212+
// contained in a workspace folder.
11301213
//
1131-
// TODO(rfindley): it should not be necessary to make another edit here.
1132-
// Gopls should be smart enough to avoid diagnosing a.
1133-
env.RegexpReplace("b/main.go", "package b", "package b // a package")
1214+
// Yet we should get diagnostics for b, because it is open.
1215+
if got, want := len(env.Views()), 1; got != want {
1216+
t.Errorf("after opening b/b.go, got %d views, want %d", got, want)
1217+
}
1218+
env.CloseBuffer("b/b.go")
11341219
env.AfterChange(
1135-
NoDiagnostics(ForFile("a/main.go")),
1220+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1221+
NoDiagnostics(ForFile("b/b.go")),
1222+
NoDiagnostics(ForFile("c/c.go")),
1223+
)
1224+
1225+
// We should get references in the b package.
1226+
bUse := env.RegexpSearch("a/a.go", `b\.(B)`)
1227+
refs := env.References(bUse)
1228+
wantRefs := []string{"a/a.go", "b/b.go", "b/b2.go"}
1229+
var gotRefs []string
1230+
for _, ref := range refs {
1231+
gotRefs = append(gotRefs, env.Sandbox.Workdir.URIToPath(ref.URI))
1232+
}
1233+
sort.Strings(gotRefs)
1234+
if diff := cmp.Diff(wantRefs, gotRefs); diff != "" {
1235+
t.Errorf("references(b.B) mismatch (-want +got)\n%s", diff)
1236+
}
1237+
1238+
// Opening c/c.go should also not result in a new view, yet we should get
1239+
// orphaned file diagnostics.
1240+
env.OpenFile("c/c.go")
1241+
env.AfterChange(
1242+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1243+
NoDiagnostics(ForFile("b/b.go")),
1244+
Diagnostics(env.AtRegexp("c/c.go", "V"), WithMessage("not used")),
1245+
)
1246+
if got, want := len(env.Views()), 1; got != want {
1247+
t.Errorf("after opening b/b.go, got %d views, want %d", got, want)
1248+
}
1249+
1250+
env.CloseBuffer("c/c.go")
1251+
env.AfterChange(
1252+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1253+
NoDiagnostics(ForFile("b/b.go")),
1254+
NoDiagnostics(ForFile("c/c.go")),
1255+
)
1256+
env.CloseBuffer("a/a.go")
1257+
env.AfterChange(
1258+
Diagnostics(env.AtRegexp("a/a.go", "V"), WithMessage("not used")),
1259+
NoDiagnostics(ForFile("b/b.go")),
1260+
NoDiagnostics(ForFile("c/c.go")),
11361261
)
11371262
})
11381263
}

0 commit comments

Comments
 (0)