--- /dev/null
+// 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"
+ "strings"
+ "testing"
+
+ "golang.org/x/tools/internal/lsp/fake"
+ "golang.org/x/tools/internal/lsp/source"
+ "golang.org/x/tools/internal/span"
+)
+
+// osFileSource is a fileSource that just reads from the operating system.
+type osFileSource struct {
+ overlays map[span.URI]fakeOverlay
+}
+
+type fakeOverlay struct {
+ source.VersionedFileHandle
+ uri span.URI
+ content string
+ err error
+ saved bool
+}
+
+func (o fakeOverlay) Saved() bool { return o.saved }
+
+func (o fakeOverlay) Read() ([]byte, error) {
+ if o.err != nil {
+ return nil, o.err
+ }
+ return []byte(o.content), nil
+}
+
+func (o fakeOverlay) URI() span.URI {
+ return o.uri
+}
+
+// change updates the file source with the given file content. For convenience,
+// empty content signals a deletion. If saved is true, these changes are
+// persisted to disk.
+func (s *osFileSource) change(ctx context.Context, uri span.URI, content string, saved bool) (*fileChange, error) {
+ if content == "" {
+ delete(s.overlays, uri)
+ if saved {
+ if err := os.Remove(uri.Filename()); err != nil {
+ return nil, err
+ }
+ }
+ fh, err := s.GetFile(ctx, uri)
+ if err != nil {
+ return nil, err
+ }
+ data, err := fh.Read()
+ return &fileChange{exists: err == nil, content: data, fileHandle: &closedFile{fh}}, nil
+ }
+ if s.overlays == nil {
+ s.overlays = map[span.URI]fakeOverlay{}
+ }
+ s.overlays[uri] = fakeOverlay{uri: uri, content: content, saved: saved}
+ return &fileChange{
+ exists: content != "",
+ content: []byte(content),
+ fileHandle: s.overlays[uri],
+ }, nil
+}
+
+func (s *osFileSource) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error) {
+ if overlay, ok := s.overlays[uri]; ok {
+ return overlay, nil
+ }
+ fi, statErr := os.Stat(uri.Filename())
+ if statErr != nil {
+ return &fileHandle{
+ err: statErr,
+ uri: uri,
+ }, nil
+ }
+ fh, err := readFile(ctx, uri, fi)
+ if err != nil {
+ return nil, err
+ }
+ 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: "removing module",
+ initial: `
+-- a/go.mod --
+module moda.com
+-- a/go.sum --
+golang.org/x/mod v0.3.0 h1:deadbeef
+-- b/go.mod --
+module modb.com
+-- b/go.sum --
+golang.org/x/mod v0.3.0 h1:beefdead`,
+ initialState: wsState{
+ modules: []string{"a/go.mod", "b/go.mod"},
+ source: fileSystemWorkspace,
+ dirs: []string{".", "a", "b"},
+ sum: "golang.org/x/mod v0.3.0 h1:beefdead\ngolang.org/x/mod v0.3.0 h1:deadbeef\n",
+ },
+ updates: map[string]wsChange{
+ "gopls.mod": {`module gopls-workspace
+
+require moda.com v0.0.0-goplsworkspace
+replace moda.com => $SANDBOX_WORKDIR/a`, true},
+ },
+ wantChanged: true,
+ wantReload: true,
+ finalState: wsState{
+ modules: []string{"a/go.mod"},
+ source: goplsModWorkspace,
+ dirs: []string{".", "a"},
+ sum: "golang.org/x/mod v0.3.0 h1:deadbeef\n",
+ },
+ },
+ {
+ 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: "deleting gopls.mod",
+ initial: `
+-- gopls.mod --
+module gopls-workspace
+
+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": {"", true},
+ },
+ wantChanged: true,
+ wantReload: true,
+ finalState: wsState{
+ modules: []string{"a/go.mod", "b/go.mod"},
+ source: fileSystemWorkspace,
+ 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(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, gotChanged, gotReload := w.invalidate(ctx, changes)
+ if gotChanged != test.wantChanged {
+ t.Errorf("w.invalidate(): got changed %t, want %t", gotChanged, test.wantChanged)
+ }
+ if gotReload != test.wantReload {
+ t.Errorf("w.invalidate(): got reload %t, want %t", gotReload, test.wantReload)
+ }
+ checkState(ctx, t, fs, rel, got, test.finalState)
+ }
+ })
+ }
+}
+
+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.getActiveModFiles() {
+ 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)
+ }
+}