| // 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" |
| "errors" |
| "os" |
| "strings" |
| "testing" |
| |
| "golang.org/x/mod/modfile" |
| "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(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.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) |
| } |
| } |