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
5package cache
6
7import (
8	"context"
9	"os"
10	"strings"
11	"testing"
12
13	"golang.org/x/tools/internal/lsp/fake"
14	"golang.org/x/tools/internal/lsp/source"
15	"golang.org/x/tools/internal/span"
16)
17
18// osFileSource is a fileSource that just reads from the operating system.
19type osFileSource struct {
20	overlays map[span.URI]fakeOverlay
21}
22
23type fakeOverlay struct {
24	source.VersionedFileHandle
25	uri     span.URI
26	content string
27	err     error
28	saved   bool
29}
30
31func (o fakeOverlay) Saved() bool { return o.saved }
32
33func (o fakeOverlay) Read() ([]byte, error) {
34	if o.err != nil {
35		return nil, o.err
36	}
37	return []byte(o.content), nil
38}
39
40func (o fakeOverlay) URI() span.URI {
41	return o.uri
42}
43
44// change updates the file source with the given file content. For convenience,
45// empty content signals a deletion. If saved is true, these changes are
46// persisted to disk.
47func (s *osFileSource) change(ctx context.Context, uri span.URI, content string, saved bool) (*fileChange, error) {
48	if content == "" {
49		delete(s.overlays, uri)
50		if saved {
51			if err := os.Remove(uri.Filename()); err != nil {
52				return nil, err
53			}
54		}
55		fh, err := s.GetFile(ctx, uri)
56		if err != nil {
57			return nil, err
58		}
59		data, err := fh.Read()
60		return &fileChange{exists: err == nil, content: data, fileHandle: &closedFile{fh}}, nil
61	}
62	if s.overlays == nil {
63		s.overlays = map[span.URI]fakeOverlay{}
64	}
65	s.overlays[uri] = fakeOverlay{uri: uri, content: content, saved: saved}
66	return &fileChange{
67		exists:     content != "",
68		content:    []byte(content),
69		fileHandle: s.overlays[uri],
70	}, nil
71}
72
73func (s *osFileSource) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error) {
74	if overlay, ok := s.overlays[uri]; ok {
75		return overlay, nil
76	}
77	fi, statErr := os.Stat(uri.Filename())
78	if statErr != nil {
79		return &fileHandle{
80			err: statErr,
81			uri: uri,
82		}, nil
83	}
84	fh, err := readFile(ctx, uri, fi)
85	if err != nil {
86		return nil, err
87	}
88	return fh, nil
89}
90
91type wsState struct {
92	source  workspaceSource
93	modules []string
94	dirs    []string
95	sum     string
96}
97
98type wsChange struct {
99	content string
100	saved   bool
101}
102
103func TestWorkspaceModule(t *testing.T) {
104	tests := []struct {
105		desc         string
106		initial      string // txtar-encoded
107		legacyMode   bool
108		initialState wsState
109		updates      map[string]wsChange
110		wantChanged  bool
111		wantReload   bool
112		finalState   wsState
113	}{
114		{
115			desc: "legacy mode",
116			initial: `
117-- go.mod --
118module mod.com
119-- go.sum --
120golang.org/x/mod v0.3.0 h1:deadbeef
121-- a/go.mod --
122module moda.com`,
123			legacyMode: true,
124			initialState: wsState{
125				modules: []string{"./go.mod"},
126				source:  legacyWorkspace,
127				dirs:    []string{"."},
128				sum:     "golang.org/x/mod v0.3.0 h1:deadbeef\n",
129			},
130		},
131		{
132			desc: "nested module",
133			initial: `
134-- go.mod --
135module mod.com
136-- a/go.mod --
137module moda.com`,
138			initialState: wsState{
139				modules: []string{"./go.mod", "a/go.mod"},
140				source:  fileSystemWorkspace,
141				dirs:    []string{".", "a"},
142			},
143		},
144		{
145			desc: "removing module",
146			initial: `
147-- a/go.mod --
148module moda.com
149-- a/go.sum --
150golang.org/x/mod v0.3.0 h1:deadbeef
151-- b/go.mod --
152module modb.com
153-- b/go.sum --
154golang.org/x/mod v0.3.0 h1:beefdead`,
155			initialState: wsState{
156				modules: []string{"a/go.mod", "b/go.mod"},
157				source:  fileSystemWorkspace,
158				dirs:    []string{".", "a", "b"},
159				sum:     "golang.org/x/mod v0.3.0 h1:beefdead\ngolang.org/x/mod v0.3.0 h1:deadbeef\n",
160			},
161			updates: map[string]wsChange{
162				"gopls.mod": {`module gopls-workspace
163
164require moda.com v0.0.0-goplsworkspace
165replace moda.com => $SANDBOX_WORKDIR/a`, true},
166			},
167			wantChanged: true,
168			wantReload:  true,
169			finalState: wsState{
170				modules: []string{"a/go.mod"},
171				source:  goplsModWorkspace,
172				dirs:    []string{".", "a"},
173				sum:     "golang.org/x/mod v0.3.0 h1:deadbeef\n",
174			},
175		},
176		{
177			desc: "adding module",
178			initial: `
179-- gopls.mod --
180require moda.com v0.0.0-goplsworkspace
181replace moda.com => $SANDBOX_WORKDIR/a
182-- a/go.mod --
183module moda.com
184-- b/go.mod --
185module modb.com`,
186			initialState: wsState{
187				modules: []string{"a/go.mod"},
188				source:  goplsModWorkspace,
189				dirs:    []string{".", "a"},
190			},
191			updates: map[string]wsChange{
192				"gopls.mod": {`module gopls-workspace
193
194require moda.com v0.0.0-goplsworkspace
195require modb.com v0.0.0-goplsworkspace
196
197replace moda.com => $SANDBOX_WORKDIR/a
198replace modb.com => $SANDBOX_WORKDIR/b`, true},
199			},
200			wantChanged: true,
201			wantReload:  true,
202			finalState: wsState{
203				modules: []string{"a/go.mod", "b/go.mod"},
204				source:  goplsModWorkspace,
205				dirs:    []string{".", "a", "b"},
206			},
207		},
208		{
209			desc: "deleting gopls.mod",
210			initial: `
211-- gopls.mod --
212module gopls-workspace
213
214require moda.com v0.0.0-goplsworkspace
215replace moda.com => $SANDBOX_WORKDIR/a
216-- a/go.mod --
217module moda.com
218-- b/go.mod --
219module modb.com`,
220			initialState: wsState{
221				modules: []string{"a/go.mod"},
222				source:  goplsModWorkspace,
223				dirs:    []string{".", "a"},
224			},
225			updates: map[string]wsChange{
226				"gopls.mod": {"", true},
227			},
228			wantChanged: true,
229			wantReload:  true,
230			finalState: wsState{
231				modules: []string{"a/go.mod", "b/go.mod"},
232				source:  fileSystemWorkspace,
233				dirs:    []string{".", "a", "b"},
234			},
235		},
236		{
237			desc: "broken module parsing",
238			initial: `
239-- a/go.mod --
240module moda.com
241
242require gopls.test v0.0.0-goplsworkspace
243replace gopls.test => ../../gopls.test // (this path shouldn't matter)
244-- b/go.mod --
245module modb.com`,
246			initialState: wsState{
247				modules: []string{"a/go.mod", "b/go.mod"},
248				source:  fileSystemWorkspace,
249				dirs:    []string{".", "a", "b", "../gopls.test"},
250			},
251			updates: map[string]wsChange{
252				"a/go.mod": {`modul moda.com
253
254require gopls.test v0.0.0-goplsworkspace
255replace gopls.test => ../../gopls.test2`, false},
256			},
257			wantChanged: true,
258			wantReload:  false,
259			finalState: wsState{
260				modules: []string{"a/go.mod", "b/go.mod"},
261				source:  fileSystemWorkspace,
262				// finalDirs should be unchanged: we should preserve dirs in the presence
263				// of a broken modfile.
264				dirs: []string{".", "a", "b", "../gopls.test"},
265			},
266		},
267	}
268
269	for _, test := range tests {
270		t.Run(test.desc, func(t *testing.T) {
271			ctx := context.Background()
272			dir, err := fake.Tempdir(fake.UnpackTxt(test.initial))
273			if err != nil {
274				t.Fatal(err)
275			}
276			defer os.RemoveAll(dir)
277			root := span.URIFromPath(dir)
278
279			fs := &osFileSource{}
280			excludeNothing := func(string) bool { return false }
281			w, err := newWorkspace(ctx, root, fs, excludeNothing, false, !test.legacyMode)
282			if err != nil {
283				t.Fatal(err)
284			}
285			rel := fake.RelativeTo(dir)
286			checkState(ctx, t, fs, rel, w, test.initialState)
287
288			// Apply updates.
289			if test.updates != nil {
290				changes := make(map[span.URI]*fileChange)
291				for k, v := range test.updates {
292					content := strings.ReplaceAll(v.content, "$SANDBOX_WORKDIR", string(rel))
293					uri := span.URIFromPath(rel.AbsPath(k))
294					changes[uri], err = fs.change(ctx, uri, content, v.saved)
295					if err != nil {
296						t.Fatal(err)
297					}
298				}
299				got, gotChanged, gotReload := w.invalidate(ctx, changes, fs)
300				if gotChanged != test.wantChanged {
301					t.Errorf("w.invalidate(): got changed %t, want %t", gotChanged, test.wantChanged)
302				}
303				if gotReload != test.wantReload {
304					t.Errorf("w.invalidate(): got reload %t, want %t", gotReload, test.wantReload)
305				}
306				checkState(ctx, t, fs, rel, got, test.finalState)
307			}
308		})
309	}
310}
311
312func checkState(ctx context.Context, t *testing.T, fs source.FileSource, rel fake.RelativeTo, got *workspace, want wsState) {
313	t.Helper()
314	if got.moduleSource != want.source {
315		t.Errorf("module source = %v, want %v", got.moduleSource, want.source)
316	}
317	modules := make(map[span.URI]struct{})
318	for k := range got.getActiveModFiles() {
319		modules[k] = struct{}{}
320	}
321	for _, modPath := range want.modules {
322		path := rel.AbsPath(modPath)
323		uri := span.URIFromPath(path)
324		if _, ok := modules[uri]; !ok {
325			t.Errorf("missing module %q", uri)
326		}
327		delete(modules, uri)
328	}
329	for remaining := range modules {
330		t.Errorf("unexpected module %q", remaining)
331	}
332	gotDirs := got.dirs(ctx, fs)
333	gotM := make(map[span.URI]bool)
334	for _, dir := range gotDirs {
335		gotM[dir] = true
336	}
337	for _, dir := range want.dirs {
338		path := rel.AbsPath(dir)
339		uri := span.URIFromPath(path)
340		if !gotM[uri] {
341			t.Errorf("missing dir %q", uri)
342		}
343		delete(gotM, uri)
344	}
345	for remaining := range gotM {
346		t.Errorf("unexpected dir %q", remaining)
347	}
348	gotSumBytes, err := got.sumFile(ctx, fs)
349	if err != nil {
350		t.Fatal(err)
351	}
352	if gotSum := string(gotSumBytes); gotSum != want.sum {
353		t.Errorf("got final sum %q, want %q", gotSum, want.sum)
354	}
355}
356