blob: 709f6cee78022ac14ea2cb8954fa25b2541844e5 [file] [log] [blame]
// 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 cache
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"golang.org/x/mod/modfile"
"golang.org/x/tools/internal/event"
"golang.org/x/tools/internal/lsp/source"
"golang.org/x/tools/internal/span"
"golang.org/x/tools/internal/xcontext"
errors "golang.org/x/xerrors"
)
type workspaceSource int
const (
legacyWorkspace = iota
goplsModWorkspace
fileSystemWorkspace
)
func (s workspaceSource) String() string {
switch s {
case legacyWorkspace:
return "legacy"
case goplsModWorkspace:
return "gopls.mod"
case fileSystemWorkspace:
return "file system"
default:
return "!(unknown module source)"
}
}
// 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 {
root span.URI
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{}
// go111moduleOff indicates whether GO111MODULE=off has been configured in
// the environment.
go111moduleOff bool
// 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
file *modfile.File
wsDirs map[span.URI]struct{}
}
func newWorkspace(ctx context.Context, root span.URI, fs source.FileSource, go111module string, experimental bool) (*workspace, error) {
// In experimental mode, the user may have a gopls.mod file that defines
// their workspace.
if experimental {
goplsModFH, err := fs.GetFile(ctx, goplsModURI(root))
if err != nil {
return nil, err
}
contents, err := goplsModFH.Read()
if err == nil {
file, activeModFiles, err := parseGoplsMod(root, goplsModFH.URI(), contents)
if err != nil {
return nil, err
}
return &workspace{
root: root,
activeModFiles: activeModFiles,
knownModFiles: activeModFiles,
file: file,
moduleSource: goplsModWorkspace,
}, nil
}
}
// Otherwise, in all other modes, search for all of the go.mod files in the
// workspace.
knownModFiles, err := findModules(ctx, root, 0)
if err != nil {
return nil, err
}
// When GO111MODULE=off, there are no active go.mod files.
if go111module == "off" {
return &workspace{
root: root,
moduleSource: legacyWorkspace,
knownModFiles: knownModFiles,
go111moduleOff: true,
}, nil
}
// In legacy mode, not all known go.mod files will be considered active.
if !experimental {
activeModFiles, err := getLegacyModules(ctx, root, fs)
if err != nil {
return nil, err
}
return &workspace{
root: root,
activeModFiles: activeModFiles,
knownModFiles: knownModFiles,
moduleSource: legacyWorkspace,
}, nil
}
return &workspace{
root: root,
activeModFiles: knownModFiles,
knownModFiles: knownModFiles,
moduleSource: fileSystemWorkspace,
}, nil
}
func (wm *workspace) getKnownModFiles() map[span.URI]struct{} {
return wm.knownModFiles
}
func (wm *workspace) getActiveModFiles() map[span.URI]struct{} {
return wm.activeModFiles
}
// 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 (wm *workspace) modFile(ctx context.Context, fs source.FileSource) (*modfile.File, error) {
wm.build(ctx, fs)
return wm.file, wm.buildErr
}
func (wm *workspace) build(ctx context.Context, fs source.FileSource) {
wm.buildMu.Lock()
defer wm.buildMu.Unlock()
if wm.built {
return
}
// 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 our module source is not gopls.mod, try to build the workspace module
// from modules. Fall back on the pre-existing mod file if parsing fails.
if wm.moduleSource != goplsModWorkspace {
file, err := buildWorkspaceModFile(ctx, wm.activeModFiles, fs)
switch {
case err == nil:
wm.file = file
case wm.file != 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.
wm.buildErr = err
}
}
if wm.file != nil {
wm.wsDirs = map[span.URI]struct{}{
wm.root: {},
}
for _, r := range wm.file.Replace {
// We may be replacing a module with a different version, not a path
// on disk.
if r.New.Version != "" {
continue
}
wm.wsDirs[span.URIFromPath(r.New.Path)] = struct{}{}
}
}
// Ensure that there is always at least the root dir.
if len(wm.wsDirs) == 0 {
wm.wsDirs = map[span.URI]struct{}{
wm.root: {},
}
}
wm.built = true
}
// dirs returns the workspace directories for the loaded modules.
func (wm *workspace) dirs(ctx context.Context, fs source.FileSource) []span.URI {
wm.build(ctx, fs)
var dirs []span.URI
for d := range wm.wsDirs {
dirs = append(dirs, d)
}
sort.Slice(dirs, func(i, j int) bool {
return source.CompareURI(dirs[i], dirs[j]) < 0
})
return dirs
}
// invalidate returns a (possibly) new workspaceModule after invalidating
// changedURIs. If wm is still valid in the presence of changedURIs, it returns
// itself unmodified.
func (wm *workspace) invalidate(ctx context.Context, changes map[span.URI]*fileChange) (*workspace, bool) {
// Prevent races to wm.modFile or wm.wsDirs below, if wm has not yet been
// built.
wm.buildMu.Lock()
defer wm.buildMu.Unlock()
// Any gopls.mod change is processed first, followed by go.mod changes, as
// changes to gopls.mod may affect the set of active go.mod files.
var (
// New values. We return a new workspace module if and only if
// knownModFiles is non-nil.
knownModFiles map[span.URI]struct{}
moduleSource = wm.moduleSource
modFile = wm.file
err error
)
if wm.moduleSource == goplsModWorkspace {
// If we are currently reading the modfile from gopls.mod, we default to
// preserving it even if module metadata changes (which may be the case if
// a go.sum file changes).
modFile = wm.file
}
// First handle changes to the gopls.mod file.
if wm.moduleSource != legacyWorkspace {
// If gopls.mod has changed we need to either re-read it if it exists or
// walk the filesystem if it doesn't exist.
gmURI := goplsModURI(wm.root)
if change, ok := changes[gmURI]; ok {
if change.exists {
// Only invalidate if the gopls.mod actually parses. Otherwise, stick with the current gopls.mod
parsedFile, parsedModules, err := parseGoplsMod(wm.root, gmURI, change.content)
if err == nil {
modFile = parsedFile
moduleSource = goplsModWorkspace
knownModFiles = parsedModules
} else {
// Note that modFile is not invalidated here.
event.Error(ctx, "parsing gopls.mod", err)
}
} else {
// gopls.mod is deleted. search for modules again.
moduleSource = fileSystemWorkspace
knownModFiles, err = findModules(ctx, wm.root, 0)
// the modFile is no longer valid.
if err != nil {
event.Error(ctx, "finding file system modules", err)
}
modFile = nil
}
}
}
// Next, handle go.mod changes that could affect our set of tracked modules.
// If we're reading our tracked modules from the gopls.mod, there's nothing
// to do here.
if wm.moduleSource != goplsModWorkspace {
for uri, change := range changes {
// If a go.mod file has changed, we may need to update the set of active
// modules.
if !isGoMod(uri) {
continue
}
if !source.InDir(wm.root.Filename(), uri.Filename()) {
// Otherwise, the module must be contained within the workspace root.
continue
}
if knownModFiles == nil {
knownModFiles = make(map[span.URI]struct{})
for k := range wm.knownModFiles {
knownModFiles[k] = struct{}{}
}
}
if change.exists {
knownModFiles[uri] = struct{}{}
} else {
delete(knownModFiles, uri)
}
}
}
if knownModFiles != nil {
var activeModFiles map[span.URI]struct{}
if wm.go111moduleOff {
// If GO111MODULE=off, the set of active go.mod files is unchanged.
activeModFiles = wm.activeModFiles
} else {
activeModFiles = make(map[span.URI]struct{})
for uri := range knownModFiles {
// Legacy mode only considers a module a workspace root, so don't
// update the active go.mod files map.
if wm.moduleSource == legacyWorkspace && source.CompareURI(modURI(wm.root), uri) != 0 {
continue
}
activeModFiles[uri] = struct{}{}
}
}
// Any change to modules triggers a new version.
return &workspace{
root: wm.root,
moduleSource: moduleSource,
activeModFiles: activeModFiles,
knownModFiles: knownModFiles,
file: modFile,
wsDirs: wm.wsDirs,
}, true
}
// No change. Just return wm, since it is immutable.
return wm, false
}
// goplsModURI returns the URI for the gopls.mod file contained in root.
func goplsModURI(root span.URI) span.URI {
return span.URIFromPath(filepath.Join(root.Filename(), "gopls.mod"))
}
// 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"
}
// 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)
if err != nil {
return false, err
}
return fileHandleExists(fh)
}
// fileHandleExists reports if the file underlying fh actually exits.
func fileHandleExists(fh source.FileHandle) (bool, error) {
_, err := fh.Read()
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// TODO(rFindley): replace this (and similar) with a uripath package analogous
// to filepath.
func dirURI(uri span.URI) span.URI {
return span.URIFromPath(filepath.Dir(uri.Filename()))
}
// 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 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, errors.Errorf("parsing gopls.mod: %w", err)
}
modFiles := make(map[span.URI]struct{})
for _, replace := range modFile.Replace {
if replace.New.Version != "" {
return nil, nil, errors.Errorf("gopls.mod: replaced module %q@%q must not have version", replace.New.Path, replace.New.Version)
}
dirFP := filepath.FromSlash(replace.New.Path)
if !filepath.IsAbs(dirFP) {
dirFP = filepath.Join(root.Filename(), dirFP)
// The resulting modfile must use absolute paths, so that it can be
// written to a temp directory.
replace.New.Path = dirFP
}
modURI := span.URIFromPath(filepath.Join(dirFP, "go.mod"))
modFiles[modURI] = struct{}{}
}
return modFile, modFiles, nil
}
// errExhausted is returned by findModules if the file scan limit is reached.
var errExhausted = errors.New("exhausted")
// Limit go.mod search to 1 million files. As a point of reference,
// Kubernetes has 22K files (as of 2020-11-24).
const fileLimit = 1000000
// findModules recursively walks the root directory looking for go.mod files,
// returning the set of modules it discovers. If modLimit is non-zero,
// searching stops once modLimit modules have been found.
//
// TODO(rfindley): consider overlays.
func findModules(ctx context.Context, root span.URI, modLimit int) (map[span.URI]struct{}, error) {
// Walk the view's folder to find all modules in the view.
modFiles := make(map[span.URI]struct{})
searched := 0
errDone := errors.New("done")
err := filepath.Walk(root.Filename(), func(path string, info os.FileInfo, err error) error {
if err != nil {
// Probably a permission error. Keep looking.
return filepath.SkipDir
}
// For any path that is not the workspace folder, check if the path
// would be ignored by the go command. Vendor directories also do not
// contain workspace modules.
if info.IsDir() && path != root.Filename() {
suffix := strings.TrimPrefix(path, root.Filename())
switch {
case checkIgnored(suffix),
strings.Contains(filepath.ToSlash(suffix), "/vendor/"):
return filepath.SkipDir
}
}
// We're only interested in go.mod files.
uri := span.URIFromPath(path)
if isGoMod(uri) {
modFiles[uri] = struct{}{}
}
if modLimit > 0 && len(modFiles) >= modLimit {
return errDone
}
searched++
if fileLimit > 0 && searched >= fileLimit {
return errExhausted
}
return nil
})
if err == errDone {
return modFiles, nil
}
return modFiles, err
}