gopls: remove the experimentalWorkspaceModule mode

Remove the experimentalWorkspaceModule setting, and all of the roots it
has planted. Specifically, this removes:
- the gopls.mod and filesystem workspace modes
- the command to generate a gopls.mod
- the need to track separate workspace modes entirely, as we align with
  the Go command and don't need any additional logic
- the need to maintain a workspace directory
- the 'cache.workspace' abstraction entirely; now we can just track
  workspace modules

Along the way, further simplify the treatment of view workspace
information. In particular, just use the value of GOWORK returned by the
go command, rather than computing it ourselves. This means that we may
need to re-run `go env` while processing a change to go.mod or go.work
files. If that proves to be problematic, we can improve it in the future.

Many workspace tests had to be restricted to just Go 1.18+, because we
no longer fake go.work support at earlier Go versions.

Fixes golang/go#55331

Change-Id: I15ad58f548295727a51b99f43c7572b066f9df07
Reviewed-on: https://go-review.googlesource.com/c/tools/+/458116
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Robert Findley <rfindley@google.com>
diff --git a/gopls/doc/commands.md b/gopls/doc/commands.md
index 9d5e851..ac22c55 100644
--- a/gopls/doc/commands.md
+++ b/gopls/doc/commands.md
@@ -147,20 +147,6 @@
 }
 ```
 
-### **Generate gopls.mod**
-Identifier: `gopls.generate_gopls_mod`
-
-(Re)generate the gopls.mod file for a workspace.
-
-Args:
-
-```
-{
-	// The file URI.
-	"URI": string,
-}
-```
-
 ### **go get a package**
 Identifier: `gopls.go_get_package`
 
diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md
index 6816967..3df086c 100644
--- a/gopls/doc/settings.md
+++ b/gopls/doc/settings.md
@@ -116,18 +116,6 @@
 
 Default: `true`.
 
-#### **experimentalWorkspaceModule** *bool*
-
-**This setting is experimental and may be deleted.**
-
-experimentalWorkspaceModule opts a user into the experimental support
-for multi-module workspaces.
-
-Deprecated: this feature is deprecated and will be removed in a future
-version of gopls (https://go.dev/issue/55331).
-
-Default: `false`.
-
 #### **experimentalPackageCacheKey** *bool*
 
 **This setting is experimental and may be deleted.**
diff --git a/gopls/internal/lsp/cache/check.go b/gopls/internal/lsp/cache/check.go
index ae550bc..0125958 100644
--- a/gopls/internal/lsp/cache/check.go
+++ b/gopls/internal/lsp/cache/check.go
@@ -357,13 +357,9 @@
 	// If this is a replaced module in the workspace, the version is
 	// meaningless, and we don't want clients to access it.
 	if m.Module != nil {
-		version := m.Module.Version
-		if source.IsWorkspaceModuleVersion(version) {
-			version = ""
-		}
 		pkg.version = &module.Version{
 			Path:    m.Module.Path,
-			Version: version,
+			Version: m.Module.Version,
 		}
 	}
 
diff --git a/gopls/internal/lsp/cache/imports.go b/gopls/internal/lsp/cache/imports.go
index 5b094cb..46b8d15 100644
--- a/gopls/internal/lsp/cache/imports.go
+++ b/gopls/internal/lsp/cache/imports.go
@@ -7,7 +7,6 @@
 import (
 	"context"
 	"fmt"
-	"os"
 	"reflect"
 	"strings"
 	"sync"
@@ -25,12 +24,18 @@
 
 	mu                     sync.Mutex
 	processEnv             *imports.ProcessEnv
-	cleanupProcessEnv      func()
 	cacheRefreshDuration   time.Duration
 	cacheRefreshTimer      *time.Timer
 	cachedModFileHash      source.Hash
 	cachedBuildFlags       []string
 	cachedDirectoryFilters []string
+
+	// runOnce records whether runProcessEnvFunc has been called at least once.
+	// This is necessary to avoid resetting state before the process env is
+	// populated.
+	//
+	// TODO(rfindley): this shouldn't be necessary.
+	runOnce bool
 }
 
 func (s *importsState) runProcessEnvFunc(ctx context.Context, snapshot *snapshot, fn func(*imports.Options) error) error {
@@ -43,7 +48,7 @@
 	//
 	// TODO(rfindley): consider instead hashing on-disk modfiles here.
 	var modFileHash source.Hash
-	for m := range snapshot.workspace.ActiveModFiles() {
+	for m := range snapshot.workspaceModFiles {
 		fh, err := snapshot.GetFile(ctx, m)
 		if err != nil {
 			return err
@@ -70,22 +75,21 @@
 		// As a special case, skip cleanup the first time -- we haven't fully
 		// initialized the environment yet and calling GetResolver will do
 		// unnecessary work and potentially mess up the go.mod file.
-		if s.cleanupProcessEnv != nil {
+		if s.runOnce {
 			if resolver, err := s.processEnv.GetResolver(); err == nil {
 				if modResolver, ok := resolver.(*imports.ModuleResolver); ok {
 					modResolver.ClearForNewMod()
 				}
 			}
-			s.cleanupProcessEnv()
 		}
+
 		s.cachedModFileHash = modFileHash
 		s.cachedBuildFlags = currentBuildFlags
 		s.cachedDirectoryFilters = currentDirectoryFilters
-		var err error
-		s.cleanupProcessEnv, err = s.populateProcessEnv(ctx, snapshot)
-		if err != nil {
+		if err := s.populateProcessEnv(ctx, snapshot); err != nil {
 			return err
 		}
+		s.runOnce = true
 	}
 
 	// Run the user function.
@@ -120,7 +124,7 @@
 
 // populateProcessEnv sets the dynamically configurable fields for the view's
 // process environment. Assumes that the caller is holding the s.view.importsMu.
-func (s *importsState) populateProcessEnv(ctx context.Context, snapshot *snapshot) (cleanup func(), err error) {
+func (s *importsState) populateProcessEnv(ctx context.Context, snapshot *snapshot) error {
 	pe := s.processEnv
 
 	if snapshot.view.Options().VerboseOutput {
@@ -141,7 +145,7 @@
 		WorkingDir: snapshot.view.workingDir().Filename(),
 	})
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	pe.BuildFlags = inv.BuildFlags
@@ -156,29 +160,9 @@
 	}
 	// We don't actually use the invocation, so clean it up now.
 	cleanupInvocation()
-
-	// If the snapshot uses a synthetic workspace directory, create a copy for
-	// the lifecycle of the importsState.
-	//
-	// Notably, we cannot use the snapshot invocation working directory, as that
-	// is tied to the lifecycle of the snapshot.
-	//
-	// Otherwise return a no-op cleanup function.
-	cleanup = func() {}
-	if snapshot.usesWorkspaceDir() {
-		tmpDir, err := makeWorkspaceDir(ctx, snapshot.workspace, snapshot)
-		if err != nil {
-			return nil, err
-		}
-		pe.WorkingDir = tmpDir
-		cleanup = func() {
-			os.RemoveAll(tmpDir) // ignore error
-		}
-	} else {
-		pe.WorkingDir = snapshot.view.workingDir().Filename()
-	}
-
-	return cleanup, nil
+	// TODO(rfindley): should this simply be inv.WorkingDir?
+	pe.WorkingDir = snapshot.view.workingDir().Filename()
+	return nil
 }
 
 func (s *importsState) refreshProcessEnv() {
@@ -202,11 +186,3 @@
 	s.cacheRefreshTimer = nil
 	s.mu.Unlock()
 }
-
-func (s *importsState) destroy() {
-	s.mu.Lock()
-	if s.cleanupProcessEnv != nil {
-		s.cleanupProcessEnv()
-	}
-	s.mu.Unlock()
-}
diff --git a/gopls/internal/lsp/cache/load.go b/gopls/internal/lsp/cache/load.go
index 07dae76..c4e0296 100644
--- a/gopls/internal/lsp/cache/load.go
+++ b/gopls/internal/lsp/cache/load.go
@@ -9,8 +9,6 @@
 	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
-	"os"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -287,22 +285,17 @@
 // TODO(rfindley): separate workspace diagnostics from critical workspace
 // errors.
 func (s *snapshot) workspaceLayoutError(ctx context.Context) (error, []*source.Diagnostic) {
-	// 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, nil
-	}
-
 	// TODO(rfindley): both of the checks below should be delegated to the workspace.
+
 	if s.view.effectiveGO111MODULE() == off {
 		return nil, nil
 	}
-	if s.workspace.moduleSource != legacyWorkspace {
-		return nil, nil
-	}
 
-	// If the user has one module per view, there is nothing to warn about.
-	if s.ValidBuildConfiguration() && len(s.workspace.getKnownModFiles()) == 1 {
+	// If the user is using a go.work file, we assume that they know what they
+	// are doing.
+	//
+	// TODO(golang/go#53880): improve orphaned file diagnostics when using go.work.
+	if s.view.gowork != "" {
 		return nil, nil
 	}
 
@@ -335,10 +328,10 @@
 	// If the user has one active go.mod file, they may still be editing files
 	// in nested modules. Check the module of each open file and add warnings
 	// that the nested module must be opened as a workspace folder.
-	if len(s.workspace.ActiveModFiles()) == 1 {
+	if len(s.workspaceModFiles) == 1 {
 		// Get the active root go.mod file to compare against.
 		var rootMod string
-		for uri := range s.workspace.ActiveModFiles() {
+		for uri := range s.workspaceModFiles {
 			rootMod = uri.Filename()
 		}
 		rootDir := filepath.Dir(rootMod)
@@ -413,55 +406,6 @@
 	return srcDiags
 }
 
-// getWorkspaceDir returns the URI for the workspace directory
-// associated with this snapshot. The workspace directory is a
-// temporary directory containing the go.mod file computed from all
-// active modules.
-func (s *snapshot) getWorkspaceDir(ctx context.Context) (span.URI, error) {
-	s.mu.Lock()
-	dir, err := s.workspaceDir, s.workspaceDirErr
-	s.mu.Unlock()
-	if dir == "" && err == nil { // cache miss
-		dir, err = makeWorkspaceDir(ctx, s.workspace, s)
-		s.mu.Lock()
-		s.workspaceDir, s.workspaceDirErr = dir, err
-		s.mu.Unlock()
-	}
-	return span.URIFromPath(dir), err
-}
-
-// makeWorkspaceDir creates a temporary directory containing a go.mod
-// and go.sum file for each module in the workspace.
-// Note: snapshot's mutex must be unlocked for it to satisfy FileSource.
-func makeWorkspaceDir(ctx context.Context, workspace *workspace, fs source.FileSource) (string, error) {
-	file, err := workspace.modFile(ctx, fs)
-	if err != nil {
-		return "", err
-	}
-	modContent, err := file.Format()
-	if err != nil {
-		return "", err
-	}
-	sumContent, err := workspace.sumFile(ctx, fs)
-	if err != nil {
-		return "", err
-	}
-	tmpdir, err := ioutil.TempDir("", "gopls-workspace-mod")
-	if err != nil {
-		return "", err
-	}
-	for name, content := range map[string][]byte{
-		"go.mod": modContent,
-		"go.sum": sumContent,
-	} {
-		if err := ioutil.WriteFile(filepath.Join(tmpdir, name), content, 0644); err != nil {
-			os.RemoveAll(tmpdir) // ignore error
-			return "", err
-		}
-	}
-	return tmpdir, nil
-}
-
 // buildMetadata populates the updates map with metadata updates to
 // apply, based on the given pkg. It recurs through pkg.Imports to ensure that
 // metadata exists for all dependencies.
@@ -628,9 +572,13 @@
 	// Otherwise if the package has a module it must be an active module (as
 	// defined by the module root or go.work file) and at least one file must not
 	// be filtered out by directoryFilters.
-	if m.Module != nil && s.workspace.moduleSource != legacyWorkspace {
+	//
+	// TODO(rfindley): revisit this function. We should not need to predicate on
+	// gowork != "". It should suffice to consider workspace mod files (also, we
+	// will hopefully eliminate the concept of a workspace package soon).
+	if m.Module != nil && s.view.gowork != "" {
 		modURI := span.URIFromPath(m.Module.GoMod)
-		_, ok := s.workspace.activeModFiles[modURI]
+		_, ok := s.workspaceModFiles[modURI]
 		if !ok {
 			return false
 		}
diff --git a/gopls/internal/lsp/cache/mod.go b/gopls/internal/lsp/cache/mod.go
index 48590ec..b198dff 100644
--- a/gopls/internal/lsp/cache/mod.go
+++ b/gopls/internal/lsp/cache/mod.go
@@ -383,11 +383,6 @@
 
 	for i := len(matches) - 1; i >= 0; i-- {
 		ver := module.Version{Path: matches[i][1], Version: matches[i][2]}
-		// Any module versions that come from the workspace module should not
-		// be shown to the user.
-		if source.IsWorkspaceModuleVersion(ver.Version) {
-			continue
-		}
 		if err := module.Check(ver.Path, ver.Version); err != nil {
 			continue
 		}
@@ -424,11 +419,6 @@
 	var innermost *module.Version
 	for i := len(matches) - 1; i >= 0; i-- {
 		ver := module.Version{Path: matches[i][1], Version: matches[i][2]}
-		// Any module versions that come from the workspace module should not
-		// be shown to the user.
-		if source.IsWorkspaceModuleVersion(ver.Version) {
-			continue
-		}
 		if err := module.Check(ver.Path, ver.Version); err != nil {
 			continue
 		}
diff --git a/gopls/internal/lsp/cache/session.go b/gopls/internal/lsp/cache/session.go
index 167ce90..8293873 100644
--- a/gopls/internal/lsp/cache/session.go
+++ b/gopls/internal/lsp/cache/session.go
@@ -184,33 +184,13 @@
 func (s *Session) createView(ctx context.Context, name string, folder span.URI, options *source.Options, seqID uint64) (*View, *snapshot, func(), error) {
 	index := atomic.AddInt64(&viewIndex, 1)
 
-	// Get immutable workspace configuration.
-	//
-	// TODO(rfindley): this info isn't actually immutable. For example, GOWORK
-	// could be changed, or a user's environment could be modified.
-	// We need a mechanism to invalidate it.
+	// Get immutable workspace information.
 	info, err := s.getWorkspaceInformation(ctx, folder, options)
 	if err != nil {
 		return nil, nil, func() {}, err
 	}
 
-	root := folder
-	// filterFunc is the path filter function for this workspace folder. Notably,
-	// it is relative to folder (which is specified by the user), not root.
-	filterFunc := pathExcludedByFilterFunc(folder.Filename(), info.gomodcache, options)
-	rootSrc, err := findWorkspaceModuleSource(ctx, root, s, filterFunc, options.ExperimentalWorkspaceModule)
-	if err != nil {
-		return nil, nil, func() {}, err
-	}
-	if options.ExpandWorkspaceToModule && rootSrc != "" {
-		root = span.Dir(rootSrc)
-	}
-
-	// Build the gopls workspace, collecting active modules in the view.
-	workspace, err := newWorkspace(ctx, root, info.effectiveGOWORK(), s, filterFunc, info.effectiveGO111MODULE() == off, options.ExperimentalWorkspaceModule)
-	if err != nil {
-		return nil, nil, func() {}, err
-	}
+	wsModFiles, wsModFilesErr := computeWorkspaceModFiles(ctx, info.gomod, info.effectiveGOWORK(), info.effectiveGO111MODULE(), s)
 
 	// We want a true background context and not a detached context here
 	// the spans need to be unrelated and no tag values should pollute it.
@@ -231,7 +211,6 @@
 		vulns:                map[span.URI]*govulncheck.Result{},
 		filesByURI:           make(map[span.URI]span.URI),
 		filesByBase:          make(map[string][]canonicalURI),
-		rootSrc:              rootSrc,
 		workspaceInformation: info,
 	}
 	v.importsState = &importsState{
@@ -274,7 +253,8 @@
 		modVulnHandles:       persistent.NewMap(uriLessInterface),
 		modWhyHandles:        persistent.NewMap(uriLessInterface),
 		knownSubdirs:         newKnownDirsSet(),
-		workspace:            workspace,
+		workspaceModFiles:    wsModFiles,
+		workspaceModFilesErr: wsModFilesErr,
 	}
 	// Save one reference in the view.
 	v.releaseSnapshot = v.snapshot.Acquire()
@@ -496,51 +476,52 @@
 		return nil, nil, err
 	}
 
-	// Re-create views whose root may have changed.
+	// Re-create views whose definition may have changed.
 	//
-	// checkRoots controls whether to re-evaluate view definitions when
+	// checkViews controls whether to re-evaluate view definitions when
 	// collecting views below. Any addition or deletion of a go.mod or go.work
 	// file may have affected the definition of the view.
-	checkRoots := false
+	checkViews := false
+
 	for _, c := range changes {
 		if isGoMod(c.URI) || isGoWork(c.URI) {
-			// TODO(rfindley): only consider additions or deletions here.
-			checkRoots = true
-			break
+			// Change, InvalidateMetadata, and UnknownFileAction actions do not cause
+			// us to re-evaluate views.
+			redoViews := (c.Action != source.Change &&
+				c.Action != source.InvalidateMetadata &&
+				c.Action != source.UnknownFileAction)
+
+			if redoViews {
+				checkViews = true
+				break
+			}
 		}
 	}
 
-	if checkRoots {
+	if checkViews {
 		for _, view := range s.views {
-			// Check whether the view must be recreated. This logic looks hacky,
-			// as it uses the existing view gomodcache and options to re-evaluate
-			// the workspace source, then expects view creation to compute the same
-			// root source after first re-evaluating gomodcache and options.
-			//
-			// Well, it *is* a bit hacky, but in practice we will get the same
-			// gomodcache and options, as any environment change affecting these
-			// should have already invalidated the view (c.f. minorOptionsChange).
-			//
-			// TODO(rfindley): clean this up.
-			filterFunc := pathExcludedByFilterFunc(view.folder.Filename(), view.gomodcache, view.Options())
-			src, err := findWorkspaceModuleSource(ctx, view.folder, s, filterFunc, view.Options().ExperimentalWorkspaceModule)
+			// TODO(rfindley): can we avoid running the go command (go env)
+			// synchronously to change processing? Can we assume that the env did not
+			// change, and derive go.work using a combination of the configured
+			// GOWORK value and filesystem?
+			info, err := s.getWorkspaceInformation(ctx, view.folder, view.Options())
 			if err != nil {
-				return nil, nil, err
+				// Catastrophic failure, equivalent to a failure of session
+				// initialization and therefore should almost never happen. One
+				// scenario where this failure mode could occur is if some file
+				// permissions have changed preventing us from reading go.mod
+				// files.
+				//
+				// TODO(rfindley): consider surfacing this error more loudly. We
+				// could report a bug, but it's not really a bug.
+				event.Error(ctx, "fetching workspace information", err)
 			}
-			if src != view.rootSrc {
+
+			if info != view.workspaceInformation {
 				_, err := s.updateViewLocked(ctx, view, view.Options())
 				if err != nil {
-					// Catastrophic failure, equivalent to a failure of session
-					// initialization and therefore should almost never happen. One
-					// scenario where this failure mode could occur is if some file
-					// permissions have changed preventing us from reading go.mod
-					// files.
-					//
-					// The view may or may not still exist. The best we can do is log
-					// and move on.
-					//
-					// TODO(rfindley): consider surfacing this error more loudly. We
-					// could report a bug, but it's not really a bug.
+					// More catastrophic failure. The view may or may not still exist.
+					// The best we can do is log and move on.
 					event.Error(ctx, "recreating view", err)
 				}
 			}
@@ -695,7 +676,7 @@
 func knownDirectories(ctx context.Context, snapshots []*snapshot) knownDirsSet {
 	result := newKnownDirsSet()
 	for _, snapshot := range snapshots {
-		dirs := snapshot.workspace.dirs(ctx, snapshot)
+		dirs := snapshot.dirs(ctx)
 		for _, dir := range dirs {
 			result.Insert(dir)
 		}
diff --git a/gopls/internal/lsp/cache/snapshot.go b/gopls/internal/lsp/cache/snapshot.go
index 87f3e1c..b78962e 100644
--- a/gopls/internal/lsp/cache/snapshot.go
+++ b/gopls/internal/lsp/cache/snapshot.go
@@ -26,9 +26,6 @@
 	"sync/atomic"
 	"unsafe"
 
-	"golang.org/x/mod/modfile"
-	"golang.org/x/mod/module"
-	"golang.org/x/mod/semver"
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/gopls/internal/lsp/source"
@@ -143,12 +140,6 @@
 	modWhyHandles  *persistent.Map // from span.URI to *memoize.Promise[modWhyResult]
 	modVulnHandles *persistent.Map // from span.URI to *memoize.Promise[modVulnResult]
 
-	workspace *workspace // (not guarded by mu)
-
-	// The cached result of makeWorkspaceDir, created on demand and deleted by Snapshot.Destroy.
-	workspaceDir    string
-	workspaceDirErr error
-
 	// knownSubdirs is the set of subdirectories in the workspace, used to
 	// create glob patterns for file watching.
 	knownSubdirs             knownDirsSet
@@ -157,6 +148,15 @@
 	// subdirectories in the workspace. They are not reflected to knownSubdirs
 	// during the snapshot cloning step as it can slow down cloning.
 	unprocessedSubdirChanges []*fileChange
+
+	// workspaceModFiles holds the set of mod files active in this snapshot.
+	//
+	// This is either empty, a single entry for the workspace go.mod file, or the
+	// set of mod files used by the workspace go.work file.
+	//
+	// This set is immutable inside the snapshot, and therefore is not guarded by mu.
+	workspaceModFiles    map[span.URI]struct{}
+	workspaceModFilesErr error // error encountered computing workspaceModFiles
 }
 
 var globalSnapshotID uint64
@@ -234,12 +234,6 @@
 	s.modTidyHandles.Destroy()
 	s.modVulnHandles.Destroy()
 	s.modWhyHandles.Destroy()
-
-	if s.workspaceDir != "" {
-		if err := os.RemoveAll(s.workspaceDir); err != nil {
-			event.Error(context.Background(), "cleaning workspace dir", err)
-		}
-	}
 }
 
 func (s *snapshot) SequenceID() uint64 {
@@ -264,14 +258,14 @@
 
 func (s *snapshot) ModFiles() []span.URI {
 	var uris []span.URI
-	for modURI := range s.workspace.ActiveModFiles() {
+	for modURI := range s.workspaceModFiles {
 		uris = append(uris, modURI)
 	}
 	return uris
 }
 
 func (s *snapshot) WorkFile() span.URI {
-	return s.workspace.workFile
+	return s.view.effectiveGOWORK()
 }
 
 func (s *snapshot) Templates() map[span.URI]source.VersionedFileHandle {
@@ -295,7 +289,7 @@
 	}
 	// Check if the user is working within a module or if we have found
 	// multiple modules in the workspace.
-	if len(s.workspace.ActiveModFiles()) > 0 {
+	if len(s.workspaceModFiles) > 0 {
 		return true
 	}
 	// The user may have a multiple directories in their GOPATH.
@@ -309,8 +303,37 @@
 	return false
 }
 
+// moduleMode reports whether the current snapshot uses Go modules.
+//
+// From https://go.dev/ref/mod, module mode is active if either of the
+// following hold:
+//   - GO111MODULE=on
+//   - GO111MODULE=auto and we are inside a module or have a GOWORK value.
+//
+// Additionally, this method returns false if GOPACKAGESDRIVER is set.
+//
+// TODO(rfindley): use this more widely.
+func (s *snapshot) moduleMode() bool {
+	// 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 false
+	}
+
+	switch s.view.effectiveGO111MODULE() {
+	case on:
+		return true
+	case off:
+		return false
+	default:
+		return len(s.workspaceModFiles) > 0 || s.view.gowork != ""
+	}
+}
+
 // workspaceMode describes the way in which the snapshot's workspace should
 // be loaded.
+//
+// TODO(rfindley): remove this, in favor of specific methods.
 func (s *snapshot) workspaceMode() workspaceMode {
 	var mode workspaceMode
 
@@ -323,7 +346,7 @@
 	// If the view is not in a module and contains no modules, but still has a
 	// valid workspace configuration, do not create the workspace module.
 	// It could be using GOPATH or a different build system entirely.
-	if len(s.workspace.ActiveModFiles()) == 0 && validBuildConfiguration {
+	if len(s.workspaceModFiles) == 0 && validBuildConfiguration {
 		return mode
 	}
 	mode |= moduleMode
@@ -492,27 +515,8 @@
 	// the main (workspace) module. Otherwise, we should use the module for
 	// the passed-in working dir.
 	if mode == source.LoadWorkspace {
-		switch s.workspace.moduleSource {
-		case legacyWorkspace:
-			for m := range s.workspace.ActiveModFiles() { // range to access the only element
-				modURI = m
-			}
-		case goWorkWorkspace:
-			if s.view.goversion >= 18 {
-				break
-			}
-			// Before go 1.18, the Go command did not natively support go.work files,
-			// so we 'fake' them with a workspace module.
-			fallthrough
-		case fileSystemWorkspace, goplsModWorkspace:
-			var tmpDir span.URI
-			var err error
-			tmpDir, err = s.getWorkspaceDir(ctx)
-			if err != nil {
-				return "", nil, cleanup, err
-			}
-			inv.WorkingDir = tmpDir.Filename()
-			modURI = span.URIFromPath(filepath.Join(tmpDir.Filename(), "go.mod"))
+		if s.view.effectiveGOWORK() == "" && s.view.gomod != "" {
+			modURI = s.view.gomod
 		}
 	} else {
 		modURI = s.GoModForFile(span.URIFromPath(inv.WorkingDir))
@@ -540,6 +544,7 @@
 
 	const mutableModFlag = "mod"
 	// If the mod flag isn't set, populate it based on the mode and workspace.
+	// TODO(rfindley): this doesn't make sense if we're not in module mode
 	if inv.ModFlag == "" {
 		switch mode {
 		case source.LoadWorkspace, source.Normal:
@@ -570,8 +575,7 @@
 	//    example, if running go mod tidy in a go.work workspace)
 	//
 	// TODO(rfindley): this is very hard to follow. Refactor.
-	useWorkFile := !needTempMod && s.workspace.moduleSource == goWorkWorkspace && s.view.goversion >= 18
-	if useWorkFile {
+	if !needTempMod && s.view.gowork != "" {
 		// Since we're running in the workspace root, the go command will resolve GOWORK automatically.
 	} else if useTempMod {
 		if modURI == "" {
@@ -593,25 +597,6 @@
 	return tmpURI, inv, cleanup, nil
 }
 
-// usesWorkspaceDir reports whether the snapshot should use a synthetic
-// workspace directory for running workspace go commands such as go list.
-//
-// TODO(rfindley): this logic is duplicated with goCommandInvocation. Clean up
-// the latter, and deduplicate.
-func (s *snapshot) usesWorkspaceDir() bool {
-	switch s.workspace.moduleSource {
-	case legacyWorkspace:
-		return false
-	case goWorkWorkspace:
-		if s.view.goversion >= 18 {
-			return false
-		}
-		// Before go 1.18, the Go command did not natively support go.work files,
-		// so we 'fake' them with a workspace module.
-	}
-	return true
-}
-
 func (s *snapshot) buildOverlay() map[string][]byte {
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -827,7 +812,7 @@
 	}
 
 	// Add a pattern for each Go module in the workspace that is not within the view.
-	dirs := s.workspace.dirs(ctx, s)
+	dirs := s.dirs(ctx)
 	for _, dir := range dirs {
 		dirName := dir.Filename()
 
@@ -886,7 +871,7 @@
 // snapshot's workspace directories. None of the workspace directories are
 // included.
 func (s *snapshot) collectAllKnownSubdirs(ctx context.Context) {
-	dirs := s.workspace.dirs(ctx, s)
+	dirs := s.dirs(ctx)
 
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -1096,7 +1081,7 @@
 // TODO(rfindley): clarify that this is only active modules. Or update to just
 // use findRootPattern.
 func (s *snapshot) GoModForFile(uri span.URI) span.URI {
-	return moduleForURI(s.workspace.activeModFiles, uri)
+	return moduleForURI(s.workspaceModFiles, uri)
 }
 
 func moduleForURI(modFiles map[span.URI]struct{}, uri span.URI) span.URI {
@@ -1249,8 +1234,12 @@
 }
 
 func (s *snapshot) GetCriticalError(ctx context.Context) *source.CriticalError {
-	if wsErr := s.workspace.criticalError(ctx, s); wsErr != nil {
-		return wsErr
+	// If we couldn't compute workspace mod files, then the load below is
+	// invalid.
+	//
+	// TODO(rfindley): is this a clear error to present to the user?
+	if s.workspaceModFilesErr != nil {
+		return &source.CriticalError{MainError: s.workspaceModFilesErr}
 	}
 
 	loadErr := s.awaitLoadedAllErrors(ctx)
@@ -1559,10 +1548,45 @@
 	ctx, done := event.Start(ctx, "snapshot.clone")
 	defer done()
 
-	newWorkspace, reinit := s.workspace.Clone(ctx, changes, &unappliedChanges{
-		originalSnapshot: s,
-		changes:          changes,
-	})
+	reinit := false
+	wsModFiles, wsModFilesErr := s.workspaceModFiles, s.workspaceModFilesErr
+
+	if workURI := s.view.effectiveGOWORK(); workURI != "" {
+		if change, ok := changes[workURI]; ok {
+			wsModFiles, wsModFilesErr = computeWorkspaceModFiles(ctx, s.view.gomod, workURI, s.view.effectiveGO111MODULE(), &unappliedChanges{
+				originalSnapshot: s,
+				changes:          changes,
+			})
+			// TODO(rfindley): don't rely on 'isUnchanged' here. Use a content hash instead.
+			reinit = change.fileHandle.Saved() && !change.isUnchanged
+		}
+	}
+
+	// Reinitialize if any workspace mod file has changed on disk.
+	for uri, change := range changes {
+		if _, ok := wsModFiles[uri]; ok && change.fileHandle.Saved() && !change.isUnchanged {
+			reinit = true
+		}
+	}
+
+	// Finally, process sumfile changes that may affect loading.
+	for uri, change := range changes {
+		if !change.fileHandle.Saved() {
+			continue // like with go.mod files, we only reinit when things are saved
+		}
+		if filepath.Base(uri.Filename()) == "go.work.sum" && s.view.gowork != "" {
+			if filepath.Dir(uri.Filename()) == filepath.Dir(s.view.gowork) {
+				reinit = true
+			}
+		}
+		if filepath.Base(uri.Filename()) == "go.sum" {
+			dir := filepath.Dir(uri.Filename())
+			modURI := span.URIFromPath(filepath.Join(dir, "go.mod"))
+			if _, active := wsModFiles[modURI]; active {
+				reinit = true
+			}
+		}
+	}
 
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -1606,7 +1630,8 @@
 		modWhyHandles:        s.modWhyHandles.Clone(),
 		modVulnHandles:       s.modVulnHandles.Clone(),
 		knownSubdirs:         s.knownSubdirs.Clone(),
-		workspace:            newWorkspace,
+		workspaceModFiles:    wsModFiles,
+		workspaceModFilesErr: wsModFilesErr,
 	}
 
 	// The snapshot should be initialized if either s was uninitialized, or we've
@@ -2176,190 +2201,3 @@
 
 	s.builtin = span.URIFromPath(path)
 }
-
-// BuildGoplsMod generates a go.mod file for all modules in the workspace. It
-// bypasses any existing gopls.mod.
-func (s *snapshot) BuildGoplsMod(ctx context.Context) (*modfile.File, error) {
-	allModules, err := findModules(s.view.folder, pathExcludedByFilterFunc(s.view.folder.Filename(), s.view.gomodcache, s.View().Options()), 0)
-	if err != nil {
-		return nil, err
-	}
-	return buildWorkspaceModFile(ctx, allModules, s)
-}
-
-// TODO(rfindley): move this to workspace.go
-func buildWorkspaceModFile(ctx context.Context, modFiles map[span.URI]struct{}, fs source.FileSource) (*modfile.File, error) {
-	file := &modfile.File{}
-	file.AddModuleStmt("gopls-workspace")
-	// Track the highest Go version, to be set on the workspace module.
-	// Fall back to 1.12 -- old versions insist on having some version.
-	goVersion := "1.12"
-
-	paths := map[string]span.URI{}
-	excludes := map[string][]string{}
-	var sortedModURIs []span.URI
-	for uri := range modFiles {
-		sortedModURIs = append(sortedModURIs, uri)
-	}
-	sort.Slice(sortedModURIs, func(i, j int) bool {
-		return sortedModURIs[i] < sortedModURIs[j]
-	})
-	for _, modURI := range sortedModURIs {
-		fh, err := fs.GetFile(ctx, modURI)
-		if err != nil {
-			return nil, err
-		}
-		content, err := fh.Read()
-		if err != nil {
-			return nil, err
-		}
-		parsed, err := modfile.Parse(fh.URI().Filename(), content, nil)
-		if err != nil {
-			return nil, err
-		}
-		if file == nil || parsed.Module == nil {
-			return nil, fmt.Errorf("no module declaration for %s", modURI)
-		}
-		// Prepend "v" to go versions to make them valid semver.
-		if parsed.Go != nil && semver.Compare("v"+goVersion, "v"+parsed.Go.Version) < 0 {
-			goVersion = parsed.Go.Version
-		}
-		path := parsed.Module.Mod.Path
-		if seen, ok := paths[path]; ok {
-			return nil, fmt.Errorf("found module %q multiple times in the workspace, at:\n\t%q\n\t%q", path, seen, modURI)
-		}
-		paths[path] = modURI
-		// If the module's path includes a major version, we expect it to have
-		// a matching major version.
-		_, majorVersion, _ := module.SplitPathVersion(path)
-		if majorVersion == "" {
-			majorVersion = "/v0"
-		}
-		majorVersion = strings.TrimLeft(majorVersion, "/.") // handle gopkg.in versions
-		file.AddNewRequire(path, source.WorkspaceModuleVersion(majorVersion), false)
-		if err := file.AddReplace(path, "", span.Dir(modURI).Filename(), ""); err != nil {
-			return nil, err
-		}
-		for _, exclude := range parsed.Exclude {
-			excludes[exclude.Mod.Path] = append(excludes[exclude.Mod.Path], exclude.Mod.Version)
-		}
-	}
-	if goVersion != "" {
-		file.AddGoStmt(goVersion)
-	}
-	// Go back through all of the modules to handle any of their replace
-	// statements.
-	for _, modURI := range sortedModURIs {
-		fh, err := fs.GetFile(ctx, modURI)
-		if err != nil {
-			return nil, err
-		}
-		content, err := fh.Read()
-		if err != nil {
-			return nil, err
-		}
-		parsed, err := modfile.Parse(fh.URI().Filename(), content, nil)
-		if err != nil {
-			return nil, err
-		}
-		// If any of the workspace modules have replace directives, they need
-		// to be reflected in the workspace module.
-		for _, rep := range parsed.Replace {
-			// Don't replace any modules that are in our workspace--we should
-			// always use the version in the workspace.
-			if _, ok := paths[rep.Old.Path]; ok {
-				continue
-			}
-			newPath := rep.New.Path
-			newVersion := rep.New.Version
-			// If a replace points to a module in the workspace, make sure we
-			// direct it to version of the module in the workspace.
-			if m, ok := paths[rep.New.Path]; ok {
-				newPath = span.Dir(m).Filename()
-				newVersion = ""
-			} else if rep.New.Version == "" && !filepath.IsAbs(rep.New.Path) {
-				// Make any relative paths absolute.
-				newPath = filepath.Join(span.Dir(modURI).Filename(), rep.New.Path)
-			}
-			if err := file.AddReplace(rep.Old.Path, rep.Old.Version, newPath, newVersion); err != nil {
-				return nil, err
-			}
-		}
-	}
-	for path, versions := range excludes {
-		for _, version := range versions {
-			file.AddExclude(path, version)
-		}
-	}
-	file.SortBlocks()
-	return file, nil
-}
-
-func buildWorkspaceSumFile(ctx context.Context, modFiles map[span.URI]struct{}, fs source.FileSource) ([]byte, error) {
-	allSums := map[module.Version][]string{}
-	for modURI := range modFiles {
-		// TODO(rfindley): factor out this pattern into a uripath package.
-		sumURI := span.URIFromPath(filepath.Join(filepath.Dir(modURI.Filename()), "go.sum"))
-		fh, err := fs.GetFile(ctx, sumURI)
-		if err != nil {
-			continue
-		}
-		data, err := fh.Read()
-		if os.IsNotExist(err) {
-			continue
-		}
-		if err != nil {
-			return nil, fmt.Errorf("reading go sum: %w", err)
-		}
-		if err := readGoSum(allSums, sumURI.Filename(), data); err != nil {
-			return nil, err
-		}
-	}
-	// This logic to write go.sum is copied (with minor modifications) from
-	// https://cs.opensource.google/go/go/+/master:src/cmd/go/internal/modfetch/fetch.go;l=631;drc=762eda346a9f4062feaa8a9fc0d17d72b11586f0
-	var mods []module.Version
-	for m := range allSums {
-		mods = append(mods, m)
-	}
-	module.Sort(mods)
-
-	var buf bytes.Buffer
-	for _, m := range mods {
-		list := allSums[m]
-		sort.Strings(list)
-		// Note (rfindley): here we add all sum lines without verification, because
-		// the assumption is that if they come from a go.sum file, they are
-		// trusted.
-		for _, h := range list {
-			fmt.Fprintf(&buf, "%s %s %s\n", m.Path, m.Version, h)
-		}
-	}
-	return buf.Bytes(), nil
-}
-
-// readGoSum is copied (with minor modifications) from
-// https://cs.opensource.google/go/go/+/master:src/cmd/go/internal/modfetch/fetch.go;l=398;drc=762eda346a9f4062feaa8a9fc0d17d72b11586f0
-func readGoSum(dst map[module.Version][]string, file string, data []byte) error {
-	lineno := 0
-	for len(data) > 0 {
-		var line []byte
-		lineno++
-		i := bytes.IndexByte(data, '\n')
-		if i < 0 {
-			line, data = data, nil
-		} else {
-			line, data = data[:i], data[i+1:]
-		}
-		f := strings.Fields(string(line))
-		if len(f) == 0 {
-			// blank line; skip it
-			continue
-		}
-		if len(f) != 3 {
-			return fmt.Errorf("malformed go.sum:\n%s:%d: wrong number of fields %v", file, lineno, len(f))
-		}
-		mod := module.Version{Path: f[0], Version: f[1]}
-		dst[mod] = append(dst[mod], f[2])
-	}
-	return nil
-}
diff --git a/gopls/internal/lsp/cache/view.go b/gopls/internal/lsp/cache/view.go
index 5703b30..a4c87c3 100644
--- a/gopls/internal/lsp/cache/view.go
+++ b/gopls/internal/lsp/cache/view.go
@@ -54,7 +54,6 @@
 	// options define the build list. Any change to these fields results in a new
 	// View.
 	folder               span.URI // user-specified workspace folder
-	rootSrc              span.URI // file providing module information (go.mod or go.work); may be empty
 	workspaceInformation          // Go environment information
 
 	importsState *importsState
@@ -109,10 +108,16 @@
 	initializationSema chan struct{}
 }
 
+// workspaceInformation holds the defining features of the View workspace.
+//
+// This type is compared to see if the View needs to be reconstructed.
 type workspaceInformation struct {
 	// `go env` variables that need to be tracked by gopls.
 	goEnv
 
+	// gomod holds the relevant go.mod file for this workspace.
+	gomod span.URI
+
 	// The Go version in use: X in Go 1.X.
 	goversion int
 
@@ -528,16 +533,14 @@
 	if v.knownFile(c.URI) {
 		return true
 	}
-	// The go.work/gopls.mod may not be "known" because we first access it
-	// through the session. As a result, treat changes to the view's go.work or
-	// gopls.mod file as always relevant, even if they are only on-disk
-	// changes.
-	// TODO(rstambler): Make sure the go.work/gopls.mod files are always known
+	// The go.work file may not be "known" because we first access it through the
+	// session. As a result, treat changes to the view's go.work file as always
+	// relevant, even if they are only on-disk changes.
+	//
+	// TODO(rfindley): Make sure the go.work files are always known
 	// to the view.
-	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
-		if c.URI == uriForSource(v.workingDir(), v.effectiveGOWORK(), src) {
-			return true
-		}
+	if c.URI == v.effectiveGOWORK() {
+		return true
 	}
 
 	// Note: CL 219202 filtered out on-disk changes here that were not known to
@@ -633,20 +636,19 @@
 	}
 	v.snapshotMu.Unlock()
 
-	v.importsState.destroy()
 	v.snapshotWG.Wait()
 }
 
 func (s *snapshot) IgnoredFile(uri span.URI) bool {
 	filename := uri.Filename()
 	var prefixes []string
-	if len(s.workspace.ActiveModFiles()) == 0 {
+	if len(s.workspaceModFiles) == 0 {
 		for _, entry := range filepath.SplitList(s.view.gopath) {
 			prefixes = append(prefixes, filepath.Join(entry, "src"))
 		}
 	} else {
 		prefixes = append(prefixes, s.view.gomodcache)
-		for m := range s.workspace.ActiveModFiles() {
+		for m := range s.workspaceModFiles {
 			prefixes = append(prefixes, span.Dir(m).Filename())
 		}
 	}
@@ -749,8 +751,8 @@
 		})
 	}
 
-	if len(s.workspace.ActiveModFiles()) > 0 {
-		for modURI := range s.workspace.ActiveModFiles() {
+	if len(s.workspaceModFiles) > 0 {
+		for modURI := range s.workspaceModFiles {
 			// Be careful not to add context cancellation errors as critical module
 			// errors.
 			fh, err := s.GetFile(ctx, modURI)
@@ -887,39 +889,33 @@
 	tool, _ := exec.LookPath("gopackagesdriver")
 	info.hasGopackagesDriver = gopackagesdriver != "off" && (gopackagesdriver != "" || tool != "")
 
+	// filterFunc is the path filter function for this workspace folder. Notably,
+	// it is relative to folder (which is specified by the user), not root.
+	filterFunc := pathExcludedByFilterFunc(folder.Filename(), info.gomodcache, options)
+	info.gomod, err = findWorkspaceModFile(ctx, folder, s, filterFunc)
+	if err != nil {
+		return info, err
+	}
+
 	return info, nil
 }
 
-// findWorkspaceModuleSource searches for a "module source" relative to the
-// given folder URI. A module source is the go.work or go.mod file that
-// provides module information.
-//
-// As a special case, this function returns a module source in a nested
-// directory if it finds no other module source, and exactly one nested module.
-//
-// If no module source is found, it returns "".
-func findWorkspaceModuleSource(ctx context.Context, folderURI span.URI, fs source.FileSource, excludePath func(string) bool, experimental bool) (span.URI, error) {
-	patterns := []string{"go.work", "go.mod"}
-	if experimental {
-		patterns = []string{"go.work", "gopls.mod", "go.mod"}
-	}
+// findWorkspaceModFile searches for a single go.mod file relative to the given
+// folder URI, using the following algorithm:
+//  1. if there is a go.mod file in a parent directory, return it
+//  2. else, if there is exactly one nested module, return it
+//  3. else, return ""
+func findWorkspaceModFile(ctx context.Context, folderURI span.URI, fs source.FileSource, excludePath func(string) bool) (span.URI, error) {
 	folder := folderURI.Filename()
-	for _, basename := range patterns {
-		match, err := findRootPattern(ctx, folder, basename, fs)
-		if err != nil {
-			if ctxErr := ctx.Err(); ctxErr != nil {
-				return "", ctxErr
-			}
-			return "", err
+	match, err := findRootPattern(ctx, folder, "go.mod", fs)
+	if err != nil {
+		if ctxErr := ctx.Err(); ctxErr != nil {
+			return "", ctxErr
 		}
-		if match != "" {
-			return span.URIFromPath(match), nil
-		}
+		return "", err
 	}
-
-	// The experimental workspace can handle nested modules at this point...
-	if experimental {
-		return "", nil
+	if match != "" {
+		return span.URIFromPath(match), nil
 	}
 
 	// ...else we should check if there's exactly one nested module.
@@ -948,9 +944,14 @@
 // a singular nested module. In that case, the go command won't be able to find
 // the module unless we tell it the nested directory.
 func (v *View) workingDir() span.URI {
-	// TODO(golang/go#57514): eliminate the expandWorkspaceToModule setting.
-	if v.Options().ExpandWorkspaceToModule && v.rootSrc != "" {
-		return span.Dir(v.rootSrc)
+	// Note: if gowork is in use, this will default to the workspace folder. In
+	// the past, we would instead use the folder containing go.work. This should
+	// not make a difference, and in fact may improve go list error messages.
+	//
+	// TODO(golang/go#57514): eliminate the expandWorkspaceToModule setting
+	// entirely.
+	if v.Options().ExpandWorkspaceToModule && v.gomod != "" {
+		return span.Dir(v.gomod)
 	}
 	return v.folder
 }
diff --git a/gopls/internal/lsp/cache/view_test.go b/gopls/internal/lsp/cache/view_test.go
index 617dc31..8adfbfa 100644
--- a/gopls/internal/lsp/cache/view_test.go
+++ b/gopls/internal/lsp/cache/view_test.go
@@ -53,7 +53,7 @@
 	}
 }
 
-func TestFindWorkspaceModuleSource(t *testing.T) {
+func TestFindWorkspaceModFile(t *testing.T) {
 	workspace := `
 -- a/go.mod --
 module a
@@ -71,10 +71,6 @@
 module de
 -- f/g/go.mod --
 module fg
--- h/go.work --
-go 1.18
--- h/i/go.mod --
-module hi
 `
 	dir, err := fake.Tempdir(fake.UnpackTxt(workspace))
 	if err != nil {
@@ -84,21 +80,15 @@
 
 	tests := []struct {
 		folder, want string
-		experimental bool
 	}{
-		{"", "", false}, // no module at root, and more than one nested module
-		{"a", "a/go.mod", false},
-		{"a/x", "a/go.mod", false},
-		{"a/x/y", "a/go.mod", false},
-		{"b/c", "b/c/go.mod", false},
-		{"d", "d/e/go.mod", false},
-		{"d", "d/gopls.mod", true},
-		{"d/e", "d/e/go.mod", false},
-		{"d/e", "d/gopls.mod", true},
-		{"f", "f/g/go.mod", false},
-		{"f", "", true},
-		{"h", "h/go.work", false},
-		{"h/i", "h/go.work", false},
+		{"", ""}, // no module at root, and more than one nested module
+		{"a", "a/go.mod"},
+		{"a/x", "a/go.mod"},
+		{"a/x/y", "a/go.mod"},
+		{"b/c", "b/c/go.mod"},
+		{"d", "d/e/go.mod"},
+		{"d/e", "d/e/go.mod"},
+		{"f", "f/g/go.mod"},
 	}
 
 	for _, test := range tests {
@@ -106,7 +96,7 @@
 		rel := fake.RelativeTo(dir)
 		folderURI := span.URIFromPath(rel.AbsPath(test.folder))
 		excludeNothing := func(string) bool { return false }
-		got, err := findWorkspaceModuleSource(ctx, folderURI, &osFileSource{}, excludeNothing, test.experimental)
+		got, err := findWorkspaceModFile(ctx, folderURI, &osFileSource{}, excludeNothing)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -115,7 +105,7 @@
 			want = span.URIFromPath(rel.AbsPath(test.want))
 		}
 		if got != want {
-			t.Errorf("findWorkspaceModuleSource(%q, %t) = %q, want %q", test.folder, test.experimental, got, want)
+			t.Errorf("findWorkspaceModFile(%q) = %q, want %q", test.folder, got, want)
 		}
 	}
 }
diff --git a/gopls/internal/lsp/cache/workspace.go b/gopls/internal/lsp/cache/workspace.go
index da2abdb..e9845e8 100644
--- a/gopls/internal/lsp/cache/workspace.go
+++ b/gopls/internal/lsp/cache/workspace.go
@@ -12,476 +12,85 @@
 	"path/filepath"
 	"sort"
 	"strings"
-	"sync"
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/tools/gopls/internal/lsp/source"
 	"golang.org/x/tools/gopls/internal/span"
-	"golang.org/x/tools/internal/event"
-	"golang.org/x/tools/internal/xcontext"
 )
 
-// workspaceSource reports how the set of active modules has been derived.
-type workspaceSource int
+// TODO(rfindley): now that experimentalWorkspaceModule is gone, this file can
+// be massively cleaned up and/or removed.
 
-const (
-	legacyWorkspace     = iota // non-module or single module mode
-	goplsModWorkspace          // modules provided by a gopls.mod file
-	goWorkWorkspace            // modules provided by a go.work file
-	fileSystemWorkspace        // modules found by walking the filesystem
-)
-
-func (s workspaceSource) String() string {
-	switch s {
-	case legacyWorkspace:
-		return "legacy"
-	case goplsModWorkspace:
-		return "gopls.mod"
-	case goWorkWorkspace:
-		return "go.work"
-	case fileSystemWorkspace:
-		return "file system"
-	default:
-		return "!(unknown module source)"
+// computeWorkspaceModFiles computes the set of workspace mod files based on the
+// value of go.mod, go.work, and GO111MODULE.
+func computeWorkspaceModFiles(ctx context.Context, gomod, gowork span.URI, go111module go111module, fs source.FileSource) (map[span.URI]struct{}, error) {
+	if go111module == off {
+		return nil, nil
 	}
-}
-
-// 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.
-//
-// Specifically, it provides:
-//   - the set of modules contained within in the workspace root considered to
-//     be 'active'
-//   - the workspace modfile, to be used for the go command `-modfile` flag
-//   - the set of workspace directories
-//
-// This type is immutable (or rather, idempotent), so that it may be shared
-// across multiple snapshots.
-type workspace struct {
-	workspaceCommon
-
-	// The source of modules in this workspace.
-	moduleSource workspaceSource
-
-	// activeModFiles holds the active go.mod files.
-	activeModFiles map[span.URI]struct{}
-
-	// knownModFiles holds the set of all go.mod files in the workspace.
-	// In all modes except for legacy, this is equivalent to modFiles.
-	knownModFiles map[span.URI]struct{}
-
-	// workFile, if nonEmpty, is the go.work file for the workspace.
-	workFile span.URI
-
-	// The workspace module is lazily re-built once after being invalidated.
-	// buildMu+built guards this reconstruction.
-	//
-	// file and wsDirs may be non-nil even if built == false, if they were copied
-	// from the previous workspace module version. In this case, they will be
-	// preserved if building fails.
-	buildMu  sync.Mutex
-	built    bool
-	buildErr error
-	mod      *modfile.File
-	sum      []byte
-	wsDirs   map[span.URI]struct{}
-}
-
-// newWorkspace creates a new workspace at the given root directory,
-// determining its module source based on the presence of a gopls.mod or
-// go.work file, and the go111moduleOff and useWsModule settings.
-//
-// If useWsModule is set, the workspace may use a synthetic mod file replacing
-// all modules in the root.
-//
-// If there is no active workspace file (a gopls.mod or go.work), newWorkspace
-// scans the filesystem to find modules.
-//
-// TODO(rfindley): newWorkspace should perhaps never fail, relying instead on
-// the criticalError method to surface problems in the workspace.
-func newWorkspace(ctx context.Context, root, explicitGowork span.URI, fs source.FileSource, excludePath func(string) bool, go111moduleOff, useWsModule bool) (*workspace, error) {
-	ws := &workspace{
-		workspaceCommon: workspaceCommon{
-			root:           root,
-			explicitGowork: explicitGowork,
-			excludePath:    excludePath,
-		},
-	}
-
-	// The user may have a gopls.mod or go.work file that defines their
-	// workspace.
-	//
-	// 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
-	}
-
-	// Otherwise, in all other modes, search for all of the go.mod files in the
-	// workspace.
-	knownModFiles, err := findModules(root, excludePath, 0)
-	if err != nil {
-		return nil, err
-	}
-	ws.knownModFiles = knownModFiles
-
-	switch {
-	case go111moduleOff:
-		ws.moduleSource = legacyWorkspace
-	case useWsModule:
-		ws.activeModFiles = knownModFiles
-		ws.moduleSource = fileSystemWorkspace
-	default:
-		ws.moduleSource = legacyWorkspace
-		activeModFiles, err := getLegacyModules(ctx, root, fs)
+	if gowork != "" {
+		fh, err := fs.GetFile(ctx, gowork)
 		if err != nil {
 			return nil, err
 		}
-		ws.activeModFiles = activeModFiles
-	}
-	return ws, nil
-}
-
-// loadExplicitWorkspaceFile loads workspace information from go.work or
-// gopls.mod files, setting the active modules, mod file, and module source
-// accordingly.
-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, ws.explicitGowork, src))
+		content, err := fh.Read()
 		if err != nil {
-			return err
+			return nil, err
 		}
-		contents, err := fh.Read()
+		filename := gowork.Filename()
+		dir := filepath.Dir(filename)
+		workFile, err := modfile.ParseWork(filename, content, nil)
 		if err != nil {
-			continue // TODO(rfindley): is it correct to proceed here?
+			return nil, fmt.Errorf("parsing go.work: %w", err)
 		}
-		var file *modfile.File
-		var activeModFiles map[span.URI]struct{}
-		switch src {
-		case goWorkWorkspace:
-			file, activeModFiles, err = parseGoWork(ctx, ws.root, fh.URI(), contents, fs)
-			ws.workFile = fh.URI()
-		case goplsModWorkspace:
-			file, activeModFiles, err = parseGoplsMod(ws.root, fh.URI(), contents)
-		}
-		if err != nil {
-			ws.buildMu.Lock()
-			ws.built = true
-			ws.buildErr = err
-			ws.buildMu.Unlock()
-		}
-		ws.mod = file
-		ws.activeModFiles = activeModFiles
-		ws.moduleSource = src
-		return nil
-	}
-	return noHardcodedWorkspace
-}
-
-var noHardcodedWorkspace = errors.New("no hardcoded workspace")
-
-// TODO(rfindley): eliminate getKnownModFiles.
-func (w *workspace) getKnownModFiles() map[span.URI]struct{} {
-	return w.knownModFiles
-}
-
-// ActiveModFiles returns the set of active mod files for the current workspace.
-func (w *workspace) ActiveModFiles() map[span.URI]struct{} {
-	return w.activeModFiles
-}
-
-// criticalError returns a critical error related to the workspace setup.
-func (w *workspace) criticalError(ctx context.Context, fs source.FileSource) (res *source.CriticalError) {
-	// For now, we narrowly report errors related to `go.work` files.
-	//
-	// TODO(rfindley): investigate whether other workspace validation errors
-	// can be consolidated here.
-	if w.moduleSource == goWorkWorkspace {
-		// We should have already built the modfile, but build here to be
-		// consistent about accessing w.mod after w.build.
-		//
-		// TODO(rfindley): build eagerly. Building lazily is a premature
-		// optimization that poses a significant burden on the code.
-		w.build(ctx, fs)
-		if w.buildErr != nil {
-			return &source.CriticalError{
-				MainError: w.buildErr,
+		modFiles := make(map[span.URI]struct{})
+		for _, use := range workFile.Use {
+			modDir := filepath.FromSlash(use.Path)
+			if !filepath.IsAbs(modDir) {
+				modDir = filepath.Join(dir, modDir)
 			}
+			modURI := span.URIFromPath(filepath.Join(modDir, "go.mod"))
+			modFiles[modURI] = struct{}{}
 		}
+		return modFiles, nil
 	}
-	return nil
-}
-
-// modFile gets the workspace modfile associated with this workspace,
-// computing it if it doesn't exist.
-//
-// A fileSource must be passed in to solve a chicken-egg problem: it is not
-// correct to pass in the snapshot file source to newWorkspace when
-// invalidating, because at the time these are called the snapshot is locked.
-// So we must pass it in later on when actually using the modFile.
-func (w *workspace) modFile(ctx context.Context, fs source.FileSource) (*modfile.File, error) {
-	w.build(ctx, fs)
-	return w.mod, w.buildErr
-}
-
-func (w *workspace) sumFile(ctx context.Context, fs source.FileSource) ([]byte, error) {
-	w.build(ctx, fs)
-	return w.sum, w.buildErr
-}
-
-func (w *workspace) build(ctx context.Context, fs source.FileSource) {
-	w.buildMu.Lock()
-	defer w.buildMu.Unlock()
-
-	if w.built {
-		return
+	if gomod != "" {
+		return map[span.URI]struct{}{gomod: {}}, nil
 	}
-	// Building should never be cancelled. Since the workspace module is shared
-	// across multiple snapshots, doing so would put us in a bad state, and it
-	// would not be obvious to the user how to recover.
-	ctx = xcontext.Detach(ctx)
-
-	// If the module source is from the filesystem, try to build the workspace
-	// module from active modules discovered by scanning the filesystem. Fall
-	// back on the pre-existing mod file if parsing fails.
-	if w.moduleSource == fileSystemWorkspace {
-		file, err := buildWorkspaceModFile(ctx, w.activeModFiles, fs)
-		switch {
-		case err == nil:
-			w.mod = file
-		case w.mod != nil:
-			// Parsing failed, but we have a previous file version.
-			event.Error(ctx, "building workspace mod file", err)
-		default:
-			// No file to fall back on.
-			w.buildErr = err
-		}
-	}
-
-	if w.mod != nil {
-		w.wsDirs = map[span.URI]struct{}{
-			w.root: {},
-		}
-		for _, r := range w.mod.Replace {
-			// We may be replacing a module with a different version, not a path
-			// on disk.
-			if r.New.Version != "" {
-				continue
-			}
-			w.wsDirs[span.URIFromPath(r.New.Path)] = struct{}{}
-		}
-	}
-
-	// Ensure that there is always at least the root dir.
-	if len(w.wsDirs) == 0 {
-		w.wsDirs = map[span.URI]struct{}{
-			w.root: {},
-		}
-	}
-
-	sum, err := buildWorkspaceSumFile(ctx, w.activeModFiles, fs)
-	if err == nil {
-		w.sum = sum
-	} else {
-		event.Error(ctx, "building workspace sum file", err)
-	}
-
-	w.built = true
+	return nil, nil
 }
 
 // dirs returns the workspace directories for the loaded modules.
-func (w *workspace) dirs(ctx context.Context, fs source.FileSource) []span.URI {
-	w.build(ctx, fs)
+//
+// A workspace directory is, roughly speaking, a directory for which we care
+// about file changes. This is used for the purpose of registering file
+// watching patterns, and expanding directory modifications to their adjacent
+// files.
+//
+// TODO(rfindley): move this to snapshot.go.
+// TODO(rfindley): can we make this abstraction simpler and/or more accurate?
+func (s *snapshot) dirs(ctx context.Context) []span.URI {
+	dirSet := make(map[span.URI]struct{})
+
+	// Dirs should, at the very least, contain the working directory and folder.
+	dirSet[s.view.workingDir()] = struct{}{}
+	dirSet[s.view.folder] = struct{}{}
+
+	// Additionally, if e.g. go.work indicates other workspace modules, we should
+	// include their directories too.
+	if s.workspaceModFilesErr == nil {
+		for modFile := range s.workspaceModFiles {
+			dir := filepath.Dir(modFile.Filename())
+			dirSet[span.URIFromPath(dir)] = struct{}{}
+		}
+	}
 	var dirs []span.URI
-	for d := range w.wsDirs {
+	for d := range dirSet {
 		dirs = append(dirs, d)
 	}
 	sort.Slice(dirs, func(i, j int) bool { return dirs[i] < dirs[j] })
 	return dirs
 }
 
-// Clone returns a (possibly) new workspace after invalidating the changed
-// files. If w is still valid in the presence of changedURIs, it returns itself
-// unmodified.
-//
-// The returned needReinit flag indicates to the caller that the workspace
-// needs to be reinitialized (because a relevant go.mod or go.work file has
-// been changed).
-//
-// TODO(rfindley): it looks wrong that we return 'needReinit' here. The caller
-// should determine whether to re-initialize..
-func (w *workspace) Clone(ctx context.Context, changes map[span.URI]*fileChange, fs source.FileSource) (_ *workspace, needReinit bool) {
-	// Prevent races to w.modFile or w.wsDirs below, if w has not yet been built.
-	w.buildMu.Lock()
-	defer w.buildMu.Unlock()
-
-	// Clone the workspace. This may be discarded if nothing changed.
-	changed := false
-	result := &workspace{
-		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
-	}
-	for k, v := range w.activeModFiles {
-		result.activeModFiles[k] = v
-	}
-
-	equalURI := func(a, b span.URI) (r bool) {
-		// This query is a strange mix of syntax and file system state:
-		// deletion of a file causes a false result if the name doesn't change.
-		// Our tests exercise only the first clause.
-		return a == b || span.SameExistingFile(a, b)
-	}
-
-	// First handle changes to the go.work or gopls.mod file. This must be
-	// considered before any changes to go.mod or go.sum files, as these files
-	// determine which modules we care about. If go.work/gopls.mod has changed
-	// we need to either re-read it if it exists or walk the filesystem if it
-	// has been deleted. go.work should override the gopls.mod if both exist.
-	changed, needReinit = handleWorkspaceFileChanges(ctx, result, changes, fs)
-	// Next, handle go.mod changes that could affect our workspace.
-	for uri, change := range changes {
-		// Otherwise, we only care about go.mod files in the workspace directory.
-		if change.isUnchanged || !isGoMod(uri) || !source.InDir(result.root.Filename(), uri.Filename()) {
-			continue
-		}
-		changed = true
-		active := result.moduleSource != legacyWorkspace || equalURI(modURI(w.root), uri)
-		needReinit = needReinit || (active && change.fileHandle.Saved())
-		// Don't mess with the list of mod files if using go.work or gopls.mod.
-		if result.moduleSource == goplsModWorkspace || result.moduleSource == goWorkWorkspace {
-			continue
-		}
-		if change.exists {
-			result.knownModFiles[uri] = struct{}{}
-			if active {
-				result.activeModFiles[uri] = struct{}{}
-			}
-		} else {
-			delete(result.knownModFiles, uri)
-			delete(result.activeModFiles, uri)
-		}
-	}
-
-	// Finally, process go.sum changes for any modules that are now active.
-	for uri, change := range changes {
-		if !isGoSum(uri) {
-			continue
-		}
-		// TODO(rFindley) factor out this URI mangling.
-		dir := filepath.Dir(uri.Filename())
-		modURI := span.URIFromPath(filepath.Join(dir, "go.mod"))
-		if _, active := result.activeModFiles[modURI]; !active {
-			continue
-		}
-		// Only changes to active go.sum files actually cause the workspace to
-		// change.
-		changed = true
-		needReinit = needReinit || change.fileHandle.Saved()
-	}
-
-	if !changed {
-		return w, false
-	}
-
-	return result, needReinit
-}
-
-// handleWorkspaceFileChanges handles changes related to a go.work or gopls.mod
-// file, updating ws accordingly. ws.root must be set.
-func handleWorkspaceFileChanges(ctx context.Context, ws *workspace, changes map[span.URI]*fileChange, fs source.FileSource) (changed, reload bool) {
-	if ws.moduleSource != goWorkWorkspace && ws.moduleSource != goplsModWorkspace {
-		return false, false
-	}
-
-	uri := uriForSource(ws.root, ws.explicitGowork, ws.moduleSource)
-	// File opens/closes are just no-ops.
-	change, ok := changes[uri]
-	if !ok || change.isUnchanged {
-		return false, false
-	}
-	if change.exists {
-		// Only invalidate if the file if it actually parses.
-		// Otherwise, stick with the current file.
-		var parsedFile *modfile.File
-		var parsedModules map[span.URI]struct{}
-		var err error
-		switch ws.moduleSource {
-		case goWorkWorkspace:
-			parsedFile, parsedModules, err = parseGoWork(ctx, ws.root, uri, change.content, fs)
-		case goplsModWorkspace:
-			parsedFile, parsedModules, err = parseGoplsMod(ws.root, uri, change.content)
-		}
-		if err != nil {
-			// An unparseable file should not invalidate the workspace:
-			// nothing good could come from changing the workspace in
-			// this case.
-			//
-			// TODO(rfindley): well actually, it could potentially lead to a better
-			// critical error. Evaluate whether we can unify this case with the
-			// error returned by newWorkspace, without needlessly invalidating
-			// metadata.
-			event.Error(ctx, fmt.Sprintf("parsing %s", filepath.Base(uri.Filename())), err)
-		} else {
-			// only update the modfile if it parsed.
-			changed = true
-			reload = change.fileHandle.Saved()
-			ws.mod = parsedFile
-			ws.knownModFiles = parsedModules
-			ws.activeModFiles = make(map[span.URI]struct{})
-			for k, v := range parsedModules {
-				ws.activeModFiles[k] = v
-			}
-		}
-		return changed, reload
-	}
-	// go.work/gopls.mod is deleted. We should never see this as the view should have been recreated.
-	panic(fmt.Sprintf("internal error: workspace file %q deleted without reinitialization", uri))
-}
-
-// goplsModURI returns the URI for the gopls.mod file contained in root.
-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 ""
-	}
-	return span.URIFromPath(filepath.Join(root.Filename(), basename))
-}
-
-// modURI returns the URI for the go.mod file contained in root.
-func modURI(root span.URI) span.URI {
-	return span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
-}
-
 // isGoMod reports if uri is a go.mod file.
 func isGoMod(uri span.URI) bool {
 	return filepath.Base(uri.Filename()) == "go.mod"
@@ -492,11 +101,6 @@
 	return filepath.Base(uri.Filename()) == "go.work"
 }
 
-// isGoSum reports if uri is a go.sum or go.work.sum file.
-func isGoSum(uri span.URI) bool {
-	return filepath.Base(uri.Filename()) == "go.sum" || filepath.Base(uri.Filename()) == "go.work.sum"
-}
-
 // fileExists reports if the file uri exists within source.
 func fileExists(ctx context.Context, uri span.URI, source source.FileSource) (bool, error) {
 	fh, err := source.GetFile(ctx, uri)
@@ -518,80 +122,6 @@
 	return false, err
 }
 
-// getLegacyModules returns a module set containing at most the root module.
-func getLegacyModules(ctx context.Context, root span.URI, fs source.FileSource) (map[span.URI]struct{}, error) {
-	uri := span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
-	modules := make(map[span.URI]struct{})
-	exists, err := fileExists(ctx, uri, fs)
-	if err != nil {
-		return nil, err
-	}
-	if exists {
-		modules[uri] = struct{}{}
-	}
-	return modules, nil
-}
-
-func parseGoWork(ctx context.Context, root, uri span.URI, contents []byte, fs source.FileSource) (*modfile.File, map[span.URI]struct{}, error) {
-	workFile, err := modfile.ParseWork(uri.Filename(), contents, nil)
-	if err != nil {
-		return nil, nil, fmt.Errorf("parsing go.work: %w", err)
-	}
-	modFiles := make(map[span.URI]struct{})
-	for _, dir := range workFile.Use {
-		// The resulting modfile must use absolute paths, so that it can be
-		// written to a temp directory.
-		dir.Path = absolutePath(root, dir.Path)
-		modURI := span.URIFromPath(filepath.Join(dir.Path, "go.mod"))
-		modFiles[modURI] = struct{}{}
-	}
-
-	// TODO(rfindley): we should either not build the workspace modfile here, or
-	// not fail so hard. A failure in building the workspace modfile should not
-	// invalidate the active module paths extracted above.
-	modFile, err := buildWorkspaceModFile(ctx, modFiles, fs)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// Require a go directive, per the spec.
-	if workFile.Go == nil || workFile.Go.Version == "" {
-		return nil, nil, fmt.Errorf("go.work has missing or incomplete go directive")
-	}
-	if err := modFile.AddGoStmt(workFile.Go.Version); err != nil {
-		return nil, nil, err
-	}
-
-	return modFile, modFiles, nil
-}
-
-func parseGoplsMod(root, uri span.URI, contents []byte) (*modfile.File, map[span.URI]struct{}, error) {
-	modFile, err := modfile.Parse(uri.Filename(), contents, nil)
-	if err != nil {
-		return nil, nil, fmt.Errorf("parsing gopls.mod: %w", err)
-	}
-	modFiles := make(map[span.URI]struct{})
-	for _, replace := range modFile.Replace {
-		if replace.New.Version != "" {
-			return nil, nil, fmt.Errorf("gopls.mod: replaced module %q@%q must not have version", replace.New.Path, replace.New.Version)
-		}
-		// The resulting modfile must use absolute paths, so that it can be
-		// written to a temp directory.
-		replace.New.Path = absolutePath(root, replace.New.Path)
-		modURI := span.URIFromPath(filepath.Join(replace.New.Path, "go.mod"))
-		modFiles[modURI] = struct{}{}
-	}
-	return modFile, modFiles, nil
-}
-
-func absolutePath(root span.URI, path string) string {
-	dirFP := filepath.FromSlash(path)
-	if !filepath.IsAbs(dirFP) {
-		dirFP = filepath.Join(root.Filename(), dirFP)
-	}
-	return dirFP
-}
-
 // errExhausted is returned by findModules if the file scan limit is reached.
 var errExhausted = errors.New("exhausted")
 
diff --git a/gopls/internal/lsp/cache/workspace_test.go b/gopls/internal/lsp/cache/workspace_test.go
index 45ae0cc..5f1e13e 100644
--- a/gopls/internal/lsp/cache/workspace_test.go
+++ b/gopls/internal/lsp/cache/workspace_test.go
@@ -6,13 +6,8 @@
 
 import (
 	"context"
-	"errors"
 	"os"
-	"strings"
-	"testing"
 
-	"golang.org/x/mod/modfile"
-	"golang.org/x/tools/gopls/internal/lsp/fake"
 	"golang.org/x/tools/gopls/internal/lsp/source"
 	"golang.org/x/tools/gopls/internal/span"
 )
@@ -89,278 +84,3 @@
 	}
 	return fh, nil
 }
-
-type wsState struct {
-	source  workspaceSource
-	modules []string
-	dirs    []string
-	sum     string
-}
-
-type wsChange struct {
-	content string
-	saved   bool
-}
-
-func TestWorkspaceModule(t *testing.T) {
-	tests := []struct {
-		desc         string
-		initial      string // txtar-encoded
-		legacyMode   bool
-		initialState wsState
-		updates      map[string]wsChange
-		wantChanged  bool
-		wantReload   bool
-		finalState   wsState
-	}{
-		{
-			desc: "legacy mode",
-			initial: `
--- go.mod --
-module mod.com
--- go.sum --
-golang.org/x/mod v0.3.0 h1:deadbeef
--- a/go.mod --
-module moda.com`,
-			legacyMode: true,
-			initialState: wsState{
-				modules: []string{"./go.mod"},
-				source:  legacyWorkspace,
-				dirs:    []string{"."},
-				sum:     "golang.org/x/mod v0.3.0 h1:deadbeef\n",
-			},
-		},
-		{
-			desc: "nested module",
-			initial: `
--- go.mod --
-module mod.com
--- a/go.mod --
-module moda.com`,
-			initialState: wsState{
-				modules: []string{"./go.mod", "a/go.mod"},
-				source:  fileSystemWorkspace,
-				dirs:    []string{".", "a"},
-			},
-		},
-		{
-			desc: "adding module",
-			initial: `
--- gopls.mod --
-require moda.com v0.0.0-goplsworkspace
-replace moda.com => $SANDBOX_WORKDIR/a
--- a/go.mod --
-module moda.com
--- b/go.mod --
-module modb.com`,
-			initialState: wsState{
-				modules: []string{"a/go.mod"},
-				source:  goplsModWorkspace,
-				dirs:    []string{".", "a"},
-			},
-			updates: map[string]wsChange{
-				"gopls.mod": {`module gopls-workspace
-
-require moda.com v0.0.0-goplsworkspace
-require modb.com v0.0.0-goplsworkspace
-
-replace moda.com => $SANDBOX_WORKDIR/a
-replace modb.com => $SANDBOX_WORKDIR/b`, true},
-			},
-			wantChanged: true,
-			wantReload:  true,
-			finalState: wsState{
-				modules: []string{"a/go.mod", "b/go.mod"},
-				source:  goplsModWorkspace,
-				dirs:    []string{".", "a", "b"},
-			},
-		},
-		{
-			desc: "broken module parsing",
-			initial: `
--- a/go.mod --
-module moda.com
-
-require gopls.test v0.0.0-goplsworkspace
-replace gopls.test => ../../gopls.test // (this path shouldn't matter)
--- b/go.mod --
-module modb.com`,
-			initialState: wsState{
-				modules: []string{"a/go.mod", "b/go.mod"},
-				source:  fileSystemWorkspace,
-				dirs:    []string{".", "a", "b", "../gopls.test"},
-			},
-			updates: map[string]wsChange{
-				"a/go.mod": {`modul moda.com
-
-require gopls.test v0.0.0-goplsworkspace
-replace gopls.test => ../../gopls.test2`, false},
-			},
-			wantChanged: true,
-			wantReload:  false,
-			finalState: wsState{
-				modules: []string{"a/go.mod", "b/go.mod"},
-				source:  fileSystemWorkspace,
-				// finalDirs should be unchanged: we should preserve dirs in the presence
-				// of a broken modfile.
-				dirs: []string{".", "a", "b", "../gopls.test"},
-			},
-		},
-	}
-
-	for _, test := range tests {
-		t.Run(test.desc, func(t *testing.T) {
-			ctx := context.Background()
-			dir, err := fake.Tempdir(fake.UnpackTxt(test.initial))
-			if err != nil {
-				t.Fatal(err)
-			}
-			defer os.RemoveAll(dir)
-			root := span.URIFromPath(dir)
-
-			fs := &osFileSource{}
-			excludeNothing := func(string) bool { return false }
-			w, err := newWorkspace(ctx, root, "", fs, excludeNothing, false, !test.legacyMode)
-			if err != nil {
-				t.Fatal(err)
-			}
-			rel := fake.RelativeTo(dir)
-			checkState(ctx, t, fs, rel, w, test.initialState)
-
-			// Apply updates.
-			if test.updates != nil {
-				changes := make(map[span.URI]*fileChange)
-				for k, v := range test.updates {
-					content := strings.ReplaceAll(v.content, "$SANDBOX_WORKDIR", string(rel))
-					uri := span.URIFromPath(rel.AbsPath(k))
-					changes[uri], err = fs.change(ctx, uri, content, v.saved)
-					if err != nil {
-						t.Fatal(err)
-					}
-				}
-				got, gotReinit := w.Clone(ctx, changes, fs)
-				gotChanged := got != w
-				if gotChanged != test.wantChanged {
-					t.Errorf("w.invalidate(): got changed %t, want %t", gotChanged, test.wantChanged)
-				}
-				if gotReinit != test.wantReload {
-					t.Errorf("w.invalidate(): got reload %t, want %t", gotReinit, test.wantReload)
-				}
-				checkState(ctx, t, fs, rel, got, test.finalState)
-			}
-		})
-	}
-}
-
-func workspaceFromTxtar(t *testing.T, files string) (*workspace, func(), error) {
-	ctx := context.Background()
-	dir, err := fake.Tempdir(fake.UnpackTxt(files))
-	if err != nil {
-		return nil, func() {}, err
-	}
-	cleanup := func() {
-		os.RemoveAll(dir)
-	}
-	root := span.URIFromPath(dir)
-
-	fs := &osFileSource{}
-	excludeNothing := func(string) bool { return false }
-	workspace, err := newWorkspace(ctx, root, "", fs, excludeNothing, false, false)
-	return workspace, cleanup, err
-}
-
-func TestWorkspaceParseError(t *testing.T) {
-	w, cleanup, err := workspaceFromTxtar(t, `
--- go.work --
-go 1.18
-
-usa ./typo
--- typo/go.mod --
-module foo
-`)
-	defer cleanup()
-	if err != nil {
-		t.Fatalf("error creating workspace: %v; want no error", err)
-	}
-	w.buildMu.Lock()
-	built, buildErr := w.built, w.buildErr
-	w.buildMu.Unlock()
-	if !built || buildErr == nil {
-		t.Fatalf("built, buildErr: got %v, %v; want true, non-nil", built, buildErr)
-	}
-	var errList modfile.ErrorList
-	if !errors.As(buildErr, &errList) {
-		t.Fatalf("expected error to be an errorlist; got %v", buildErr)
-	}
-	if len(errList) != 1 {
-		t.Fatalf("expected errorList to have one element; got %v elements", len(errList))
-	}
-	parseErr := errList[0]
-	if parseErr.Pos.Line != 3 {
-		t.Fatalf("expected error to be on line 3; got %v", parseErr.Pos.Line)
-	}
-}
-
-func TestWorkspaceMissingModFile(t *testing.T) {
-	w, cleanup, err := workspaceFromTxtar(t, `
--- go.work --
-go 1.18
-
-use ./missing
-`)
-	defer cleanup()
-	if err != nil {
-		t.Fatalf("error creating workspace: %v; want no error", err)
-	}
-	w.buildMu.Lock()
-	built, buildErr := w.built, w.buildErr
-	w.buildMu.Unlock()
-	if !built || buildErr == nil {
-		t.Fatalf("built, buildErr: got %v, %v; want true, non-nil", built, buildErr)
-	}
-}
-
-func checkState(ctx context.Context, t *testing.T, fs source.FileSource, rel fake.RelativeTo, got *workspace, want wsState) {
-	t.Helper()
-	if got.moduleSource != want.source {
-		t.Errorf("module source = %v, want %v", got.moduleSource, want.source)
-	}
-	modules := make(map[span.URI]struct{})
-	for k := range got.ActiveModFiles() {
-		modules[k] = struct{}{}
-	}
-	for _, modPath := range want.modules {
-		path := rel.AbsPath(modPath)
-		uri := span.URIFromPath(path)
-		if _, ok := modules[uri]; !ok {
-			t.Errorf("missing module %q", uri)
-		}
-		delete(modules, uri)
-	}
-	for remaining := range modules {
-		t.Errorf("unexpected module %q", remaining)
-	}
-	gotDirs := got.dirs(ctx, fs)
-	gotM := make(map[span.URI]bool)
-	for _, dir := range gotDirs {
-		gotM[dir] = true
-	}
-	for _, dir := range want.dirs {
-		path := rel.AbsPath(dir)
-		uri := span.URIFromPath(path)
-		if !gotM[uri] {
-			t.Errorf("missing dir %q", uri)
-		}
-		delete(gotM, uri)
-	}
-	for remaining := range gotM {
-		t.Errorf("unexpected dir %q", remaining)
-	}
-	gotSumBytes, err := got.sumFile(ctx, fs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if gotSum := string(gotSumBytes); gotSum != want.sum {
-		t.Errorf("got final sum %q, want %q", gotSum, want.sum)
-	}
-}
diff --git a/gopls/internal/lsp/cmd/cmd.go b/gopls/internal/lsp/cmd/cmd.go
index aea05d5..afe6dda 100644
--- a/gopls/internal/lsp/cmd/cmd.go
+++ b/gopls/internal/lsp/cmd/cmd.go
@@ -270,7 +270,6 @@
 		&signature{app: app},
 		&suggestedFix{app: app},
 		&symbols{app: app},
-		newWorkspace(app),
 		&workspaceSymbol{app: app},
 		&vulncheck{app: app},
 	}
diff --git a/gopls/internal/lsp/cmd/help_test.go b/gopls/internal/lsp/cmd/help_test.go
index f8d9b0b..6bd3c8c 100644
--- a/gopls/internal/lsp/cmd/help_test.go
+++ b/gopls/internal/lsp/cmd/help_test.go
@@ -12,6 +12,7 @@
 	"path/filepath"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"golang.org/x/tools/gopls/internal/lsp/cmd"
 	"golang.org/x/tools/internal/testenv"
 	"golang.org/x/tools/internal/tool"
@@ -45,12 +46,12 @@
 				}
 				return
 			}
-			expect, err := ioutil.ReadFile(helpFile)
-			switch {
-			case err != nil:
-				t.Errorf("Missing help file %q", helpFile)
-			case !bytes.Equal(expect, got):
-				t.Errorf("Help file %q did not match, got:\n%q\nwant:\n%q", helpFile, string(got), string(expect))
+			want, err := ioutil.ReadFile(helpFile)
+			if err != nil {
+				t.Fatalf("Missing help file %q", helpFile)
+			}
+			if diff := cmp.Diff(string(want), string(got)); diff != "" {
+				t.Errorf("Help file %q did not match, run with -update-help-files to fix (-want +got)\n%s", helpFile, diff)
 			}
 		})
 	}
diff --git a/gopls/internal/lsp/cmd/usage/usage.hlp b/gopls/internal/lsp/cmd/usage/usage.hlp
index eaa05c5..404750b 100644
--- a/gopls/internal/lsp/cmd/usage/usage.hlp
+++ b/gopls/internal/lsp/cmd/usage/usage.hlp
@@ -37,7 +37,6 @@
   signature         display selected identifier's signature
   fix               apply suggested fixes
   symbols           display selected file's symbols
-  workspace         manage the gopls workspace (experimental: under development)
   workspace_symbol  search symbols in workspace
   vulncheck         run experimental vulncheck analysis (experimental: under development)
 
diff --git a/gopls/internal/lsp/cmd/usage/workspace.hlp b/gopls/internal/lsp/cmd/usage/workspace.hlp
deleted file mode 100644
index 912cf29..0000000
--- a/gopls/internal/lsp/cmd/usage/workspace.hlp
+++ /dev/null
@@ -1,7 +0,0 @@
-manage the gopls workspace (experimental: under development)
-
-Usage:
-  gopls [flags] workspace <subcommand> [arg]...
-
-Subcommand:
-  generate  generate a gopls.mod file for a workspace
diff --git a/gopls/internal/lsp/cmd/workspace.go b/gopls/internal/lsp/cmd/workspace.go
deleted file mode 100644
index 2038d27..0000000
--- a/gopls/internal/lsp/cmd/workspace.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2020 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 cmd
-
-import (
-	"context"
-	"flag"
-	"fmt"
-
-	"golang.org/x/tools/gopls/internal/lsp/command"
-	"golang.org/x/tools/gopls/internal/lsp/protocol"
-	"golang.org/x/tools/gopls/internal/lsp/source"
-)
-
-// workspace is a top-level command for working with the gopls workspace. This
-// is experimental and subject to change. The idea is that subcommands could be
-// used for manipulating the workspace mod file, rather than editing it
-// manually.
-type workspace struct {
-	app *Application
-	subcommands
-}
-
-func newWorkspace(app *Application) *workspace {
-	return &workspace{
-		app: app,
-		subcommands: subcommands{
-			&generateWorkspaceMod{app: app},
-		},
-	}
-}
-
-func (w *workspace) Name() string   { return "workspace" }
-func (w *workspace) Parent() string { return w.app.Name() }
-func (w *workspace) ShortHelp() string {
-	return "manage the gopls workspace (experimental: under development)"
-}
-
-// generateWorkspaceMod (re)generates the gopls.mod file for the current
-// workspace.
-type generateWorkspaceMod struct {
-	app *Application
-}
-
-func (c *generateWorkspaceMod) Name() string  { return "generate" }
-func (c *generateWorkspaceMod) Usage() string { return "" }
-func (c *generateWorkspaceMod) ShortHelp() string {
-	return "generate a gopls.mod file for a workspace"
-}
-
-func (c *generateWorkspaceMod) DetailedHelp(f *flag.FlagSet) {
-	printFlagDefaults(f)
-}
-
-func (c *generateWorkspaceMod) Run(ctx context.Context, args ...string) error {
-	origOptions := c.app.options
-	c.app.options = func(opts *source.Options) {
-		origOptions(opts)
-		opts.ExperimentalWorkspaceModule = true
-	}
-	conn, err := c.app.connect(ctx)
-	if err != nil {
-		return err
-	}
-	defer conn.terminate(ctx)
-	cmd, err := command.NewGenerateGoplsModCommand("", command.URIArg{})
-	if err != nil {
-		return err
-	}
-	params := &protocol.ExecuteCommandParams{Command: cmd.Command, Arguments: cmd.Arguments}
-	if _, err := conn.ExecuteCommand(ctx, params); err != nil {
-		return fmt.Errorf("executing server command: %v", err)
-	}
-	return nil
-}
diff --git a/gopls/internal/lsp/command.go b/gopls/internal/lsp/command.go
index 210ce9b..b9e1fe6 100644
--- a/gopls/internal/lsp/command.go
+++ b/gopls/internal/lsp/command.go
@@ -715,35 +715,6 @@
 	})
 }
 
-func (c *commandHandler) GenerateGoplsMod(ctx context.Context, args command.URIArg) error {
-	// TODO: go back to using URI
-	return c.run(ctx, commandConfig{
-		requireSave: true,
-		progress:    "Generating gopls.mod",
-	}, func(ctx context.Context, deps commandDeps) error {
-		views := c.s.session.Views()
-		if len(views) != 1 {
-			return fmt.Errorf("cannot resolve view: have %d views", len(views))
-		}
-		v := views[0]
-		snapshot, release := v.Snapshot(ctx)
-		defer release()
-		modFile, err := snapshot.BuildGoplsMod(ctx)
-		if err != nil {
-			return fmt.Errorf("getting workspace mod file: %w", err)
-		}
-		content, err := modFile.Format()
-		if err != nil {
-			return fmt.Errorf("formatting mod file: %w", err)
-		}
-		filename := filepath.Join(v.Folder().Filename(), "gopls.mod")
-		if err := ioutil.WriteFile(filename, content, 0644); err != nil {
-			return fmt.Errorf("writing mod file: %w", err)
-		}
-		return nil
-	})
-}
-
 func (c *commandHandler) ListKnownPackages(ctx context.Context, args command.URIArg) (command.ListKnownPackagesResult, error) {
 	var result command.ListKnownPackagesResult
 	err := c.run(ctx, commandConfig{
diff --git a/gopls/internal/lsp/command/command_gen.go b/gopls/internal/lsp/command/command_gen.go
index 35d37ed..b6aea98 100644
--- a/gopls/internal/lsp/command/command_gen.go
+++ b/gopls/internal/lsp/command/command_gen.go
@@ -27,7 +27,6 @@
 	FetchVulncheckResult  Command = "fetch_vulncheck_result"
 	GCDetails             Command = "gc_details"
 	Generate              Command = "generate"
-	GenerateGoplsMod      Command = "generate_gopls_mod"
 	GoGetPackage          Command = "go_get_package"
 	ListImports           Command = "list_imports"
 	ListKnownPackages     Command = "list_known_packages"
@@ -54,7 +53,6 @@
 	FetchVulncheckResult,
 	GCDetails,
 	Generate,
-	GenerateGoplsMod,
 	GoGetPackage,
 	ListImports,
 	ListKnownPackages,
@@ -122,12 +120,6 @@
 			return nil, err
 		}
 		return nil, s.Generate(ctx, a0)
-	case "gopls.generate_gopls_mod":
-		var a0 URIArg
-		if err := UnmarshalArgs(params.Arguments, &a0); err != nil {
-			return nil, err
-		}
-		return nil, s.GenerateGoplsMod(ctx, a0)
 	case "gopls.go_get_package":
 		var a0 GoGetPackageArgs
 		if err := UnmarshalArgs(params.Arguments, &a0); err != nil {
@@ -320,18 +312,6 @@
 	}, nil
 }
 
-func NewGenerateGoplsModCommand(title string, a0 URIArg) (protocol.Command, error) {
-	args, err := MarshalArgs(a0)
-	if err != nil {
-		return protocol.Command{}, err
-	}
-	return protocol.Command{
-		Title:     title,
-		Command:   "gopls.generate_gopls_mod",
-		Arguments: args,
-	}, nil
-}
-
 func NewGoGetPackageCommand(title string, a0 GoGetPackageArgs) (protocol.Command, error) {
 	args, err := MarshalArgs(a0)
 	if err != nil {
diff --git a/gopls/internal/lsp/command/interface.go b/gopls/internal/lsp/command/interface.go
index ec910fc..965158a 100644
--- a/gopls/internal/lsp/command/interface.go
+++ b/gopls/internal/lsp/command/interface.go
@@ -121,11 +121,6 @@
 	// Toggle the calculation of gc annotations.
 	ToggleGCDetails(context.Context, URIArg) error
 
-	// GenerateGoplsMod: Generate gopls.mod
-	//
-	// (Re)generate the gopls.mod file for a workspace.
-	GenerateGoplsMod(context.Context, URIArg) error
-
 	// ListKnownPackages: List known packages
 	//
 	// Retrieve a list of packages that are importable from the given URI.
diff --git a/gopls/internal/lsp/command/interface_test.go b/gopls/internal/lsp/command/interface_test.go
index de3ce62..e602293 100644
--- a/gopls/internal/lsp/command/interface_test.go
+++ b/gopls/internal/lsp/command/interface_test.go
@@ -5,10 +5,10 @@
 package command_test
 
 import (
-	"bytes"
 	"io/ioutil"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
 	"golang.org/x/tools/gopls/internal/lsp/command/gen"
 	"golang.org/x/tools/internal/testenv"
 )
@@ -25,7 +25,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if !bytes.Equal(onDisk, generated) {
-		t.Error("command_gen.go is stale -- regenerate")
+	if diff := cmp.Diff(string(generated), string(onDisk)); diff != "" {
+		t.Errorf("command_gen.go is stale -- regenerate (-generated +on disk)\n%s", diff)
 	}
 }
diff --git a/gopls/internal/lsp/link.go b/gopls/internal/lsp/link.go
index 3adc4de..2713715 100644
--- a/gopls/internal/lsp/link.go
+++ b/gopls/internal/lsp/link.go
@@ -134,11 +134,7 @@
 			urlPath := string(importPath)
 
 			// For pkg.go.dev, append module version suffix to package import path.
-			if m := snapshot.Metadata(depsByImpPath[importPath]); m != nil &&
-				m.Module != nil &&
-				m.Module.Path != "" &&
-				m.Module.Version != "" &&
-				!source.IsWorkspaceModuleVersion(m.Module.Version) {
+			if m := snapshot.Metadata(depsByImpPath[importPath]); m != nil && m.Module != nil && m.Module.Path != "" && m.Module.Version != "" {
 				urlPath = strings.Replace(urlPath, m.Module.Path, m.Module.Path+"@"+m.Module.Version, 1)
 			}
 
diff --git a/gopls/internal/lsp/regtest/runner.go b/gopls/internal/lsp/regtest/runner.go
index a006164..20dac84 100644
--- a/gopls/internal/lsp/regtest/runner.go
+++ b/gopls/internal/lsp/regtest/runner.go
@@ -344,9 +344,6 @@
 	options := func(o *source.Options) {
 		optsHook(o)
 		o.EnableAllExperiments()
-		// ExperimentalWorkspaceModule is not (as of writing) enabled by
-		// source.Options.EnableAllExperiments, but we want to test it.
-		o.ExperimentalWorkspaceModule = true
 	}
 	return lsprpc.NewStreamServer(cache.New(nil, nil), false, options)
 }
diff --git a/gopls/internal/lsp/source/api_json.go b/gopls/internal/lsp/source/api_json.go
index 44334a0..11347e7 100755
--- a/gopls/internal/lsp/source/api_json.go
+++ b/gopls/internal/lsp/source/api_json.go
@@ -57,14 +57,6 @@
 				Hierarchy: "build",
 			},
 			{
-				Name:      "experimentalWorkspaceModule",
-				Type:      "bool",
-				Doc:       "experimentalWorkspaceModule opts a user into the experimental support\nfor multi-module workspaces.\n\nDeprecated: this feature is deprecated and will be removed in a future\nversion of gopls (https://go.dev/issue/55331).\n",
-				Default:   "false",
-				Status:    "experimental",
-				Hierarchy: "build",
-			},
-			{
 				Name:      "experimentalPackageCacheKey",
 				Type:      "bool",
 				Doc:       "experimentalPackageCacheKey controls whether to use a coarser cache key\nfor package type information to increase cache hits. This setting removes\nthe user's environment, build flags, and working directory from the cache\nkey, which should be a safe change as all relevant inputs into the type\nchecking pass are already hashed into the key. This is temporarily guarded\nby an experiment because caching behavior is subtle and difficult to\ncomprehensively test.\n",
@@ -727,12 +719,6 @@
 			ArgDoc:  "{\n\t// URI for the directory to generate.\n\t\"Dir\": string,\n\t// Whether to generate recursively (go generate ./...)\n\t\"Recursive\": bool,\n}",
 		},
 		{
-			Command: "gopls.generate_gopls_mod",
-			Title:   "Generate gopls.mod",
-			Doc:     "(Re)generate the gopls.mod file for a workspace.",
-			ArgDoc:  "{\n\t// The file URI.\n\t\"URI\": string,\n}",
-		},
-		{
 			Command: "gopls.go_get_package",
 			Title:   "go get a package",
 			Doc:     "Runs `go get` to fetch a package.",
diff --git a/gopls/internal/lsp/source/options.go b/gopls/internal/lsp/source/options.go
index ba6c3f0..ca3441a 100644
--- a/gopls/internal/lsp/source/options.go
+++ b/gopls/internal/lsp/source/options.go
@@ -268,13 +268,6 @@
 	// a go.mod file, narrowing the scope to that directory if it exists.
 	ExpandWorkspaceToModule bool `status:"experimental"`
 
-	// ExperimentalWorkspaceModule opts a user into the experimental support
-	// for multi-module workspaces.
-	//
-	// Deprecated: this feature is deprecated and will be removed in a future
-	// version of gopls (https://go.dev/issue/55331).
-	ExperimentalWorkspaceModule bool `status:"experimental"`
-
 	// ExperimentalPackageCacheKey controls whether to use a coarser cache key
 	// for package type information to increase cache hits. This setting removes
 	// the user's environment, build flags, and working directory from the cache
@@ -1100,12 +1093,7 @@
 		result.setBool(&o.ExperimentalPostfixCompletions)
 
 	case "experimentalWorkspaceModule":
-		const msg = "experimentalWorkspaceModule has been replaced by go workspaces, " +
-			"and will be removed in a future version of gopls (https://go.dev/issue/55331) -- " +
-			"see https://github.com/golang/tools/blob/master/gopls/doc/workspace.md " +
-			"for information on setting up multi-module workspaces using go.work files"
-		result.softErrorf(msg)
-		result.setBool(&o.ExperimentalWorkspaceModule)
+		result.deprecated("")
 
 	case "experimentalTemplateSupport": // TODO(pjw): remove after June 2022
 		result.deprecated("")
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index 118638a..bb6b754 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -15,7 +15,6 @@
 	"go/token"
 	"go/types"
 	"io"
-	"strings"
 
 	"golang.org/x/mod/modfile"
 	"golang.org/x/mod/module"
@@ -210,10 +209,6 @@
 	//
 	// A nil result may mean success, or context cancellation.
 	GetCriticalError(ctx context.Context) *CriticalError
-
-	// BuildGoplsMod generates a go.mod file for all modules in the workspace.
-	// It bypasses any existing gopls.mod.
-	BuildGoplsMod(ctx context.Context) (*modfile.File, error)
 }
 
 // SnapshotLabels returns a new slice of labels that should be used for events
@@ -779,7 +774,7 @@
 	PkgPath() PackagePath
 	GetTypesSizes() types.Sizes
 	ForTest() string
-	Version() *module.Version // may differ from Metadata.Module.Version
+	Version() *module.Version
 
 	// Results of parsing:
 	FileSet() *token.FileSet
@@ -856,28 +851,3 @@
 func AnalyzerErrorKind(name string) DiagnosticSource {
 	return DiagnosticSource(name)
 }
-
-// WorkspaceModuleVersion is the nonexistent pseudoversion suffix used in the
-// construction of the workspace module. It is exported so that we can make
-// sure not to show this version to end users in error messages, to avoid
-// confusion.
-// The major version is not included, as that depends on the module path.
-//
-// If workspace module A is dependent on workspace module B, we need our
-// nonexistent version to be greater than the version A mentions.
-// Otherwise, the go command will try to update to that version. Use a very
-// high minor version to make that more likely.
-const workspaceModuleVersion = ".9999999.0-goplsworkspace"
-
-func IsWorkspaceModuleVersion(version string) bool {
-	return strings.HasSuffix(version, workspaceModuleVersion)
-}
-
-func WorkspaceModuleVersion(majorVersion string) string {
-	// Use the highest compatible major version to avoid unwanted upgrades.
-	// See the comment on workspaceModuleVersion.
-	if majorVersion == "v0" {
-		majorVersion = "v1"
-	}
-	return majorVersion + workspaceModuleVersion
-}
diff --git a/gopls/internal/lsp/tests/tests.go b/gopls/internal/lsp/tests/tests.go
index 3225c15..e9f1009 100644
--- a/gopls/internal/lsp/tests/tests.go
+++ b/gopls/internal/lsp/tests/tests.go
@@ -286,7 +286,6 @@
 	o.InsertTextFormat = protocol.SnippetTextFormat
 	o.CompletionBudget = time.Minute
 	o.HierarchicalDocumentSymbolSupport = true
-	o.ExperimentalWorkspaceModule = true
 	o.SemanticTokens = true
 	o.InternalOptions.NewDiff = "both"
 }
diff --git a/gopls/internal/regtest/codelens/codelens_test.go b/gopls/internal/regtest/codelens/codelens_test.go
index 734499c..b03b96f 100644
--- a/gopls/internal/regtest/codelens/codelens_test.go
+++ b/gopls/internal/regtest/codelens/codelens_test.go
@@ -78,6 +78,8 @@
 // regression test for golang/go#39446. It also checks that these code lenses
 // only affect the diagnostics and contents of the containing go.mod file.
 func TestUpgradeCodelens(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // uses go.work
+
 	const proxyWithLatest = `
 -- golang.org/x/hello@v1.3.3/go.mod --
 module golang.org/x/hello
@@ -185,7 +187,7 @@
 				}); err != nil {
 					t.Fatal(err)
 				}
-				env.Await(env.DoneWithChangeWatchedFiles())
+				env.AfterChange()
 				if got := env.BufferText("a/go.mod"); got != wantGoModA {
 					t.Fatalf("a/go.mod upgrade failed:\n%s", compare.Text(wantGoModA, got))
 				}
@@ -201,7 +203,7 @@
 				if vendoring {
 					env.RunGoCommandInDir("a", "mod", "vendor")
 				}
-				env.Await(env.DoneWithChangeWatchedFiles())
+				env.AfterChange()
 				env.OpenFile("a/go.mod")
 				env.OpenFile("b/go.mod")
 				env.ExecuteCodeLensCommand("a/go.mod", command.CheckUpgrades, nil)
diff --git a/gopls/internal/regtest/workspace/broken_test.go b/gopls/internal/regtest/workspace/broken_test.go
index fb101d3..e9cc524 100644
--- a/gopls/internal/regtest/workspace/broken_test.go
+++ b/gopls/internal/regtest/workspace/broken_test.go
@@ -25,6 +25,9 @@
 func TestBrokenWorkspace_DuplicateModules(t *testing.T) {
 	testenv.NeedsGo1Point(t, 18)
 
+	// TODO(golang/go#57650): fix this feature.
+	t.Skip("we no longer detect duplicate modules")
+
 	// This proxy module content is replaced by the workspace, but is still
 	// required for module resolution to function in the Go command.
 	const proxy = `
diff --git a/gopls/internal/regtest/workspace/directoryfilters_test.go b/gopls/internal/regtest/workspace/directoryfilters_test.go
index 94aa8b7..3f8bf6c 100644
--- a/gopls/internal/regtest/workspace/directoryfilters_test.go
+++ b/gopls/internal/regtest/workspace/directoryfilters_test.go
@@ -10,6 +10,7 @@
 	"testing"
 
 	. "golang.org/x/tools/gopls/internal/lsp/regtest"
+	"golang.org/x/tools/internal/testenv"
 )
 
 // This file contains regression tests for the directoryFilters setting.
@@ -198,6 +199,8 @@
 // Test for golang/go#52993: non-wildcard directoryFilters should still be
 // applied relative to the workspace folder, not the module root.
 func TestDirectoryFilters_MultiRootImportScanning(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // uses go.work
+
 	const files = `
 -- go.work --
 go 1.18
diff --git a/gopls/internal/regtest/workspace/fromenv_test.go b/gopls/internal/regtest/workspace/fromenv_test.go
index 47405c3..218e206 100644
--- a/gopls/internal/regtest/workspace/fromenv_test.go
+++ b/gopls/internal/regtest/workspace/fromenv_test.go
@@ -31,12 +31,19 @@
 func _() {
 	x := 1 // unused
 }
+-- other/c/go.mod --
+module c.com
+
+go 1.18
+-- other/c/c.go --
+package c
 -- config/go.work --
 go 1.18
 
 use (
 	$SANDBOX_WORKDIR/work/a
 	$SANDBOX_WORKDIR/work/b
+	$SANDBOX_WORKDIR/other/c
 )
 `
 
@@ -45,15 +52,19 @@
 		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(
+			OnceMet(
+				InitialWorkspaceLoad,
+				FileWatchMatching(`other`),
+				FileWatchMatching(`config.go\.work`),
+			),
+		)
 		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"),
-			),
+		env.AfterChange(
+			env.DiagnosticAtRegexpWithMessage("work/b/b.go", "x := 1", "not used"),
 		)
 	})
 }
diff --git a/gopls/internal/regtest/workspace/workspace_test.go b/gopls/internal/regtest/workspace/workspace_test.go
index 81e3531..35ea6b9 100644
--- a/gopls/internal/regtest/workspace/workspace_test.go
+++ b/gopls/internal/regtest/workspace/workspace_test.go
@@ -466,8 +466,6 @@
 // This test confirms that a gopls workspace can recover from initialization
 // with one invalid module.
 func TestOneBrokenModule(t *testing.T) {
-	t.Skip("golang/go#55331: this test is temporarily broken as go.work handling tries to build the workspace module")
-
 	testenv.NeedsGo1Point(t, 18) // uses go.work
 	const multiModule = `
 -- go.work --
@@ -521,135 +519,6 @@
 	})
 }
 
-func TestUseGoplsMod(t *testing.T) {
-	// This test validates certain functionality related to using a gopls.mod
-	// file to specify workspace modules.
-	const multiModule = `
--- moda/a/go.mod --
-module a.com
-
-require b.com v1.2.3
--- moda/a/go.sum --
-b.com v1.2.3 h1:tXrlXP0rnjRpKNmkbLYoWBdq0ikb3C3bKK9//moAWBI=
-b.com v1.2.3/go.mod h1:D+J7pfFBZK5vdIdZEFquR586vKKIkqG7Qjw9AxG5BQ8=
--- moda/a/a.go --
-package a
-
-import (
-	"b.com/b"
-)
-
-func main() {
-	var x int
-	_ = b.Hello()
-}
--- modb/go.mod --
-module b.com
-
-require example.com v1.2.3
--- modb/go.sum --
-example.com v1.2.3 h1:veRD4tUnatQRgsULqULZPjeoBGFr2qBhevSCZllD2Ds=
-example.com v1.2.3/go.mod h1:Y2Rc5rVWjWur0h3pd9aEvK5Pof8YKDANh9gHA2Maujo=
--- modb/b/b.go --
-package b
-
-func Hello() int {
-	var x int
-}
--- gopls.mod --
-module gopls-workspace
-
-require (
-	a.com v0.0.0-goplsworkspace
-	b.com v1.2.3
-)
-
-replace a.com => $SANDBOX_WORKDIR/moda/a
-`
-	WithOptions(
-		ProxyFiles(workspaceModuleProxy),
-		Modes(Experimental),
-	).Run(t, multiModule, func(t *testing.T, env *Env) {
-		// Initially, the gopls.mod should cause only the a.com module to be
-		// loaded. Validate this by jumping to a definition in b.com and ensuring
-		// that we go to the module cache.
-		env.OpenFile("moda/a/a.go")
-		env.Await(env.DoneWithOpen())
-
-		// To verify which modules are loaded, we'll jump to the definition of
-		// b.Hello.
-		checkHelloLocation := func(want string) error {
-			location, _ := env.GoToDefinition("moda/a/a.go", env.RegexpSearch("moda/a/a.go", "Hello"))
-			if !strings.HasSuffix(location, want) {
-				return fmt.Errorf("expected %s, got %v", want, location)
-			}
-			return nil
-		}
-
-		// Initially this should be in the module cache, as b.com is not replaced.
-		if err := checkHelloLocation("b.com@v1.2.3/b/b.go"); err != nil {
-			t.Fatal(err)
-		}
-
-		// Now, modify the gopls.mod file on disk to activate the b.com module in
-		// the workspace.
-		workdir := env.Sandbox.Workdir.RootURI().SpanURI().Filename()
-		env.WriteWorkspaceFile("gopls.mod", fmt.Sprintf(`module gopls-workspace
-
-require (
-	a.com v1.9999999.0-goplsworkspace
-	b.com v1.9999999.0-goplsworkspace
-)
-
-replace a.com => %s/moda/a
-replace b.com => %s/modb
-`, workdir, workdir))
-
-		// As of golang/go#54069, writing a gopls.mod to the workspace triggers a
-		// workspace reload.
-		env.Await(
-			OnceMet(
-				env.DoneWithChangeWatchedFiles(),
-				env.DiagnosticAtRegexp("modb/b/b.go", "x"),
-			),
-		)
-
-		// Jumping to definition should now go to b.com in the workspace.
-		if err := checkHelloLocation("modb/b/b.go"); err != nil {
-			t.Fatal(err)
-		}
-
-		// Now, let's modify the gopls.mod *overlay* (not on disk), and verify that
-		// this change is only picked up once it is saved.
-		env.OpenFile("gopls.mod")
-		env.Await(env.DoneWithOpen())
-		env.SetBufferContent("gopls.mod", fmt.Sprintf(`module gopls-workspace
-
-require (
-	a.com v0.0.0-goplsworkspace
-)
-
-replace a.com => %s/moda/a
-`, workdir))
-
-		// Editing the gopls.mod removes modb from the workspace modules, and so
-		// should clear outstanding diagnostics...
-		env.Await(OnceMet(
-			env.DoneWithChange(),
-			EmptyDiagnostics("modb/go.mod"),
-		))
-		// ...but does not yet cause a workspace reload, so we should still jump to modb.
-		if err := checkHelloLocation("modb/b/b.go"); err != nil {
-			t.Fatal(err)
-		}
-		// Saving should reload the workspace.
-		env.SaveBufferWithoutActions("gopls.mod")
-		if err := checkHelloLocation("b.com@v1.2.3/b/b.go"); err != nil {
-			t.Fatal(err)
-		}
-	})
-}
-
 // TestBadGoWork exercises the panic from golang/vscode-go#2121.
 func TestBadGoWork(t *testing.T) {
 	const files = `
@@ -664,6 +533,7 @@
 }
 
 func TestUseGoWork(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // uses go.work
 	// This test validates certain functionality related to using a go.work
 	// file to specify workspace modules.
 	const multiModule = `
@@ -809,6 +679,8 @@
 }
 
 func TestUseGoWorkDiagnosticMissingModule(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18) // uses go.work
+
 	const files = `
 -- go.work --
 go 1.18
@@ -819,7 +691,7 @@
 `
 	Run(t, files, func(t *testing.T, env *Env) {
 		env.OpenFile("go.work")
-		env.Await(
+		env.AfterChange(
 			env.DiagnosticAtRegexpWithMessage("go.work", "use", "directory ./foo does not contain a module"),
 		)
 		// The following tests is a regression test against an issue where we weren't
@@ -829,17 +701,18 @@
 		// struct, and then set the content back to the old contents to make sure
 		// the diagnostic still shows up.
 		env.SetBufferContent("go.work", "go 1.18 \n\n use ./bar\n")
-		env.Await(
+		env.AfterChange(
 			env.NoDiagnosticAtRegexp("go.work", "use"),
 		)
 		env.SetBufferContent("go.work", "go 1.18 \n\n use ./foo\n")
-		env.Await(
+		env.AfterChange(
 			env.DiagnosticAtRegexpWithMessage("go.work", "use", "directory ./foo does not contain a module"),
 		)
 	})
 }
 
 func TestUseGoWorkDiagnosticSyntaxError(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18)
 	const files = `
 -- go.work --
 go 1.18
@@ -849,7 +722,7 @@
 `
 	Run(t, files, func(t *testing.T, env *Env) {
 		env.OpenFile("go.work")
-		env.Await(
+		env.AfterChange(
 			env.DiagnosticAtRegexpWithMessage("go.work", "usa", "unknown directive: usa"),
 			env.DiagnosticAtRegexpWithMessage("go.work", "replace", "usage: replace"),
 		)
@@ -857,6 +730,8 @@
 }
 
 func TestUseGoWorkHover(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18)
+
 	const files = `
 -- go.work --
 go 1.18
@@ -1155,6 +1030,7 @@
 }
 
 func TestAddAndRemoveGoWork(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18)
 	// Use a workspace with a module in the root directory to exercise the case
 	// where a go.work is added to the existing root directory. This verifies
 	// that we're detecting changes to the module source, not just the root