all: merge master (92d58ea) into gopls-release-branch.0.9

Also add a replace directive to gopls/go.mod, and re-tidy.

Merge List:

+ 2022-08-08 92d58ea4e internal/lsp/cache: register a file watcher for explicit GOWORK values
+ 2022-08-08 98aef7799 internal/lsp/cache: track explicit go.work files outside the workspace
+ 2022-08-08 fff6d6d39 internal/lsp: update the broken workspace message to mention go.work
+ 2022-08-08 9b6085242 gopls/internal/regtest: move TestMultipleModules_Warning to ./workspace
+ 2022-08-05 06d96ee8f gopls/internal/regtest/bench: add a test for completion following edits

Change-Id: I78f2b3552725d0317ec1dd62196558997f70fe29
diff --git a/gopls/go.mod b/gopls/go.mod
index d68c4a2..b3fc2db 100644
--- a/gopls/go.mod
+++ b/gopls/go.mod
@@ -23,3 +23,5 @@
 	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
 	golang.org/x/text v0.3.7 // indirect
 )
+
+replace golang.org/x/tools => ../
diff --git a/gopls/go.sum b/gopls/go.sum
index 9696486..ecd3f4d 100644
--- a/gopls/go.sum
+++ b/gopls/go.sum
@@ -41,38 +41,20 @@
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
 golang.org/x/exp/typeparams v0.0.0-20220722155223-a9213eeb770e h1:7Xs2YCOpMlNqSQSmrrnhlzBXIE/bpMecZplbLePTJvE=
 golang.org/x/exp/typeparams v0.0.0-20220722155223-a9213eeb770e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211213223007-03aa0b5f6827/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -82,23 +64,11 @@
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
-golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
-golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
-golang.org/x/tools v0.1.11-0.20220523181440-ccb10502d1a5/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
-golang.org/x/tools v0.1.13-0.20220805154628-fad228792ce9 h1:wnZ6J3Vs1RS/EjUI71D435/4VhO7j/XKzGYkQy4Rfp0=
-golang.org/x/tools v0.1.13-0.20220805154628-fad228792ce9/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/vuln v0.0.0-20220725105440-4151a5aca1df h1:BkeW9/QJhcigekDUPS9N9bIb0v7gPKKmLYeczVAqr2s=
 golang.org/x/vuln v0.0.0-20220725105440-4151a5aca1df/go.mod h1:UZshlUPxXeGUM9I14UOawXQg6yosDE9cr1vKY/DzgWo=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/gopls/internal/regtest/bench/bench_test.go b/gopls/internal/regtest/bench/bench_test.go
index a3780f0..cfe4db6 100644
--- a/gopls/internal/regtest/bench/bench_test.go
+++ b/gopls/internal/regtest/bench/bench_test.go
@@ -133,7 +133,7 @@
 		dir := benchmarkDir()
 
 		var err error
-		sandbox, editor, awaiter, err = connectEditor(dir)
+		sandbox, editor, awaiter, err = connectEditor(dir, fake.EditorConfig{})
 		if err != nil {
 			log.Fatalf("connecting editor: %v", err)
 		}
@@ -154,7 +154,7 @@
 
 // connectEditor connects a fake editor session in the given dir, using the
 // given editor config.
-func connectEditor(dir string) (*fake.Sandbox, *fake.Editor, *regtest.Awaiter, error) {
+func connectEditor(dir string, config fake.EditorConfig) (*fake.Sandbox, *fake.Editor, *regtest.Awaiter, error) {
 	s, err := fake.NewSandbox(&fake.SandboxConfig{
 		Workdir: dir,
 		GOPROXY: "https://proxy.golang.org",
@@ -165,7 +165,7 @@
 
 	a := regtest.NewAwaiter(s.Workdir)
 	ts := getServer()
-	e, err := fake.NewEditor(s, fake.EditorConfig{}).Connect(context.Background(), ts, a.Hooks())
+	e, err := fake.NewEditor(s, config).Connect(context.Background(), ts, a.Hooks())
 	if err != nil {
 		return nil, nil, nil, err
 	}
diff --git a/gopls/internal/regtest/bench/completion_test.go b/gopls/internal/regtest/bench/completion_test.go
index cdafb08..a8725ce 100644
--- a/gopls/internal/regtest/bench/completion_test.go
+++ b/gopls/internal/regtest/bench/completion_test.go
@@ -18,8 +18,9 @@
 type completionBenchOptions struct {
 	file, locationRegexp string
 
-	// hook to run edits before initial completion
-	preCompletionEdits func(*Env)
+	// Hooks to run edits before initial completion
+	setup            func(*Env) // run before the benchmark starts
+	beforeCompletion func(*Env) // run before each completion
 }
 
 func benchmarkCompletion(options completionBenchOptions, b *testing.B) {
@@ -27,7 +28,11 @@
 
 	// Use a new environment for each test, to avoid any existing state from the
 	// previous session.
-	sandbox, editor, awaiter, err := connectEditor(dir)
+	sandbox, editor, awaiter, err := connectEditor(dir, fake.EditorConfig{
+		Settings: map[string]interface{}{
+			"completionBudget": "1m", // arbitrary long completion budget
+		},
+	})
 	if err != nil {
 		b.Fatal(err)
 	}
@@ -45,11 +50,10 @@
 		Sandbox: sandbox,
 		Awaiter: awaiter,
 	}
-	env.OpenFile(options.file)
 
 	// Run edits required for this completion.
-	if options.preCompletionEdits != nil {
-		options.preCompletionEdits(env)
+	if options.setup != nil {
+		options.setup(env)
 	}
 
 	// Run a completion to make sure the system is warm.
@@ -70,6 +74,9 @@
 	// initialization).
 	b.Run("completion", func(b *testing.B) {
 		for i := 0; i < b.N; i++ {
+			if options.beforeCompletion != nil {
+				options.beforeCompletion(env)
+			}
 			env.Completion(options.file, pos)
 		}
 	})
@@ -92,7 +99,7 @@
 func BenchmarkStructCompletion(b *testing.B) {
 	file := "internal/lsp/cache/session.go"
 
-	preCompletionEdits := func(env *Env) {
+	setup := func(env *Env) {
 		env.OpenFile(file)
 		originalBuffer := env.Editor.BufferText(file)
 		env.EditBuffer(file, fake.Edit{
@@ -102,17 +109,19 @@
 	}
 
 	benchmarkCompletion(completionBenchOptions{
-		file:               file,
-		locationRegexp:     `var testVariable map\[string\]bool = Session{}(\.)`,
-		preCompletionEdits: preCompletionEdits,
+		file:           file,
+		locationRegexp: `var testVariable map\[string\]bool = Session{}(\.)`,
+		setup:          setup,
 	}, b)
 }
 
 // Benchmark import completion in tools codebase.
 func BenchmarkImportCompletion(b *testing.B) {
+	const file = "internal/lsp/source/completion/completion.go"
 	benchmarkCompletion(completionBenchOptions{
-		file:           "internal/lsp/source/completion/completion.go",
+		file:           file,
 		locationRegexp: `go\/()`,
+		setup:          func(env *Env) { env.OpenFile(file) },
 	}, b)
 }
 
@@ -120,7 +129,7 @@
 func BenchmarkSliceCompletion(b *testing.B) {
 	file := "internal/lsp/cache/session.go"
 
-	preCompletionEdits := func(env *Env) {
+	setup := func(env *Env) {
 		env.OpenFile(file)
 		originalBuffer := env.Editor.BufferText(file)
 		env.EditBuffer(file, fake.Edit{
@@ -130,9 +139,9 @@
 	}
 
 	benchmarkCompletion(completionBenchOptions{
-		file:               file,
-		locationRegexp:     `var testVariable \[\]byte (=)`,
-		preCompletionEdits: preCompletionEdits,
+		file:           file,
+		locationRegexp: `var testVariable \[\]byte (=)`,
+		setup:          setup,
 	}, b)
 }
 
@@ -144,7 +153,7 @@
 	c.inference.kindMatches(c.)
 }
 `
-	preCompletionEdits := func(env *Env) {
+	setup := func(env *Env) {
 		env.OpenFile(file)
 		originalBuffer := env.Editor.BufferText(file)
 		env.EditBuffer(file, fake.Edit{
@@ -154,8 +163,42 @@
 	}
 
 	benchmarkCompletion(completionBenchOptions{
-		file:               file,
-		locationRegexp:     `func \(c \*completer\) _\(\) {\n\tc\.inference\.kindMatches\((c)`,
-		preCompletionEdits: preCompletionEdits,
+		file:           file,
+		locationRegexp: `func \(c \*completer\) _\(\) {\n\tc\.inference\.kindMatches\((c)`,
+		setup:          setup,
+	}, b)
+}
+
+// Benchmark completion following an arbitrary edit.
+//
+// Edits force type-checked packages to be invalidated, so we want to measure
+// how long it takes before completion results are available.
+func BenchmarkCompletionFollowingEdit(b *testing.B) {
+	file := "internal/lsp/source/completion/completion2.go"
+	fileContent := `
+package completion
+
+func (c *completer) _() {
+	c.inference.kindMatches(c.)
+	// __MAGIC_STRING_1
+}
+`
+	setup := func(env *Env) {
+		env.CreateBuffer(file, fileContent)
+	}
+
+	n := 1
+	beforeCompletion := func(env *Env) {
+		old := fmt.Sprintf("__MAGIC_STRING_%d", n)
+		new := fmt.Sprintf("__MAGIC_STRING_%d", n+1)
+		n++
+		env.RegexpReplace(file, old, new)
+	}
+
+	benchmarkCompletion(completionBenchOptions{
+		file:             file,
+		locationRegexp:   `func \(c \*completer\) _\(\) {\n\tc\.inference\.kindMatches\((c)`,
+		setup:            setup,
+		beforeCompletion: beforeCompletion,
 	}, b)
 }
diff --git a/gopls/internal/regtest/bench/iwl_test.go b/gopls/internal/regtest/bench/iwl_test.go
index e262a39..b223e33 100644
--- a/gopls/internal/regtest/bench/iwl_test.go
+++ b/gopls/internal/regtest/bench/iwl_test.go
@@ -8,6 +8,7 @@
 	"context"
 	"testing"
 
+	"golang.org/x/tools/internal/lsp/fake"
 	. "golang.org/x/tools/internal/lsp/regtest"
 )
 
@@ -19,7 +20,7 @@
 
 	ctx := context.Background()
 	for i := 0; i < b.N; i++ {
-		_, editor, awaiter, err := connectEditor(dir)
+		_, editor, awaiter, err := connectEditor(dir, fake.EditorConfig{})
 		if err != nil {
 			b.Fatal(err)
 		}
diff --git a/gopls/internal/regtest/diagnostics/diagnostics_test.go b/gopls/internal/regtest/diagnostics/diagnostics_test.go
index d7246ae..209e015 100644
--- a/gopls/internal/regtest/diagnostics/diagnostics_test.go
+++ b/gopls/internal/regtest/diagnostics/diagnostics_test.go
@@ -1548,7 +1548,7 @@
 }
 -- go.mod --
 module mod.com
--- main.go --
+-- cmd/main.go --
 package main
 
 import "mod.com/bob"
@@ -1558,11 +1558,12 @@
 }
 `
 	Run(t, mod, func(t *testing.T, env *Env) {
+		env.Await(FileWatchMatching("bob"))
 		env.RemoveWorkspaceFile("bob")
 		env.Await(
-			env.DiagnosticAtRegexp("main.go", `"mod.com/bob"`),
+			env.DiagnosticAtRegexp("cmd/main.go", `"mod.com/bob"`),
 			EmptyDiagnostics("bob/bob.go"),
-			RegistrationMatching("didChangeWatchedFiles"),
+			NoFileWatchMatching("bob"),
 		)
 	})
 }
@@ -1692,57 +1693,6 @@
 	})
 }
 
-func TestMultipleModules_Warning(t *testing.T) {
-	const modules = `
--- a/go.mod --
-module a.com
-
-go 1.12
--- a/a.go --
-package a
--- b/go.mod --
-module b.com
-
-go 1.12
--- b/b.go --
-package b
-`
-	for _, go111module := range []string{"on", "auto"} {
-		t.Run("GO111MODULE="+go111module, func(t *testing.T) {
-			WithOptions(
-				Modes(Default),
-				EnvVars{"GO111MODULE": go111module},
-			).Run(t, modules, func(t *testing.T, env *Env) {
-				env.OpenFile("a/a.go")
-				env.OpenFile("b/go.mod")
-				env.Await(
-					env.DiagnosticAtRegexp("a/a.go", "package a"),
-					env.DiagnosticAtRegexp("b/go.mod", "module b.com"),
-					OutstandingWork(lsp.WorkspaceLoadFailure, "gopls requires a module at the root of your workspace."),
-				)
-			})
-		})
-	}
-
-	// Expect no warning if GO111MODULE=auto in a directory in GOPATH.
-	t.Run("GOPATH_GO111MODULE_auto", func(t *testing.T) {
-		WithOptions(
-			Modes(Default),
-			EnvVars{"GO111MODULE": "auto"},
-			InGOPATH(),
-		).Run(t, modules, func(t *testing.T, env *Env) {
-			env.OpenFile("a/a.go")
-			env.Await(
-				OnceMet(
-					env.DoneWithOpen(),
-					EmptyDiagnostics("a/a.go"),
-				),
-				NoOutstandingWork(),
-			)
-		})
-	})
-}
-
 func TestNestedModules(t *testing.T) {
 	const proxy = `
 -- nested.com@v1.0.0/go.mod --
diff --git a/gopls/internal/regtest/workspace/broken_test.go b/gopls/internal/regtest/workspace/broken_test.go
index e88b98b..fbc41de 100644
--- a/gopls/internal/regtest/workspace/broken_test.go
+++ b/gopls/internal/regtest/workspace/broken_test.go
@@ -16,6 +16,10 @@
 // This file holds various tests for UX with respect to broken workspaces.
 //
 // TODO: consolidate other tests here.
+//
+// TODO: write more tests:
+//  - an explicit GOWORK value that doesn't exist
+//  - using modules and/or GOWORK inside of GOPATH?
 
 // Test for golang/go#53933
 func TestBrokenWorkspace_DuplicateModules(t *testing.T) {
@@ -28,8 +32,6 @@
 module example.com/foo
 
 go 1.12
--- example.com/foo@v1.2.3/foo.go --
-package foo
 `
 
 	const src = `
@@ -167,3 +169,83 @@
 		env.Await(NoOutstandingDiagnostics())
 	})
 }
+
+func TestMultipleModules_Warning(t *testing.T) {
+	msgForVersion := func(ver int) string {
+		if ver >= 18 {
+			return `gopls was not able to find modules in your workspace.`
+		} else {
+			return `gopls requires a module at the root of your workspace.`
+		}
+	}
+
+	const modules = `
+-- a/go.mod --
+module a.com
+
+go 1.12
+-- a/a.go --
+package a
+-- b/go.mod --
+module b.com
+
+go 1.12
+-- b/b.go --
+package b
+`
+	for _, go111module := range []string{"on", "auto"} {
+		t.Run("GO111MODULE="+go111module, func(t *testing.T) {
+			WithOptions(
+				Modes(Default),
+				EnvVars{"GO111MODULE": go111module},
+			).Run(t, modules, func(t *testing.T, env *Env) {
+				ver := env.GoVersion()
+				msg := msgForVersion(ver)
+				env.OpenFile("a/a.go")
+				env.OpenFile("b/go.mod")
+				env.Await(
+					env.DiagnosticAtRegexp("a/a.go", "package a"),
+					env.DiagnosticAtRegexp("b/go.mod", "module b.com"),
+					OutstandingWork(lsp.WorkspaceLoadFailure, msg),
+				)
+
+				// Changing the workspace folders to the valid modules should resolve
+				// the workspace error.
+				env.ChangeWorkspaceFolders("a", "b")
+				env.Await(NoOutstandingWork())
+
+				env.ChangeWorkspaceFolders(".")
+
+				// TODO(rfindley): when GO111MODULE=auto, we need to open or change a
+				// file here in order to detect a critical error. This is because gopls
+				// has forgotten about a/a.go, and therefor doesn't hit the heuristic
+				// "all packages are command-line-arguments".
+				//
+				// This is broken, and could be fixed by adjusting the heuristic to
+				// account for the scenario where there are *no* workspace packages, or
+				// (better) trying to get workspace packages for each open file. See
+				// also golang/go#54261.
+				env.OpenFile("b/b.go")
+				env.Await(OutstandingWork(lsp.WorkspaceLoadFailure, msg))
+			})
+		})
+	}
+
+	// Expect no warning if GO111MODULE=auto in a directory in GOPATH.
+	t.Run("GOPATH_GO111MODULE_auto", func(t *testing.T) {
+		WithOptions(
+			Modes(Default),
+			EnvVars{"GO111MODULE": "auto"},
+			InGOPATH(),
+		).Run(t, modules, func(t *testing.T, env *Env) {
+			env.OpenFile("a/a.go")
+			env.Await(
+				OnceMet(
+					env.DoneWithOpen(),
+					EmptyDiagnostics("a/a.go"),
+				),
+				NoOutstandingWork(),
+			)
+		})
+	})
+}
diff --git a/gopls/internal/regtest/workspace/fromenv_test.go b/gopls/internal/regtest/workspace/fromenv_test.go
new file mode 100644
index 0000000..8a77867
--- /dev/null
+++ b/gopls/internal/regtest/workspace/fromenv_test.go
@@ -0,0 +1,56 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package workspace
+
+import (
+	"testing"
+
+	. "golang.org/x/tools/internal/lsp/regtest"
+)
+
+// Test that setting go.work via environment variables or settings works.
+func TestUseGoWorkOutsideTheWorkspace(t *testing.T) {
+	const files = `
+-- work/a/go.mod --
+module a.com
+
+go 1.12
+-- work/a/a.go --
+package a
+-- work/b/go.mod --
+module b.com
+
+go 1.12
+-- work/b/b.go --
+package b
+
+func _() {
+	x := 1 // unused
+}
+-- config/go.work --
+go 1.18
+
+use (
+	$SANDBOX_WORKDIR/work/a
+	$SANDBOX_WORKDIR/work/b
+)
+`
+
+	WithOptions(
+		EnvVars{"GOWORK": "$SANDBOX_WORKDIR/config/go.work"},
+	).Run(t, files, func(t *testing.T, env *Env) {
+		// When we have an explicit GOWORK set, we should get a file watch request.
+		env.Await(FileWatchMatching(`config.go\.work`))
+		// Even though work/b is not open, we should get its diagnostics as it is
+		// included in the workspace.
+		env.OpenFile("work/a/a.go")
+		env.Await(
+			OnceMet(
+				env.DoneWithOpen(),
+				env.DiagnosticAtRegexpWithMessage("work/b/b.go", "x := 1", "not used"),
+			),
+		)
+	})
+}
diff --git a/internal/lsp/cache/load.go b/internal/lsp/cache/load.go
index 0952fc6..7f30939 100644
--- a/internal/lsp/cache/load.go
+++ b/internal/lsp/cache/load.go
@@ -283,15 +283,20 @@
 // 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 {
+	// TODO(rfindley): do we really not want to show a critical error if the user
+	// has no go.mod files?
 	if len(s.workspace.getKnownModFiles()) == 0 {
 		return nil
 	}
+
+	// TODO(rfindley): both of the checks below should be delegated to the workspace.
 	if s.view.userGo111Module == off {
 		return nil
 	}
 	if s.workspace.moduleSource != legacyWorkspace {
 		return nil
 	}
+
 	// If the user has one module per view, there is nothing to warn about.
 	if s.ValidBuildConfiguration() && len(s.workspace.getKnownModFiles()) == 1 {
 		return nil
@@ -305,10 +310,21 @@
 	// that the user has opened a directory that contains multiple modules.
 	// Check for that an warn about it.
 	if !s.ValidBuildConfiguration() {
-		msg := `gopls requires a module at the root of your workspace.
-You can work with multiple modules by opening each one as a workspace folder.
-Improvements to this workflow will be coming soon, and you can learn more here:
+		var msg string
+		if s.view.goversion >= 18 {
+			msg = `gopls was not able to find modules in your workspace.
+When outside of GOPATH, gopls needs to know which modules you are working on.
+You can fix this by opening your workspace to a folder inside a Go module, or
+by using a go.work file to specify multiple modules.
+See the documentation for more information on setting up your workspace:
 https://github.com/golang/tools/blob/master/gopls/doc/workspace.md.`
+		} else {
+			msg = `gopls requires a module at the root of your workspace.
+You can work with multiple modules by upgrading to Go 1.18 or later, and using
+go workspaces (go.work files).
+See the documentation for more information on setting up your workspace:
+https://github.com/golang/tools/blob/master/gopls/doc/workspace.md.`
+		}
 		return &source.CriticalError{
 			MainError:   fmt.Errorf(msg),
 			Diagnostics: s.applyCriticalErrorToFiles(ctx, msg, openFiles),
diff --git a/internal/lsp/cache/session.go b/internal/lsp/cache/session.go
index 984e8c1..d11c06d 100644
--- a/internal/lsp/cache/session.go
+++ b/internal/lsp/cache/session.go
@@ -7,6 +7,7 @@
 import (
 	"context"
 	"fmt"
+	"os"
 	"strconv"
 	"strings"
 	"sync"
@@ -199,8 +200,14 @@
 		}
 	}
 
+	explicitGowork := os.Getenv("GOWORK")
+	if v, ok := options.Env["GOWORK"]; ok {
+		explicitGowork = v
+	}
+	goworkURI := span.URIFromPath(explicitGowork)
+
 	// Build the gopls workspace, collecting active modules in the view.
-	workspace, err := newWorkspace(ctx, root, s, pathExcludedByFilterFunc(root.Filename(), wsInfo.gomodcache, options), wsInfo.userGo111Module == off, options.ExperimentalWorkspaceModule)
+	workspace, err := newWorkspace(ctx, root, goworkURI, s, pathExcludedByFilterFunc(root.Filename(), wsInfo.gomodcache, options), wsInfo.userGo111Module == off, options.ExperimentalWorkspaceModule)
 	if err != nil {
 		return nil, nil, func() {}, err
 	}
@@ -223,6 +230,7 @@
 		filesByURI:           map[span.URI]*fileBase{},
 		filesByBase:          map[string][]*fileBase{},
 		rootURI:              root,
+		explicitGowork:       goworkURI,
 		workspaceInformation: *wsInfo,
 	}
 	v.importsState = &importsState{
diff --git a/internal/lsp/cache/snapshot.go b/internal/lsp/cache/snapshot.go
index da2d7b5..0fa670c 100644
--- a/internal/lsp/cache/snapshot.go
+++ b/internal/lsp/cache/snapshot.go
@@ -274,7 +274,24 @@
 }
 
 func (s *snapshot) ValidBuildConfiguration() bool {
-	return validBuildConfiguration(s.view.rootURI, &s.view.workspaceInformation, s.workspace.getActiveModFiles())
+	// Since we only really understand the `go` command, if the user has a
+	// different GOPACKAGESDRIVER, assume that their configuration is valid.
+	if s.view.hasGopackagesDriver {
+		return true
+	}
+	// Check if the user is working within a module or if we have found
+	// multiple modules in the workspace.
+	if len(s.workspace.getActiveModFiles()) > 0 {
+		return true
+	}
+	// The user may have a multiple directories in their GOPATH.
+	// Check if the workspace is within any of them.
+	for _, gp := range filepath.SplitList(s.view.gopath) {
+		if source.InDir(filepath.Join(gp, "src"), s.view.rootURI.Filename()) {
+			return true
+		}
+	}
+	return false
 }
 
 // workspaceMode describes the way in which the snapshot's workspace should
@@ -419,6 +436,12 @@
 	s.view.optionsMu.Lock()
 	allowModfileModificationOption := s.view.options.AllowModfileModifications
 	allowNetworkOption := s.view.options.AllowImplicitNetworkAccess
+
+	// TODO(rfindley): this is very hard to follow, and may not even be doing the
+	// right thing: should inv.Env really trample view.options? Do we ever invoke
+	// this with a non-empty inv.Env?
+	//
+	// We should refactor to make it clearer that the correct env is being used.
 	inv.Env = append(append(append(os.Environ(), s.view.options.EnvSlice()...), inv.Env...), "GO111MODULE="+s.view.effectiveGo111Module)
 	inv.BuildFlags = append([]string{}, s.view.options.BuildFlags...)
 	s.view.optionsMu.Unlock()
@@ -864,6 +887,10 @@
 		fmt.Sprintf("**/*.{%s}", extensions): {},
 	}
 
+	if s.view.explicitGowork != "" {
+		patterns[s.view.explicitGowork.Filename()] = struct{}{}
+	}
+
 	// Add a pattern for each Go module in the workspace that is not within the view.
 	dirs := s.workspace.dirs(ctx, s)
 	for _, dir := range dirs {
@@ -1358,6 +1385,8 @@
 		// 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.
+		//
+		// TODO(rfindley): re-evaluate this heuristic.
 		if containsCommandLineArguments(wsPkgs) {
 			return s.workspaceLayoutError(ctx)
 		}
diff --git a/internal/lsp/cache/view.go b/internal/lsp/cache/view.go
index 6150109..b23ed61 100644
--- a/internal/lsp/cache/view.go
+++ b/internal/lsp/cache/view.go
@@ -27,6 +27,7 @@
 	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/internal/gocommand"
 	"golang.org/x/tools/internal/imports"
+	"golang.org/x/tools/internal/lsp/bug"
 	"golang.org/x/tools/internal/lsp/protocol"
 	"golang.org/x/tools/internal/lsp/source"
 	"golang.org/x/tools/internal/span"
@@ -98,6 +99,13 @@
 	// is just the folder. If we are in module mode, this is the module rootURI.
 	rootURI span.URI
 
+	// explicitGowork is, if non-empty, the URI for the explicit go.work file
+	// provided via the users environment.
+	//
+	// TODO(rfindley): this is duplicated in the workspace type. Refactor to
+	// eliminate this duplication.
+	explicitGowork span.URI
+
 	// workspaceInformation tracks various details about this view's
 	// environment variables, go version, and use of modules.
 	workspaceInformation
@@ -469,7 +477,7 @@
 	// TODO(rstambler): Make sure the go.work/gopls.mod files are always known
 	// to the view.
 	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
-		if c.URI == uriForSource(v.rootURI, src) {
+		if c.URI == uriForSource(v.rootURI, v.explicitGowork, src) {
 			return true
 		}
 	}
@@ -813,9 +821,13 @@
 	}
 	// The value of GOPACKAGESDRIVER is not returned through the go command.
 	gopackagesdriver := os.Getenv("GOPACKAGESDRIVER")
+	// TODO(rfindley): this looks wrong, or at least overly defensive. If the
+	// value of GOPACKAGESDRIVER is not returned from the go command... why do we
+	// look it up here?
 	for _, s := range env {
 		split := strings.SplitN(s, "=", 2)
 		if split[0] == "GOPACKAGESDRIVER" {
+			bug.Reportf("found GOPACKAGESDRIVER from the go command") // see note above
 			gopackagesdriver = split[1]
 		}
 	}
@@ -926,27 +938,6 @@
 	return nil
 }
 
-func validBuildConfiguration(folder span.URI, ws *workspaceInformation, modFiles map[span.URI]struct{}) bool {
-	// Since we only really understand the `go` command, if the user has a
-	// different GOPACKAGESDRIVER, assume that their configuration is valid.
-	if ws.hasGopackagesDriver {
-		return true
-	}
-	// Check if the user is working within a module or if we have found
-	// multiple modules in the workspace.
-	if len(modFiles) > 0 {
-		return true
-	}
-	// The user may have a multiple directories in their GOPATH.
-	// Check if the workspace is within any of them.
-	for _, gp := range filepath.SplitList(ws.gopath) {
-		if source.InDir(filepath.Join(gp, "src"), folder.Filename()) {
-			return true
-		}
-	}
-	return false
-}
-
 // getGoEnv gets the view's various GO* values.
 func (s *Session) getGoEnv(ctx context.Context, folder string, goversion int, go111module string, configEnv []string) (environmentVariables, map[string]string, error) {
 	envVars := environmentVariables{}
diff --git a/internal/lsp/cache/workspace.go b/internal/lsp/cache/workspace.go
index 9182cb9..f04fbe8 100644
--- a/internal/lsp/cache/workspace.go
+++ b/internal/lsp/cache/workspace.go
@@ -46,6 +46,19 @@
 	}
 }
 
+// workspaceCommon holds immutable information about the workspace setup.
+//
+// TODO(rfindley): there is some redundancy here with workspaceInformation.
+// Reconcile these two types.
+type workspaceCommon struct {
+	root        span.URI
+	excludePath func(string) bool
+
+	// explicitGowork is, if non-empty, the URI for the explicit go.work file
+	// provided via the user's environment.
+	explicitGowork span.URI
+}
+
 // workspace tracks go.mod files in the workspace, along with the
 // gopls.mod file, to provide support for multi-module workspaces.
 //
@@ -58,8 +71,8 @@
 // This type is immutable (or rather, idempotent), so that it may be shared
 // across multiple snapshots.
 type workspace struct {
-	root         span.URI
-	excludePath  func(string) bool
+	workspaceCommon
+
 	moduleSource workspaceSource
 
 	// activeModFiles holds the active go.mod files.
@@ -98,17 +111,21 @@
 //
 // TODO(rfindley): newWorkspace should perhaps never fail, relying instead on
 // the criticalError method to surface problems in the workspace.
-// TODO(rfindley): this function should accept the GOWORK value, if specified
-// by the user.
-func newWorkspace(ctx context.Context, root span.URI, fs source.FileSource, excludePath func(string) bool, go111moduleOff, useWsModule bool) (*workspace, error) {
+func newWorkspace(ctx context.Context, root, explicitGowork span.URI, fs source.FileSource, excludePath func(string) bool, go111moduleOff, useWsModule bool) (*workspace, error) {
 	ws := &workspace{
-		root:        root,
-		excludePath: excludePath,
+		workspaceCommon: workspaceCommon{
+			root:           root,
+			explicitGowork: explicitGowork,
+			excludePath:    excludePath,
+		},
 	}
 
 	// The user may have a gopls.mod or go.work file that defines their
 	// workspace.
-	if err := loadExplicitWorkspaceFile(ctx, ws, fs); err == nil {
+	//
+	// TODO(rfindley): if GO111MODULE=off, this looks wrong, though there are
+	// probably other problems.
+	if err := ws.loadExplicitWorkspaceFile(ctx, fs); err == nil {
 		return ws, nil
 	}
 
@@ -140,15 +157,15 @@
 // loadExplicitWorkspaceFile loads workspace information from go.work or
 // gopls.mod files, setting the active modules, mod file, and module source
 // accordingly.
-func loadExplicitWorkspaceFile(ctx context.Context, ws *workspace, fs source.FileSource) error {
+func (ws *workspace) loadExplicitWorkspaceFile(ctx context.Context, fs source.FileSource) error {
 	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
-		fh, err := fs.GetFile(ctx, uriForSource(ws.root, src))
+		fh, err := fs.GetFile(ctx, uriForSource(ws.root, ws.explicitGowork, src))
 		if err != nil {
 			return err
 		}
 		contents, err := fh.Read()
 		if err != nil {
-			continue
+			continue // TODO(rfindley): is it correct to proceed here?
 		}
 		var file *modfile.File
 		var activeModFiles map[span.URI]struct{}
@@ -313,15 +330,14 @@
 	// Clone the workspace. This may be discarded if nothing changed.
 	changed := false
 	result := &workspace{
-		root:           w.root,
-		moduleSource:   w.moduleSource,
-		knownModFiles:  make(map[span.URI]struct{}),
-		activeModFiles: make(map[span.URI]struct{}),
-		workFile:       w.workFile,
-		mod:            w.mod,
-		sum:            w.sum,
-		wsDirs:         w.wsDirs,
-		excludePath:    w.excludePath,
+		workspaceCommon: w.workspaceCommon,
+		moduleSource:    w.moduleSource,
+		knownModFiles:   make(map[span.URI]struct{}),
+		activeModFiles:  make(map[span.URI]struct{}),
+		workFile:        w.workFile,
+		mod:             w.mod,
+		sum:             w.sum,
+		wsDirs:          w.wsDirs,
 	}
 	for k, v := range w.knownModFiles {
 		result.knownModFiles[k] = v
@@ -391,7 +407,7 @@
 	// exists or walk the filesystem if it has been deleted.
 	// go.work should override the gopls.mod if both exist.
 	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
-		uri := uriForSource(ws.root, src)
+		uri := uriForSource(ws.root, ws.explicitGowork, src)
 		// File opens/closes are just no-ops.
 		change, ok := changes[uri]
 		if !ok {
@@ -460,12 +476,15 @@
 }
 
 // goplsModURI returns the URI for the gopls.mod file contained in root.
-func uriForSource(root span.URI, src workspaceSource) span.URI {
+func uriForSource(root, explicitGowork span.URI, src workspaceSource) span.URI {
 	var basename string
 	switch src {
 	case goplsModWorkspace:
 		basename = "gopls.mod"
 	case goWorkWorkspace:
+		if explicitGowork != "" {
+			return explicitGowork
+		}
 		basename = "go.work"
 	default:
 		return ""
diff --git a/internal/lsp/cache/workspace_test.go b/internal/lsp/cache/workspace_test.go
index 871e4bb..f1cd00b 100644
--- a/internal/lsp/cache/workspace_test.go
+++ b/internal/lsp/cache/workspace_test.go
@@ -280,7 +280,7 @@
 
 			fs := &osFileSource{}
 			excludeNothing := func(string) bool { return false }
-			w, err := newWorkspace(ctx, root, fs, excludeNothing, false, !test.legacyMode)
+			w, err := newWorkspace(ctx, root, "", fs, excludeNothing, false, !test.legacyMode)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -325,7 +325,7 @@
 
 	fs := &osFileSource{}
 	excludeNothing := func(string) bool { return false }
-	workspace, err := newWorkspace(ctx, root, fs, excludeNothing, false, false)
+	workspace, err := newWorkspace(ctx, root, "", fs, excludeNothing, false, false)
 	return workspace, cleanup, err
 }
 
diff --git a/internal/lsp/fake/client.go b/internal/lsp/fake/client.go
index 4c5f2a2..bb258f2 100644
--- a/internal/lsp/fake/client.go
+++ b/internal/lsp/fake/client.go
@@ -74,10 +74,11 @@
 func (c *Client) Configuration(_ context.Context, p *protocol.ParamConfiguration) ([]interface{}, error) {
 	results := make([]interface{}, len(p.Items))
 	for i, item := range p.Items {
-		if item.Section != "gopls" {
-			continue
+		if item.Section == "gopls" {
+			c.editor.mu.Lock()
+			results[i] = c.editor.settingsLocked()
+			c.editor.mu.Unlock()
 		}
-		results[i] = c.editor.settings()
 	}
 	return results, nil
 }
diff --git a/internal/lsp/fake/edit.go b/internal/lsp/fake/edit.go
index 8b04c39..579c3a1 100644
--- a/internal/lsp/fake/edit.go
+++ b/internal/lsp/fake/edit.go
@@ -108,6 +108,8 @@
 // editContent implements a simplistic, inefficient algorithm for applying text
 // edits to our buffer representation. It returns an error if the edit is
 // invalid for the current content.
+//
+// TODO(rfindley): this function does not handle non-ascii text correctly.
 func editContent(content []string, edits []Edit) ([]string, error) {
 	newEdits := make([]Edit, len(edits))
 	copy(newEdits, edits)
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index bc2cb2f..db43260 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -121,7 +121,7 @@
 //
 // It returns the editor, so that it may be called as follows:
 //
-//	editor, err := NewEditor(s).Connect(ctx, conn)
+//	editor, err := NewEditor(s).Connect(ctx, conn, hooks)
 func (e *Editor) Connect(ctx context.Context, connector servertest.Connector, hooks ClientHooks) (*Editor, error) {
 	bgCtx, cancelConn := context.WithCancel(xcontext.Detach(ctx))
 	conn := connector.Connect(bgCtx)
@@ -135,7 +135,7 @@
 			protocol.ClientHandler(e.client,
 				jsonrpc2.MethodNotFound)))
 
-	if err := e.initialize(ctx, e.config.WorkspaceFolders); err != nil {
+	if err := e.initialize(ctx); err != nil {
 		return nil, err
 	}
 	e.sandbox.Workdir.AddWatcher(e.onFileChanges)
@@ -197,11 +197,10 @@
 	return e.client
 }
 
-// settings builds the settings map for use in LSP settings
-// RPCs.
-func (e *Editor) settings() map[string]interface{} {
-	e.mu.Lock()
-	defer e.mu.Unlock()
+// settingsLocked builds the settings map for use in LSP settings RPCs.
+//
+// e.mu must be held while calling this function.
+func (e *Editor) settingsLocked() map[string]interface{} {
 	env := make(map[string]string)
 	for k, v := range e.defaultEnv {
 		env[k] = v
@@ -240,26 +239,19 @@
 	return settings
 }
 
-func (e *Editor) initialize(ctx context.Context, workspaceFolders []string) error {
+func (e *Editor) initialize(ctx context.Context) error {
 	params := &protocol.ParamInitialize{}
 	params.ClientInfo.Name = "fakeclient"
 	params.ClientInfo.Version = "v1.0.0"
-
-	if workspaceFolders == nil {
-		workspaceFolders = []string{string(e.sandbox.Workdir.RelativeTo)}
-	}
-	for _, folder := range workspaceFolders {
-		params.WorkspaceFolders = append(params.WorkspaceFolders, protocol.WorkspaceFolder{
-			URI:  string(e.sandbox.Workdir.URI(folder)),
-			Name: filepath.Base(folder),
-		})
-	}
-
+	e.mu.Lock()
+	params.WorkspaceFolders = e.makeWorkspaceFoldersLocked()
+	params.InitializationOptions = e.settingsLocked()
+	e.mu.Unlock()
 	params.Capabilities.Workspace.Configuration = true
 	params.Capabilities.Window.WorkDoneProgress = true
+
 	// TODO: set client capabilities
 	params.Capabilities.TextDocument.Completion.CompletionItem.TagSupport.ValueSet = []protocol.CompletionItemTag{protocol.ComplDeprecated}
-	params.InitializationOptions = e.settings()
 
 	params.Capabilities.TextDocument.Completion.CompletionItem.SnippetSupport = true
 	params.Capabilities.TextDocument.SemanticTokens.Requests.Full = true
@@ -296,6 +288,27 @@
 	return nil
 }
 
+// makeWorkspaceFoldersLocked creates a slice of workspace folders to use for
+// this editing session, based on the editor configuration.
+//
+// e.mu must be held while calling this function.
+func (e *Editor) makeWorkspaceFoldersLocked() (folders []protocol.WorkspaceFolder) {
+	paths := e.config.WorkspaceFolders
+	if len(paths) == 0 {
+		paths = append(paths, string(e.sandbox.Workdir.RelativeTo))
+	}
+
+	for _, path := range paths {
+		uri := string(e.sandbox.Workdir.URI(path))
+		folders = append(folders, protocol.WorkspaceFolder{
+			URI:  uri,
+			Name: filepath.Base(uri),
+		})
+	}
+
+	return folders
+}
+
 // onFileChanges is registered to be called by the Workdir on any writes that
 // go through the Workdir API. It is called synchronously by the Workdir.
 func (e *Editor) onFileChanges(ctx context.Context, evts []FileEvent) {
@@ -1195,6 +1208,54 @@
 	return nil
 }
 
+// ChangeWorkspaceFolders sets the new workspace folders, and sends a
+// didChangeWorkspaceFolders notification to the server.
+//
+// The given folders must all be unique.
+func (e *Editor) ChangeWorkspaceFolders(ctx context.Context, folders []string) error {
+	// capture existing folders so that we can compute the change.
+	e.mu.Lock()
+	oldFolders := e.makeWorkspaceFoldersLocked()
+	e.config.WorkspaceFolders = folders
+	newFolders := e.makeWorkspaceFoldersLocked()
+	e.mu.Unlock()
+
+	if e.Server == nil {
+		return nil
+	}
+
+	var params protocol.DidChangeWorkspaceFoldersParams
+
+	// Keep track of old workspace folders that must be removed.
+	toRemove := make(map[protocol.URI]protocol.WorkspaceFolder)
+	for _, folder := range oldFolders {
+		toRemove[folder.URI] = folder
+	}
+
+	// Sanity check: if we see a folder twice the algorithm below doesn't work,
+	// so track seen folders to ensure that we panic in that case.
+	seen := make(map[protocol.URI]protocol.WorkspaceFolder)
+	for _, folder := range newFolders {
+		if _, ok := seen[folder.URI]; ok {
+			panic(fmt.Sprintf("folder %s seen twice", folder.URI))
+		}
+
+		// If this folder already exists, we don't want to remove it.
+		// Otherwise, we need to add it.
+		if _, ok := toRemove[folder.URI]; ok {
+			delete(toRemove, folder.URI)
+		} else {
+			params.Event.Added = append(params.Event.Added, folder)
+		}
+	}
+
+	for _, v := range toRemove {
+		params.Event.Removed = append(params.Event.Removed, v)
+	}
+
+	return e.Server.DidChangeWorkspaceFolders(ctx, &params)
+}
+
 // CodeAction executes a codeAction request on the server.
 func (e *Editor) CodeAction(ctx context.Context, path string, rng *protocol.Range, diagnostics []protocol.Diagnostic) ([]protocol.CodeAction, error) {
 	if e.Server == nil {
diff --git a/internal/lsp/fake/sandbox.go b/internal/lsp/fake/sandbox.go
index 72b846c..e0e113f 100644
--- a/internal/lsp/fake/sandbox.go
+++ b/internal/lsp/fake/sandbox.go
@@ -23,10 +23,11 @@
 // Sandbox holds a collection of temporary resources to use for working with Go
 // code in tests.
 type Sandbox struct {
-	gopath  string
-	rootdir string
-	goproxy string
-	Workdir *Workdir
+	gopath          string
+	rootdir         string
+	goproxy         string
+	Workdir         *Workdir
+	goCommandRunner gocommand.Runner
 }
 
 // SandboxConfig controls the behavior of a test sandbox. The zero value
@@ -229,30 +230,36 @@
 	return vars
 }
 
-// RunGoCommand executes a go command in the sandbox. If checkForFileChanges is
-// true, the sandbox scans the working directory and emits file change events
-// for any file changes it finds.
-func (sb *Sandbox) RunGoCommand(ctx context.Context, dir, verb string, args []string, checkForFileChanges bool) error {
+// goCommandInvocation returns a new gocommand.Invocation initialized with the
+// sandbox environment variables and working directory.
+func (sb *Sandbox) goCommandInvocation() gocommand.Invocation {
 	var vars []string
 	for k, v := range sb.GoEnv() {
 		vars = append(vars, fmt.Sprintf("%s=%s", k, v))
 	}
 	inv := gocommand.Invocation{
-		Verb: verb,
-		Args: args,
-		Env:  vars,
+		Env: vars,
 	}
-	// Use the provided directory for the working directory, if available.
 	// sb.Workdir may be nil if we exited the constructor with errors (we call
 	// Close to clean up any partial state from the constructor, which calls
 	// RunGoCommand).
-	if dir != "" {
-		inv.WorkingDir = sb.Workdir.AbsPath(dir)
-	} else if sb.Workdir != nil {
+	if sb.Workdir != nil {
 		inv.WorkingDir = string(sb.Workdir.RelativeTo)
 	}
-	gocmdRunner := &gocommand.Runner{}
-	stdout, stderr, _, err := gocmdRunner.RunRaw(ctx, inv)
+	return inv
+}
+
+// RunGoCommand executes a go command in the sandbox. If checkForFileChanges is
+// true, the sandbox scans the working directory and emits file change events
+// for any file changes it finds.
+func (sb *Sandbox) RunGoCommand(ctx context.Context, dir, verb string, args []string, checkForFileChanges bool) error {
+	inv := sb.goCommandInvocation()
+	inv.Verb = verb
+	inv.Args = args
+	if dir != "" {
+		inv.WorkingDir = sb.Workdir.AbsPath(dir)
+	}
+	stdout, stderr, _, err := sb.goCommandRunner.RunRaw(ctx, inv)
 	if err != nil {
 		return fmt.Errorf("go command failed (stdout: %s) (stderr: %s): %v", stdout.String(), stderr.String(), err)
 	}
@@ -269,6 +276,13 @@
 	return nil
 }
 
+// GoVersion checks the version of the go command.
+// It returns the X in Go 1.X.
+func (sb *Sandbox) GoVersion(ctx context.Context) (int, error) {
+	inv := sb.goCommandInvocation()
+	return gocommand.GoVersion(ctx, inv, &sb.goCommandRunner)
+}
+
 // Close removes all state associated with the sandbox.
 func (sb *Sandbox) Close() error {
 	var goCleanErr error
diff --git a/internal/lsp/progress/progress.go b/internal/lsp/progress/progress.go
index 8b0d1c6..ee63bb7 100644
--- a/internal/lsp/progress/progress.go
+++ b/internal/lsp/progress/progress.go
@@ -118,7 +118,7 @@
 		},
 	})
 	if err != nil {
-		event.Error(ctx, "generate progress begin", err)
+		event.Error(ctx, "progress begin", err)
 	}
 	return wd
 }
diff --git a/internal/lsp/regtest/env.go b/internal/lsp/regtest/env.go
index 502636a..f8a68b3 100644
--- a/internal/lsp/regtest/env.go
+++ b/internal/lsp/regtest/env.go
@@ -85,8 +85,9 @@
 	showMessage        []*protocol.ShowMessageParams
 	showMessageRequest []*protocol.ShowMessageRequestParams
 
-	registrations   []*protocol.RegistrationParams
-	unregistrations []*protocol.UnregistrationParams
+	registrations          []*protocol.RegistrationParams
+	registeredCapabilities map[string]protocol.Registration
+	unregistrations        []*protocol.UnregistrationParams
 
 	// outstandingWork is a map of token->work summary. All tokens are assumed to
 	// be string, though the spec allows for numeric tokens as well.  When work
@@ -226,6 +227,12 @@
 	defer a.mu.Unlock()
 
 	a.state.registrations = append(a.state.registrations, m)
+	if a.state.registeredCapabilities == nil {
+		a.state.registeredCapabilities = make(map[string]protocol.Registration)
+	}
+	for _, reg := range m.Registrations {
+		a.state.registeredCapabilities[reg.Method] = reg
+	}
 	a.checkConditionsLocked()
 	return nil
 }
diff --git a/internal/lsp/regtest/expectation.go b/internal/lsp/regtest/expectation.go
index a0a7d52..7867af9 100644
--- a/internal/lsp/regtest/expectation.go
+++ b/internal/lsp/regtest/expectation.go
@@ -394,32 +394,66 @@
 	}
 }
 
-// RegistrationExpectation is an expectation on the capability registrations
-// received by the editor from gopls.
-type RegistrationExpectation struct {
-	check       func([]*protocol.RegistrationParams) Verdict
-	description string
+// FileWatchMatching expects that a file registration matches re.
+func FileWatchMatching(re string) SimpleExpectation {
+	return SimpleExpectation{
+		check:       checkFileWatch(re, Met, Unmet),
+		description: fmt.Sprintf("file watch matching %q", re),
+	}
 }
 
-// Check implements the Expectation interface.
-func (e RegistrationExpectation) Check(s State) Verdict {
-	return e.check(s.registrations)
+// NoFileWatchMatching expects that no file registration matches re.
+func NoFileWatchMatching(re string) SimpleExpectation {
+	return SimpleExpectation{
+		check:       checkFileWatch(re, Unmet, Met),
+		description: fmt.Sprintf("no file watch matching %q", re),
+	}
 }
 
-// Description implements the Expectation interface.
-func (e RegistrationExpectation) Description() string {
-	return e.description
+func checkFileWatch(re string, onMatch, onNoMatch Verdict) func(State) Verdict {
+	rec := regexp.MustCompile(re)
+	return func(s State) Verdict {
+		r := s.registeredCapabilities["workspace/didChangeWatchedFiles"]
+		watchers := jsonProperty(r.RegisterOptions, "watchers").([]interface{})
+		for _, watcher := range watchers {
+			pattern := jsonProperty(watcher, "globPattern").(string)
+			if rec.MatchString(pattern) {
+				return onMatch
+			}
+		}
+		return onNoMatch
+	}
+}
+
+// jsonProperty extracts a value from a path of JSON property names, assuming
+// the default encoding/json unmarshaling to the empty interface (i.e.: that
+// JSON objects are unmarshalled as map[string]interface{})
+//
+// For example, if obj is unmarshalled from the following json:
+//
+//	{
+//		"foo": { "bar": 3 }
+//	}
+//
+// Then jsonProperty(obj, "foo", "bar") will be 3.
+func jsonProperty(obj interface{}, path ...string) interface{} {
+	if len(path) == 0 || obj == nil {
+		return obj
+	}
+	m := obj.(map[string]interface{})
+	return jsonProperty(m[path[0]], path[1:]...)
 }
 
 // RegistrationMatching asserts that the client has received a capability
 // registration matching the given regexp.
-func RegistrationMatching(re string) RegistrationExpectation {
-	rec, err := regexp.Compile(re)
-	if err != nil {
-		panic(err)
-	}
-	check := func(params []*protocol.RegistrationParams) Verdict {
-		for _, p := range params {
+//
+// TODO(rfindley): remove this once TestWatchReplaceTargets has been revisited.
+//
+// Deprecated: use (No)FileWatchMatching
+func RegistrationMatching(re string) SimpleExpectation {
+	rec := regexp.MustCompile(re)
+	check := func(s State) Verdict {
+		for _, p := range s.registrations {
 			for _, r := range p.Registrations {
 				if rec.Match([]byte(r.Method)) {
 					return Met
@@ -428,38 +462,18 @@
 		}
 		return Unmet
 	}
-	return RegistrationExpectation{
+	return SimpleExpectation{
 		check:       check,
 		description: fmt.Sprintf("registration matching %q", re),
 	}
 }
 
-// UnregistrationExpectation is an expectation on the capability
-// unregistrations received by the editor from gopls.
-type UnregistrationExpectation struct {
-	check       func([]*protocol.UnregistrationParams) Verdict
-	description string
-}
-
-// Check implements the Expectation interface.
-func (e UnregistrationExpectation) Check(s State) Verdict {
-	return e.check(s.unregistrations)
-}
-
-// Description implements the Expectation interface.
-func (e UnregistrationExpectation) Description() string {
-	return e.description
-}
-
 // UnregistrationMatching asserts that the client has received an
 // unregistration whose ID matches the given regexp.
-func UnregistrationMatching(re string) UnregistrationExpectation {
-	rec, err := regexp.Compile(re)
-	if err != nil {
-		panic(err)
-	}
-	check := func(params []*protocol.UnregistrationParams) Verdict {
-		for _, p := range params {
+func UnregistrationMatching(re string) SimpleExpectation {
+	rec := regexp.MustCompile(re)
+	check := func(s State) Verdict {
+		for _, p := range s.unregistrations {
 			for _, r := range p.Unregisterations {
 				if rec.Match([]byte(r.Method)) {
 					return Met
@@ -468,7 +482,7 @@
 		}
 		return Unmet
 	}
-	return UnregistrationExpectation{
+	return SimpleExpectation{
 		check:       check,
 		description: fmt.Sprintf("unregistration matching %q", re),
 	}
diff --git a/internal/lsp/regtest/wrappers.go b/internal/lsp/regtest/wrappers.go
index d8c080c..e8f49a6 100644
--- a/internal/lsp/regtest/wrappers.go
+++ b/internal/lsp/regtest/wrappers.go
@@ -277,6 +277,17 @@
 	}
 }
 
+// GoVersion checks the version of the go command.
+// It returns the X in Go 1.X.
+func (e *Env) GoVersion() int {
+	e.T.Helper()
+	v, err := e.Sandbox.GoVersion(e.Ctx)
+	if err != nil {
+		e.T.Fatal(err)
+	}
+	return v
+}
+
 // DumpGoSum prints the correct go.sum contents for dir in txtar format,
 // for use in creating regtests.
 func (e *Env) DumpGoSum(dir string) {
@@ -433,3 +444,12 @@
 		e.T.Fatal(err)
 	}
 }
+
+// ChangeWorkspaceFolders updates the editor workspace folders, calling t.Fatal
+// on any error.
+func (e *Env) ChangeWorkspaceFolders(newFolders ...string) {
+	e.T.Helper()
+	if err := e.Editor.ChangeWorkspaceFolders(e.Ctx, newFolders); err != nil {
+		e.T.Fatal(err)
+	}
+}
diff --git a/internal/span/uri.go b/internal/span/uri.go
index 8132665..ce874e7 100644
--- a/internal/span/uri.go
+++ b/internal/span/uri.go
@@ -144,7 +144,9 @@
 }
 
 // URIFromPath returns a span URI for the supplied file path.
-// It will always have the file scheme.
+//
+// For empty paths, URIFromPath returns the empty URI "".
+// For non-empty paths, URIFromPath returns a uri with the file:// scheme.
 func URIFromPath(path string) URI {
 	if path == "" {
 		return ""