github.com/powerman/golang-tools@v0.1.11-0.20220410185822-5ad214d8d803/internal/lsp/cache/workspace.go (about)

     1  // Copyright 2020 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package cache
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"os"
    11  	"path/filepath"
    12  	"sort"
    13  	"strings"
    14  	"sync"
    15  
    16  	"golang.org/x/mod/modfile"
    17  	"github.com/powerman/golang-tools/internal/event"
    18  	"github.com/powerman/golang-tools/internal/lsp/source"
    19  	"github.com/powerman/golang-tools/internal/span"
    20  	"github.com/powerman/golang-tools/internal/xcontext"
    21  	errors "golang.org/x/xerrors"
    22  )
    23  
    24  // workspaceSource reports how the set of active modules has been derived.
    25  type workspaceSource int
    26  
    27  const (
    28  	legacyWorkspace     = iota // non-module or single module mode
    29  	goplsModWorkspace          // modules provided by a gopls.mod file
    30  	goWorkWorkspace            // modules provided by a go.work file
    31  	fileSystemWorkspace        // modules scanned from the filesystem
    32  )
    33  
    34  func (s workspaceSource) String() string {
    35  	switch s {
    36  	case legacyWorkspace:
    37  		return "legacy"
    38  	case goplsModWorkspace:
    39  		return "gopls.mod"
    40  	case goWorkWorkspace:
    41  		return "go.work"
    42  	case fileSystemWorkspace:
    43  		return "file system"
    44  	default:
    45  		return "!(unknown module source)"
    46  	}
    47  }
    48  
    49  // workspace tracks go.mod files in the workspace, along with the
    50  // gopls.mod file, to provide support for multi-module workspaces.
    51  //
    52  // Specifically, it provides:
    53  //  - the set of modules contained within in the workspace root considered to
    54  //    be 'active'
    55  //  - the workspace modfile, to be used for the go command `-modfile` flag
    56  //  - the set of workspace directories
    57  //
    58  // This type is immutable (or rather, idempotent), so that it may be shared
    59  // across multiple snapshots.
    60  type workspace struct {
    61  	root         span.URI
    62  	excludePath  func(string) bool
    63  	moduleSource workspaceSource
    64  
    65  	// activeModFiles holds the active go.mod files.
    66  	activeModFiles map[span.URI]struct{}
    67  
    68  	// knownModFiles holds the set of all go.mod files in the workspace.
    69  	// In all modes except for legacy, this is equivalent to modFiles.
    70  	knownModFiles map[span.URI]struct{}
    71  
    72  	// workFile, if nonEmpty, is the go.work file for the workspace.
    73  	workFile span.URI
    74  
    75  	// The workspace module is lazily re-built once after being invalidated.
    76  	// buildMu+built guards this reconstruction.
    77  	//
    78  	// file and wsDirs may be non-nil even if built == false, if they were copied
    79  	// from the previous workspace module version. In this case, they will be
    80  	// preserved if building fails.
    81  	buildMu  sync.Mutex
    82  	built    bool
    83  	buildErr error
    84  	mod      *modfile.File
    85  	sum      []byte
    86  	wsDirs   map[span.URI]struct{}
    87  }
    88  
    89  // newWorkspace creates a new workspace at the given root directory,
    90  // determining its module source based on the presence of a gopls.mod or
    91  // go.work file, and the go111moduleOff and useWsModule settings.
    92  //
    93  // If useWsModule is set, the workspace may use a synthetic mod file replacing
    94  // all modules in the root.
    95  //
    96  // If there is no active workspace file (a gopls.mod or go.work), newWorkspace
    97  // scans the filesystem to find modules.
    98  func newWorkspace(ctx context.Context, root span.URI, fs source.FileSource, excludePath func(string) bool, go111moduleOff bool, useWsModule bool) (*workspace, error) {
    99  	ws := &workspace{
   100  		root:        root,
   101  		excludePath: excludePath,
   102  	}
   103  
   104  	// The user may have a gopls.mod or go.work file that defines their
   105  	// workspace.
   106  	if err := loadExplicitWorkspaceFile(ctx, ws, fs); err == nil {
   107  		return ws, nil
   108  	}
   109  
   110  	// Otherwise, in all other modes, search for all of the go.mod files in the
   111  	// workspace.
   112  	knownModFiles, err := findModules(root, excludePath, 0)
   113  	if err != nil {
   114  		return nil, err
   115  	}
   116  	ws.knownModFiles = knownModFiles
   117  
   118  	switch {
   119  	case go111moduleOff:
   120  		ws.moduleSource = legacyWorkspace
   121  	case useWsModule:
   122  		ws.activeModFiles = knownModFiles
   123  		ws.moduleSource = fileSystemWorkspace
   124  	default:
   125  		ws.moduleSource = legacyWorkspace
   126  		activeModFiles, err := getLegacyModules(ctx, root, fs)
   127  		if err != nil {
   128  			return nil, err
   129  		}
   130  		ws.activeModFiles = activeModFiles
   131  	}
   132  	return ws, nil
   133  }
   134  
   135  // loadExplicitWorkspaceFile loads workspace information from go.work or
   136  // gopls.mod files, setting the active modules, mod file, and module source
   137  // accordingly.
   138  func loadExplicitWorkspaceFile(ctx context.Context, ws *workspace, fs source.FileSource) error {
   139  	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
   140  		fh, err := fs.GetFile(ctx, uriForSource(ws.root, src))
   141  		if err != nil {
   142  			return err
   143  		}
   144  		contents, err := fh.Read()
   145  		if err != nil {
   146  			continue
   147  		}
   148  		var file *modfile.File
   149  		var activeModFiles map[span.URI]struct{}
   150  		switch src {
   151  		case goWorkWorkspace:
   152  			file, activeModFiles, err = parseGoWork(ctx, ws.root, fh.URI(), contents, fs)
   153  			ws.workFile = fh.URI()
   154  		case goplsModWorkspace:
   155  			file, activeModFiles, err = parseGoplsMod(ws.root, fh.URI(), contents)
   156  		}
   157  		if err != nil {
   158  			ws.buildMu.Lock()
   159  			ws.built = true
   160  			ws.buildErr = err
   161  			ws.buildMu.Unlock()
   162  		}
   163  		ws.mod = file
   164  		ws.activeModFiles = activeModFiles
   165  		ws.moduleSource = src
   166  		return nil
   167  	}
   168  	return noHardcodedWorkspace
   169  }
   170  
   171  var noHardcodedWorkspace = errors.New("no hardcoded workspace")
   172  
   173  func (w *workspace) getKnownModFiles() map[span.URI]struct{} {
   174  	return w.knownModFiles
   175  }
   176  
   177  func (w *workspace) getActiveModFiles() map[span.URI]struct{} {
   178  	return w.activeModFiles
   179  }
   180  
   181  // modFile gets the workspace modfile associated with this workspace,
   182  // computing it if it doesn't exist.
   183  //
   184  // A fileSource must be passed in to solve a chicken-egg problem: it is not
   185  // correct to pass in the snapshot file source to newWorkspace when
   186  // invalidating, because at the time these are called the snapshot is locked.
   187  // So we must pass it in later on when actually using the modFile.
   188  func (w *workspace) modFile(ctx context.Context, fs source.FileSource) (*modfile.File, error) {
   189  	w.build(ctx, fs)
   190  	return w.mod, w.buildErr
   191  }
   192  
   193  func (w *workspace) sumFile(ctx context.Context, fs source.FileSource) ([]byte, error) {
   194  	w.build(ctx, fs)
   195  	return w.sum, w.buildErr
   196  }
   197  
   198  func (w *workspace) build(ctx context.Context, fs source.FileSource) {
   199  	w.buildMu.Lock()
   200  	defer w.buildMu.Unlock()
   201  
   202  	if w.built {
   203  		return
   204  	}
   205  	// Building should never be cancelled. Since the workspace module is shared
   206  	// across multiple snapshots, doing so would put us in a bad state, and it
   207  	// would not be obvious to the user how to recover.
   208  	ctx = xcontext.Detach(ctx)
   209  
   210  	// If our module source is not gopls.mod, try to build the workspace module
   211  	// from modules. Fall back on the pre-existing mod file if parsing fails.
   212  	if w.moduleSource != goplsModWorkspace {
   213  		file, err := buildWorkspaceModFile(ctx, w.activeModFiles, fs)
   214  		switch {
   215  		case err == nil:
   216  			w.mod = file
   217  		case w.mod != nil:
   218  			// Parsing failed, but we have a previous file version.
   219  			event.Error(ctx, "building workspace mod file", err)
   220  		default:
   221  			// No file to fall back on.
   222  			w.buildErr = err
   223  		}
   224  	}
   225  	if w.mod != nil {
   226  		w.wsDirs = map[span.URI]struct{}{
   227  			w.root: {},
   228  		}
   229  		for _, r := range w.mod.Replace {
   230  			// We may be replacing a module with a different version, not a path
   231  			// on disk.
   232  			if r.New.Version != "" {
   233  				continue
   234  			}
   235  			w.wsDirs[span.URIFromPath(r.New.Path)] = struct{}{}
   236  		}
   237  	}
   238  	// Ensure that there is always at least the root dir.
   239  	if len(w.wsDirs) == 0 {
   240  		w.wsDirs = map[span.URI]struct{}{
   241  			w.root: {},
   242  		}
   243  	}
   244  	sum, err := buildWorkspaceSumFile(ctx, w.activeModFiles, fs)
   245  	if err == nil {
   246  		w.sum = sum
   247  	} else {
   248  		event.Error(ctx, "building workspace sum file", err)
   249  	}
   250  	w.built = true
   251  }
   252  
   253  // dirs returns the workspace directories for the loaded modules.
   254  func (w *workspace) dirs(ctx context.Context, fs source.FileSource) []span.URI {
   255  	w.build(ctx, fs)
   256  	var dirs []span.URI
   257  	for d := range w.wsDirs {
   258  		dirs = append(dirs, d)
   259  	}
   260  	sort.Slice(dirs, func(i, j int) bool {
   261  		return source.CompareURI(dirs[i], dirs[j]) < 0
   262  	})
   263  	return dirs
   264  }
   265  
   266  // invalidate returns a (possibly) new workspace after invalidating the changed
   267  // files. If w is still valid in the presence of changedURIs, it returns itself
   268  // unmodified.
   269  //
   270  // The returned changed and reload flags control the level of invalidation.
   271  // Some workspace changes may affect workspace contents without requiring a
   272  // reload of metadata (for example, unsaved changes to a go.mod or go.sum
   273  // file).
   274  func (w *workspace) invalidate(ctx context.Context, changes map[span.URI]*fileChange, fs source.FileSource) (_ *workspace, changed, reload bool) {
   275  	// Prevent races to w.modFile or w.wsDirs below, if w has not yet been built.
   276  	w.buildMu.Lock()
   277  	defer w.buildMu.Unlock()
   278  
   279  	// Clone the workspace. This may be discarded if nothing changed.
   280  	result := &workspace{
   281  		root:           w.root,
   282  		moduleSource:   w.moduleSource,
   283  		knownModFiles:  make(map[span.URI]struct{}),
   284  		activeModFiles: make(map[span.URI]struct{}),
   285  		workFile:       w.workFile,
   286  		mod:            w.mod,
   287  		sum:            w.sum,
   288  		wsDirs:         w.wsDirs,
   289  		excludePath:    w.excludePath,
   290  	}
   291  	for k, v := range w.knownModFiles {
   292  		result.knownModFiles[k] = v
   293  	}
   294  	for k, v := range w.activeModFiles {
   295  		result.activeModFiles[k] = v
   296  	}
   297  
   298  	// First handle changes to the go.work or gopls.mod file. This must be
   299  	// considered before any changes to go.mod or go.sum files, as these files
   300  	// determine which modules we care about. If go.work/gopls.mod has changed
   301  	// we need to either re-read it if it exists or walk the filesystem if it
   302  	// has been deleted. go.work should override the gopls.mod if both exist.
   303  	changed, reload = handleWorkspaceFileChanges(ctx, result, changes, fs)
   304  	// Next, handle go.mod changes that could affect our workspace.
   305  	for uri, change := range changes {
   306  		// Otherwise, we only care about go.mod files in the workspace directory.
   307  		if change.isUnchanged || !isGoMod(uri) || !source.InDir(result.root.Filename(), uri.Filename()) {
   308  			continue
   309  		}
   310  		changed = true
   311  		active := result.moduleSource != legacyWorkspace || source.CompareURI(modURI(w.root), uri) == 0
   312  		reload = reload || (active && change.fileHandle.Saved())
   313  		// Don't mess with the list of mod files if using go.work or gopls.mod.
   314  		if result.moduleSource == goplsModWorkspace || result.moduleSource == goWorkWorkspace {
   315  			continue
   316  		}
   317  		if change.exists {
   318  			result.knownModFiles[uri] = struct{}{}
   319  			if active {
   320  				result.activeModFiles[uri] = struct{}{}
   321  			}
   322  		} else {
   323  			delete(result.knownModFiles, uri)
   324  			delete(result.activeModFiles, uri)
   325  		}
   326  	}
   327  
   328  	// Finally, process go.sum changes for any modules that are now active.
   329  	for uri, change := range changes {
   330  		if !isGoSum(uri) {
   331  			continue
   332  		}
   333  		// TODO(rFindley) factor out this URI mangling.
   334  		dir := filepath.Dir(uri.Filename())
   335  		modURI := span.URIFromPath(filepath.Join(dir, "go.mod"))
   336  		if _, active := result.activeModFiles[modURI]; !active {
   337  			continue
   338  		}
   339  		// Only changes to active go.sum files actually cause the workspace to
   340  		// change.
   341  		changed = true
   342  		reload = reload || change.fileHandle.Saved()
   343  	}
   344  
   345  	if !changed {
   346  		return w, false, false
   347  	}
   348  
   349  	return result, changed, reload
   350  }
   351  
   352  // handleWorkspaceFileChanges handles changes related to a go.work or gopls.mod
   353  // file, updating ws accordingly. ws.root must be set.
   354  func handleWorkspaceFileChanges(ctx context.Context, ws *workspace, changes map[span.URI]*fileChange, fs source.FileSource) (changed, reload bool) {
   355  	// If go.work/gopls.mod has changed we need to either re-read it if it
   356  	// exists or walk the filesystem if it has been deleted.
   357  	// go.work should override the gopls.mod if both exist.
   358  	for _, src := range []workspaceSource{goWorkWorkspace, goplsModWorkspace} {
   359  		uri := uriForSource(ws.root, src)
   360  		// File opens/closes are just no-ops.
   361  		change, ok := changes[uri]
   362  		if !ok {
   363  			continue
   364  		}
   365  		if change.isUnchanged {
   366  			break
   367  		}
   368  		if change.exists {
   369  			// Only invalidate if the file if it actually parses.
   370  			// Otherwise, stick with the current file.
   371  			var parsedFile *modfile.File
   372  			var parsedModules map[span.URI]struct{}
   373  			var err error
   374  			switch src {
   375  			case goWorkWorkspace:
   376  				parsedFile, parsedModules, err = parseGoWork(ctx, ws.root, uri, change.content, fs)
   377  			case goplsModWorkspace:
   378  				parsedFile, parsedModules, err = parseGoplsMod(ws.root, uri, change.content)
   379  			}
   380  			if err != nil {
   381  				// An unparseable file should not invalidate the workspace:
   382  				// nothing good could come from changing the workspace in
   383  				// this case.
   384  				event.Error(ctx, fmt.Sprintf("parsing %s", filepath.Base(uri.Filename())), err)
   385  			} else {
   386  				// only update the modfile if it parsed.
   387  				changed = true
   388  				reload = change.fileHandle.Saved()
   389  				ws.mod = parsedFile
   390  				ws.moduleSource = src
   391  				ws.knownModFiles = parsedModules
   392  				ws.activeModFiles = make(map[span.URI]struct{})
   393  				for k, v := range parsedModules {
   394  					ws.activeModFiles[k] = v
   395  				}
   396  			}
   397  			break // We've found an explicit workspace file, so can stop looking.
   398  		} else {
   399  			// go.work/gopls.mod is deleted. search for modules again.
   400  			changed = true
   401  			reload = true
   402  			ws.moduleSource = fileSystemWorkspace
   403  			// The parsed file is no longer valid.
   404  			ws.mod = nil
   405  			knownModFiles, err := findModules(ws.root, ws.excludePath, 0)
   406  			if err != nil {
   407  				ws.knownModFiles = nil
   408  				ws.activeModFiles = nil
   409  				event.Error(ctx, "finding file system modules", err)
   410  			} else {
   411  				ws.knownModFiles = knownModFiles
   412  				ws.activeModFiles = make(map[span.URI]struct{})
   413  				for k, v := range ws.knownModFiles {
   414  					ws.activeModFiles[k] = v
   415  				}
   416  			}
   417  		}
   418  	}
   419  	return changed, reload
   420  }
   421  
   422  // goplsModURI returns the URI for the gopls.mod file contained in root.
   423  func uriForSource(root span.URI, src workspaceSource) span.URI {
   424  	var basename string
   425  	switch src {
   426  	case goplsModWorkspace:
   427  		basename = "gopls.mod"
   428  	case goWorkWorkspace:
   429  		basename = "go.work"
   430  	default:
   431  		return ""
   432  	}
   433  	return span.URIFromPath(filepath.Join(root.Filename(), basename))
   434  }
   435  
   436  // modURI returns the URI for the go.mod file contained in root.
   437  func modURI(root span.URI) span.URI {
   438  	return span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
   439  }
   440  
   441  // isGoMod reports if uri is a go.mod file.
   442  func isGoMod(uri span.URI) bool {
   443  	return filepath.Base(uri.Filename()) == "go.mod"
   444  }
   445  
   446  func isGoSum(uri span.URI) bool {
   447  	return filepath.Base(uri.Filename()) == "go.sum" || filepath.Base(uri.Filename()) == "go.work.sum"
   448  }
   449  
   450  // fileExists reports if the file uri exists within source.
   451  func fileExists(ctx context.Context, uri span.URI, source source.FileSource) (bool, error) {
   452  	fh, err := source.GetFile(ctx, uri)
   453  	if err != nil {
   454  		return false, err
   455  	}
   456  	return fileHandleExists(fh)
   457  }
   458  
   459  // fileHandleExists reports if the file underlying fh actually exits.
   460  func fileHandleExists(fh source.FileHandle) (bool, error) {
   461  	_, err := fh.Read()
   462  	if err == nil {
   463  		return true, nil
   464  	}
   465  	if os.IsNotExist(err) {
   466  		return false, nil
   467  	}
   468  	return false, err
   469  }
   470  
   471  // TODO(rFindley): replace this (and similar) with a uripath package analogous
   472  // to filepath.
   473  func dirURI(uri span.URI) span.URI {
   474  	return span.URIFromPath(filepath.Dir(uri.Filename()))
   475  }
   476  
   477  // getLegacyModules returns a module set containing at most the root module.
   478  func getLegacyModules(ctx context.Context, root span.URI, fs source.FileSource) (map[span.URI]struct{}, error) {
   479  	uri := span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
   480  	modules := make(map[span.URI]struct{})
   481  	exists, err := fileExists(ctx, uri, fs)
   482  	if err != nil {
   483  		return nil, err
   484  	}
   485  	if exists {
   486  		modules[uri] = struct{}{}
   487  	}
   488  	return modules, nil
   489  }
   490  
   491  func parseGoWork(ctx context.Context, root, uri span.URI, contents []byte, fs source.FileSource) (*modfile.File, map[span.URI]struct{}, error) {
   492  	workFile, err := modfile.ParseWork(uri.Filename(), contents, nil)
   493  	if err != nil {
   494  		return nil, nil, errors.Errorf("parsing go.work: %w", err)
   495  	}
   496  	modFiles := make(map[span.URI]struct{})
   497  	for _, dir := range workFile.Use {
   498  		// The resulting modfile must use absolute paths, so that it can be
   499  		// written to a temp directory.
   500  		dir.Path = absolutePath(root, dir.Path)
   501  		modURI := span.URIFromPath(filepath.Join(dir.Path, "go.mod"))
   502  		modFiles[modURI] = struct{}{}
   503  	}
   504  	modFile, err := buildWorkspaceModFile(ctx, modFiles, fs)
   505  	if err != nil {
   506  		return nil, nil, err
   507  	}
   508  
   509  	// Require a go directive, per the spec.
   510  	if workFile.Go == nil || workFile.Go.Version == "" {
   511  		return nil, nil, fmt.Errorf("go.work has missing or incomplete go directive")
   512  	}
   513  	if err := modFile.AddGoStmt(workFile.Go.Version); err != nil {
   514  		return nil, nil, err
   515  	}
   516  
   517  	return modFile, modFiles, nil
   518  }
   519  
   520  func parseGoplsMod(root, uri span.URI, contents []byte) (*modfile.File, map[span.URI]struct{}, error) {
   521  	modFile, err := modfile.Parse(uri.Filename(), contents, nil)
   522  	if err != nil {
   523  		return nil, nil, errors.Errorf("parsing gopls.mod: %w", err)
   524  	}
   525  	modFiles := make(map[span.URI]struct{})
   526  	for _, replace := range modFile.Replace {
   527  		if replace.New.Version != "" {
   528  			return nil, nil, errors.Errorf("gopls.mod: replaced module %q@%q must not have version", replace.New.Path, replace.New.Version)
   529  		}
   530  		// The resulting modfile must use absolute paths, so that it can be
   531  		// written to a temp directory.
   532  		replace.New.Path = absolutePath(root, replace.New.Path)
   533  		modURI := span.URIFromPath(filepath.Join(replace.New.Path, "go.mod"))
   534  		modFiles[modURI] = struct{}{}
   535  	}
   536  	return modFile, modFiles, nil
   537  }
   538  
   539  func absolutePath(root span.URI, path string) string {
   540  	dirFP := filepath.FromSlash(path)
   541  	if !filepath.IsAbs(dirFP) {
   542  		dirFP = filepath.Join(root.Filename(), dirFP)
   543  	}
   544  	return dirFP
   545  }
   546  
   547  // errExhausted is returned by findModules if the file scan limit is reached.
   548  var errExhausted = errors.New("exhausted")
   549  
   550  // Limit go.mod search to 1 million files. As a point of reference,
   551  // Kubernetes has 22K files (as of 2020-11-24).
   552  const fileLimit = 1000000
   553  
   554  // findModules recursively walks the root directory looking for go.mod files,
   555  // returning the set of modules it discovers. If modLimit is non-zero,
   556  // searching stops once modLimit modules have been found.
   557  //
   558  // TODO(rfindley): consider overlays.
   559  func findModules(root span.URI, excludePath func(string) bool, modLimit int) (map[span.URI]struct{}, error) {
   560  	// Walk the view's folder to find all modules in the view.
   561  	modFiles := make(map[span.URI]struct{})
   562  	searched := 0
   563  	errDone := errors.New("done")
   564  	err := filepath.Walk(root.Filename(), func(path string, info os.FileInfo, err error) error {
   565  		if err != nil {
   566  			// Probably a permission error. Keep looking.
   567  			return filepath.SkipDir
   568  		}
   569  		// For any path that is not the workspace folder, check if the path
   570  		// would be ignored by the go command. Vendor directories also do not
   571  		// contain workspace modules.
   572  		if info.IsDir() && path != root.Filename() {
   573  			suffix := strings.TrimPrefix(path, root.Filename())
   574  			switch {
   575  			case checkIgnored(suffix),
   576  				strings.Contains(filepath.ToSlash(suffix), "/vendor/"),
   577  				excludePath(suffix):
   578  				return filepath.SkipDir
   579  			}
   580  		}
   581  		// We're only interested in go.mod files.
   582  		uri := span.URIFromPath(path)
   583  		if isGoMod(uri) {
   584  			modFiles[uri] = struct{}{}
   585  		}
   586  		if modLimit > 0 && len(modFiles) >= modLimit {
   587  			return errDone
   588  		}
   589  		searched++
   590  		if fileLimit > 0 && searched >= fileLimit {
   591  			return errExhausted
   592  		}
   593  		return nil
   594  	})
   595  	if err == errDone {
   596  		return modFiles, nil
   597  	}
   598  	return modFiles, err
   599  }