github.com/jd-ly/tools@v0.5.7/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  	"os"
    10  	"path/filepath"
    11  	"sort"
    12  	"strings"
    13  	"sync"
    14  
    15  	"golang.org/x/mod/modfile"
    16  	"github.com/jd-ly/tools/internal/event"
    17  	"github.com/jd-ly/tools/internal/lsp/source"
    18  	"github.com/jd-ly/tools/internal/span"
    19  	"github.com/jd-ly/tools/internal/xcontext"
    20  	errors "golang.org/x/xerrors"
    21  )
    22  
    23  type workspaceSource int
    24  
    25  const (
    26  	legacyWorkspace = iota
    27  	goplsModWorkspace
    28  	fileSystemWorkspace
    29  )
    30  
    31  func (s workspaceSource) String() string {
    32  	switch s {
    33  	case legacyWorkspace:
    34  		return "legacy"
    35  	case goplsModWorkspace:
    36  		return "gopls.mod"
    37  	case fileSystemWorkspace:
    38  		return "file system"
    39  	default:
    40  		return "!(unknown module source)"
    41  	}
    42  }
    43  
    44  // workspace tracks go.mod files in the workspace, along with the
    45  // gopls.mod file, to provide support for multi-module workspaces.
    46  //
    47  // Specifically, it provides:
    48  //  - the set of modules contained within in the workspace root considered to
    49  //    be 'active'
    50  //  - the workspace modfile, to be used for the go command `-modfile` flag
    51  //  - the set of workspace directories
    52  //
    53  // This type is immutable (or rather, idempotent), so that it may be shared
    54  // across multiple snapshots.
    55  type workspace struct {
    56  	root         span.URI
    57  	moduleSource workspaceSource
    58  
    59  	// activeModFiles holds the active go.mod files.
    60  	activeModFiles map[span.URI]struct{}
    61  
    62  	// knownModFiles holds the set of all go.mod files in the workspace.
    63  	// In all modes except for legacy, this is equivalent to modFiles.
    64  	knownModFiles map[span.URI]struct{}
    65  
    66  	// go111moduleOff indicates whether GO111MODULE=off has been configured in
    67  	// the environment.
    68  	go111moduleOff bool
    69  
    70  	// The workspace module is lazily re-built once after being invalidated.
    71  	// buildMu+built guards this reconstruction.
    72  	//
    73  	// file and wsDirs may be non-nil even if built == false, if they were copied
    74  	// from the previous workspace module version. In this case, they will be
    75  	// preserved if building fails.
    76  	buildMu  sync.Mutex
    77  	built    bool
    78  	buildErr error
    79  	file     *modfile.File
    80  	wsDirs   map[span.URI]struct{}
    81  }
    82  
    83  func newWorkspace(ctx context.Context, root span.URI, fs source.FileSource, go111moduleOff bool, experimental bool) (*workspace, error) {
    84  	// In experimental mode, the user may have a gopls.mod file that defines
    85  	// their workspace.
    86  	if experimental {
    87  		goplsModFH, err := fs.GetFile(ctx, goplsModURI(root))
    88  		if err != nil {
    89  			return nil, err
    90  		}
    91  		contents, err := goplsModFH.Read()
    92  		if err == nil {
    93  			file, activeModFiles, err := parseGoplsMod(root, goplsModFH.URI(), contents)
    94  			if err != nil {
    95  				return nil, err
    96  			}
    97  			return &workspace{
    98  				root:           root,
    99  				activeModFiles: activeModFiles,
   100  				knownModFiles:  activeModFiles,
   101  				file:           file,
   102  				moduleSource:   goplsModWorkspace,
   103  			}, nil
   104  		}
   105  	}
   106  	// Otherwise, in all other modes, search for all of the go.mod files in the
   107  	// workspace.
   108  	knownModFiles, err := findModules(ctx, root, 0)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  	// When GO111MODULE=off, there are no active go.mod files.
   113  	if go111moduleOff {
   114  		return &workspace{
   115  			root:           root,
   116  			moduleSource:   legacyWorkspace,
   117  			knownModFiles:  knownModFiles,
   118  			go111moduleOff: true,
   119  		}, nil
   120  	}
   121  	// In legacy mode, not all known go.mod files will be considered active.
   122  	if !experimental {
   123  		activeModFiles, err := getLegacyModules(ctx, root, fs)
   124  		if err != nil {
   125  			return nil, err
   126  		}
   127  		return &workspace{
   128  			root:           root,
   129  			activeModFiles: activeModFiles,
   130  			knownModFiles:  knownModFiles,
   131  			moduleSource:   legacyWorkspace,
   132  		}, nil
   133  	}
   134  	return &workspace{
   135  		root:           root,
   136  		activeModFiles: knownModFiles,
   137  		knownModFiles:  knownModFiles,
   138  		moduleSource:   fileSystemWorkspace,
   139  	}, nil
   140  }
   141  
   142  func (wm *workspace) getKnownModFiles() map[span.URI]struct{} {
   143  	return wm.knownModFiles
   144  }
   145  
   146  func (wm *workspace) getActiveModFiles() map[span.URI]struct{} {
   147  	return wm.activeModFiles
   148  }
   149  
   150  // modFile gets the workspace modfile associated with this workspace,
   151  // computing it if it doesn't exist.
   152  //
   153  // A fileSource must be passed in to solve a chicken-egg problem: it is not
   154  // correct to pass in the snapshot file source to newWorkspace when
   155  // invalidating, because at the time these are called the snapshot is locked.
   156  // So we must pass it in later on when actually using the modFile.
   157  func (wm *workspace) modFile(ctx context.Context, fs source.FileSource) (*modfile.File, error) {
   158  	wm.build(ctx, fs)
   159  	return wm.file, wm.buildErr
   160  }
   161  
   162  func (wm *workspace) build(ctx context.Context, fs source.FileSource) {
   163  	wm.buildMu.Lock()
   164  	defer wm.buildMu.Unlock()
   165  
   166  	if wm.built {
   167  		return
   168  	}
   169  	// Building should never be cancelled. Since the workspace module is shared
   170  	// across multiple snapshots, doing so would put us in a bad state, and it
   171  	// would not be obvious to the user how to recover.
   172  	ctx = xcontext.Detach(ctx)
   173  
   174  	// If our module source is not gopls.mod, try to build the workspace module
   175  	// from modules. Fall back on the pre-existing mod file if parsing fails.
   176  	if wm.moduleSource != goplsModWorkspace {
   177  		file, err := buildWorkspaceModFile(ctx, wm.activeModFiles, fs)
   178  		switch {
   179  		case err == nil:
   180  			wm.file = file
   181  		case wm.file != nil:
   182  			// Parsing failed, but we have a previous file version.
   183  			event.Error(ctx, "building workspace mod file", err)
   184  		default:
   185  			// No file to fall back on.
   186  			wm.buildErr = err
   187  		}
   188  	}
   189  	if wm.file != nil {
   190  		wm.wsDirs = map[span.URI]struct{}{
   191  			wm.root: {},
   192  		}
   193  		for _, r := range wm.file.Replace {
   194  			// We may be replacing a module with a different version, not a path
   195  			// on disk.
   196  			if r.New.Version != "" {
   197  				continue
   198  			}
   199  			wm.wsDirs[span.URIFromPath(r.New.Path)] = struct{}{}
   200  		}
   201  	}
   202  	// Ensure that there is always at least the root dir.
   203  	if len(wm.wsDirs) == 0 {
   204  		wm.wsDirs = map[span.URI]struct{}{
   205  			wm.root: {},
   206  		}
   207  	}
   208  	wm.built = true
   209  }
   210  
   211  // dirs returns the workspace directories for the loaded modules.
   212  func (wm *workspace) dirs(ctx context.Context, fs source.FileSource) []span.URI {
   213  	wm.build(ctx, fs)
   214  	var dirs []span.URI
   215  	for d := range wm.wsDirs {
   216  		dirs = append(dirs, d)
   217  	}
   218  	sort.Slice(dirs, func(i, j int) bool {
   219  		return source.CompareURI(dirs[i], dirs[j]) < 0
   220  	})
   221  	return dirs
   222  }
   223  
   224  // invalidate returns a (possibly) new workspaceModule after invalidating
   225  // changedURIs. If wm is still valid in the presence of changedURIs, it returns
   226  // itself unmodified.
   227  func (wm *workspace) invalidate(ctx context.Context, changes map[span.URI]*fileChange) (*workspace, bool) {
   228  	// Prevent races to wm.modFile or wm.wsDirs below, if wm has not yet been
   229  	// built.
   230  	wm.buildMu.Lock()
   231  	defer wm.buildMu.Unlock()
   232  
   233  	// Any gopls.mod change is processed first, followed by go.mod changes, as
   234  	// changes to gopls.mod may affect the set of active go.mod files.
   235  	var (
   236  		// New values. We return a new workspace module if and only if
   237  		// knownModFiles is non-nil.
   238  		knownModFiles map[span.URI]struct{}
   239  		moduleSource  = wm.moduleSource
   240  		modFile       = wm.file
   241  		err           error
   242  	)
   243  	if wm.moduleSource == goplsModWorkspace {
   244  		// If we are currently reading the modfile from gopls.mod, we default to
   245  		// preserving it even if module metadata changes (which may be the case if
   246  		// a go.sum file changes).
   247  		modFile = wm.file
   248  	}
   249  	// First handle changes to the gopls.mod file.
   250  	if wm.moduleSource != legacyWorkspace {
   251  		// If gopls.mod has changed we need to either re-read it if it exists or
   252  		// walk the filesystem if it doesn't exist.
   253  		gmURI := goplsModURI(wm.root)
   254  		if change, ok := changes[gmURI]; ok {
   255  			if change.exists {
   256  				// Only invalidate if the gopls.mod actually parses. Otherwise, stick with the current gopls.mod
   257  				parsedFile, parsedModules, err := parseGoplsMod(wm.root, gmURI, change.content)
   258  				if err == nil {
   259  					modFile = parsedFile
   260  					moduleSource = goplsModWorkspace
   261  					knownModFiles = parsedModules
   262  				} else {
   263  					// Note that modFile is not invalidated here.
   264  					event.Error(ctx, "parsing gopls.mod", err)
   265  				}
   266  			} else {
   267  				// gopls.mod is deleted. search for modules again.
   268  				moduleSource = fileSystemWorkspace
   269  				knownModFiles, err = findModules(ctx, wm.root, 0)
   270  				// the modFile is no longer valid.
   271  				if err != nil {
   272  					event.Error(ctx, "finding file system modules", err)
   273  				}
   274  				modFile = nil
   275  			}
   276  		}
   277  	}
   278  
   279  	// Next, handle go.mod changes that could affect our set of tracked modules.
   280  	// If we're reading our tracked modules from the gopls.mod, there's nothing
   281  	// to do here.
   282  	if wm.moduleSource != goplsModWorkspace {
   283  		for uri, change := range changes {
   284  			// If a go.mod file has changed, we may need to update the set of active
   285  			// modules.
   286  			if !isGoMod(uri) {
   287  				continue
   288  			}
   289  			if !source.InDir(wm.root.Filename(), uri.Filename()) {
   290  				// Otherwise, the module must be contained within the workspace root.
   291  				continue
   292  			}
   293  			if knownModFiles == nil {
   294  				knownModFiles = make(map[span.URI]struct{})
   295  				for k := range wm.knownModFiles {
   296  					knownModFiles[k] = struct{}{}
   297  				}
   298  			}
   299  			if change.exists {
   300  				knownModFiles[uri] = struct{}{}
   301  			} else {
   302  				delete(knownModFiles, uri)
   303  			}
   304  		}
   305  	}
   306  	if knownModFiles != nil {
   307  		var activeModFiles map[span.URI]struct{}
   308  		if wm.go111moduleOff {
   309  			// If GO111MODULE=off, the set of active go.mod files is unchanged.
   310  			activeModFiles = wm.activeModFiles
   311  		} else {
   312  			activeModFiles = make(map[span.URI]struct{})
   313  			for uri := range knownModFiles {
   314  				// Legacy mode only considers a module a workspace root, so don't
   315  				// update the active go.mod files map.
   316  				if wm.moduleSource == legacyWorkspace && source.CompareURI(modURI(wm.root), uri) != 0 {
   317  					continue
   318  				}
   319  				activeModFiles[uri] = struct{}{}
   320  			}
   321  		}
   322  		// Any change to modules triggers a new version.
   323  		return &workspace{
   324  			root:           wm.root,
   325  			moduleSource:   moduleSource,
   326  			activeModFiles: activeModFiles,
   327  			knownModFiles:  knownModFiles,
   328  			file:           modFile,
   329  			wsDirs:         wm.wsDirs,
   330  		}, true
   331  	}
   332  	// No change. Just return wm, since it is immutable.
   333  	return wm, false
   334  }
   335  
   336  // goplsModURI returns the URI for the gopls.mod file contained in root.
   337  func goplsModURI(root span.URI) span.URI {
   338  	return span.URIFromPath(filepath.Join(root.Filename(), "gopls.mod"))
   339  }
   340  
   341  // modURI returns the URI for the go.mod file contained in root.
   342  func modURI(root span.URI) span.URI {
   343  	return span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
   344  }
   345  
   346  // isGoMod reports if uri is a go.mod file.
   347  func isGoMod(uri span.URI) bool {
   348  	return filepath.Base(uri.Filename()) == "go.mod"
   349  }
   350  
   351  // fileExists reports if the file uri exists within source.
   352  func fileExists(ctx context.Context, uri span.URI, source source.FileSource) (bool, error) {
   353  	fh, err := source.GetFile(ctx, uri)
   354  	if err != nil {
   355  		return false, err
   356  	}
   357  	return fileHandleExists(fh)
   358  }
   359  
   360  // fileHandleExists reports if the file underlying fh actually exits.
   361  func fileHandleExists(fh source.FileHandle) (bool, error) {
   362  	_, err := fh.Read()
   363  	if err == nil {
   364  		return true, nil
   365  	}
   366  	if os.IsNotExist(err) {
   367  		return false, nil
   368  	}
   369  	return false, err
   370  }
   371  
   372  // TODO(rFindley): replace this (and similar) with a uripath package analogous
   373  // to filepath.
   374  func dirURI(uri span.URI) span.URI {
   375  	return span.URIFromPath(filepath.Dir(uri.Filename()))
   376  }
   377  
   378  // getLegacyModules returns a module set containing at most the root module.
   379  func getLegacyModules(ctx context.Context, root span.URI, fs source.FileSource) (map[span.URI]struct{}, error) {
   380  	uri := span.URIFromPath(filepath.Join(root.Filename(), "go.mod"))
   381  	modules := make(map[span.URI]struct{})
   382  	exists, err := fileExists(ctx, uri, fs)
   383  	if err != nil {
   384  		return nil, err
   385  	}
   386  	if exists {
   387  		modules[uri] = struct{}{}
   388  	}
   389  	return modules, nil
   390  }
   391  
   392  func parseGoplsMod(root, uri span.URI, contents []byte) (*modfile.File, map[span.URI]struct{}, error) {
   393  	modFile, err := modfile.Parse(uri.Filename(), contents, nil)
   394  	if err != nil {
   395  		return nil, nil, errors.Errorf("parsing gopls.mod: %w", err)
   396  	}
   397  	modFiles := make(map[span.URI]struct{})
   398  	for _, replace := range modFile.Replace {
   399  		if replace.New.Version != "" {
   400  			return nil, nil, errors.Errorf("gopls.mod: replaced module %q@%q must not have version", replace.New.Path, replace.New.Version)
   401  		}
   402  		dirFP := filepath.FromSlash(replace.New.Path)
   403  		if !filepath.IsAbs(dirFP) {
   404  			dirFP = filepath.Join(root.Filename(), dirFP)
   405  			// The resulting modfile must use absolute paths, so that it can be
   406  			// written to a temp directory.
   407  			replace.New.Path = dirFP
   408  		}
   409  		modURI := span.URIFromPath(filepath.Join(dirFP, "go.mod"))
   410  		modFiles[modURI] = struct{}{}
   411  	}
   412  	return modFile, modFiles, nil
   413  }
   414  
   415  // errExhausted is returned by findModules if the file scan limit is reached.
   416  var errExhausted = errors.New("exhausted")
   417  
   418  // Limit go.mod search to 1 million files. As a point of reference,
   419  // Kubernetes has 22K files (as of 2020-11-24).
   420  const fileLimit = 1000000
   421  
   422  // findModules recursively walks the root directory looking for go.mod files,
   423  // returning the set of modules it discovers. If modLimit is non-zero,
   424  // searching stops once modLimit modules have been found.
   425  //
   426  // TODO(rfindley): consider overlays.
   427  func findModules(ctx context.Context, root span.URI, modLimit int) (map[span.URI]struct{}, error) {
   428  	// Walk the view's folder to find all modules in the view.
   429  	modFiles := make(map[span.URI]struct{})
   430  	searched := 0
   431  	errDone := errors.New("done")
   432  	err := filepath.Walk(root.Filename(), func(path string, info os.FileInfo, err error) error {
   433  		if err != nil {
   434  			// Probably a permission error. Keep looking.
   435  			return filepath.SkipDir
   436  		}
   437  		// For any path that is not the workspace folder, check if the path
   438  		// would be ignored by the go command. Vendor directories also do not
   439  		// contain workspace modules.
   440  		if info.IsDir() && path != root.Filename() {
   441  			suffix := strings.TrimPrefix(path, root.Filename())
   442  			switch {
   443  			case checkIgnored(suffix),
   444  				strings.Contains(filepath.ToSlash(suffix), "/vendor/"):
   445  				return filepath.SkipDir
   446  			}
   447  		}
   448  		// We're only interested in go.mod files.
   449  		uri := span.URIFromPath(path)
   450  		if isGoMod(uri) {
   451  			modFiles[uri] = struct{}{}
   452  		}
   453  		if modLimit > 0 && len(modFiles) >= modLimit {
   454  			return errDone
   455  		}
   456  		searched++
   457  		if fileLimit > 0 && searched >= fileLimit {
   458  			return errExhausted
   459  		}
   460  		return nil
   461  	})
   462  	if err == errDone {
   463  		return modFiles, nil
   464  	}
   465  	return modFiles, err
   466  }