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 fake
6
7import (
8	"bytes"
9	"context"
10	"io/ioutil"
11	"os"
12	"path/filepath"
13	"strings"
14	"sync"
15	"time"
16
17	"golang.org/x/tools/internal/lsp/protocol"
18	"golang.org/x/tools/internal/span"
19	errors "golang.org/x/xerrors"
20)
21
22// FileEvent wraps the protocol.FileEvent so that it can be associated with a
23// workdir-relative path.
24type FileEvent struct {
25	Path, Content string
26	ProtocolEvent protocol.FileEvent
27}
28
29// RelativeTo is a helper for operations relative to a given directory.
30type RelativeTo string
31
32// AbsPath returns an absolute filesystem path for the workdir-relative path.
33func (r RelativeTo) AbsPath(path string) string {
34	fp := filepath.FromSlash(path)
35	if filepath.IsAbs(fp) {
36		return fp
37	}
38	return filepath.Join(string(r), filepath.FromSlash(path))
39}
40
41// RelPath returns a '/'-encoded path relative to the working directory (or an
42// absolute path if the file is outside of workdir)
43func (r RelativeTo) RelPath(fp string) string {
44	root := string(r)
45	if rel, err := filepath.Rel(root, fp); err == nil && !strings.HasPrefix(rel, "..") {
46		return filepath.ToSlash(rel)
47	}
48	return filepath.ToSlash(fp)
49}
50
51func writeTxtar(txt string, rel RelativeTo) error {
52	files := unpackTxt(txt)
53	for name, data := range files {
54		if err := WriteFileData(name, data, rel); err != nil {
55			return errors.Errorf("writing to workdir: %w", err)
56		}
57	}
58	return nil
59}
60
61// WriteFileData writes content to the relative path, replacing the special
62// token $SANDBOX_WORKDIR with the relative root given by rel.
63func WriteFileData(path string, content []byte, rel RelativeTo) error {
64	content = bytes.ReplaceAll(content, []byte("$SANDBOX_WORKDIR"), []byte(rel))
65	fp := rel.AbsPath(path)
66	if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
67		return errors.Errorf("creating nested directory: %w", err)
68	}
69	if err := ioutil.WriteFile(fp, []byte(content), 0644); err != nil {
70		return errors.Errorf("writing %q: %w", path, err)
71	}
72	return nil
73}
74
75// Workdir is a temporary working directory for tests. It exposes file
76// operations in terms of relative paths, and fakes file watching by triggering
77// events on file operations.
78type Workdir struct {
79	RelativeTo
80
81	watcherMu sync.Mutex
82	watchers  []func(context.Context, []FileEvent)
83
84	fileMu sync.Mutex
85	files  map[string]time.Time
86}
87
88// NewWorkdir writes the txtar-encoded file data in txt to dir, and returns a
89// Workir for operating on these files using
90func NewWorkdir(dir string) *Workdir {
91	return &Workdir{RelativeTo: RelativeTo(dir)}
92}
93
94func (w *Workdir) writeInitialFiles(txt string) error {
95	writeTxtar(txt, w.RelativeTo)
96	// Poll to capture the current file state.
97	if _, err := w.pollFiles(); err != nil {
98		return errors.Errorf("polling files: %w", err)
99	}
100	return nil
101}
102
103// RootURI returns the root URI for this working directory of this scratch
104// environment.
105func (w *Workdir) RootURI() protocol.DocumentURI {
106	return toURI(string(w.RelativeTo))
107}
108
109// AddWatcher registers the given func to be called on any file change.
110func (w *Workdir) AddWatcher(watcher func(context.Context, []FileEvent)) {
111	w.watcherMu.Lock()
112	w.watchers = append(w.watchers, watcher)
113	w.watcherMu.Unlock()
114}
115
116// URI returns the URI to a the workdir-relative path.
117func (w *Workdir) URI(path string) protocol.DocumentURI {
118	return toURI(w.AbsPath(path))
119}
120
121// URIToPath converts a uri to a workdir-relative path (or an absolute path,
122// if the uri is outside of the workdir).
123func (w *Workdir) URIToPath(uri protocol.DocumentURI) string {
124	fp := uri.SpanURI().Filename()
125	return w.RelPath(fp)
126}
127
128func toURI(fp string) protocol.DocumentURI {
129	return protocol.DocumentURI(span.URIFromPath(fp))
130}
131
132// ReadFile reads a text file specified by a workdir-relative path.
133func (w *Workdir) ReadFile(path string) (string, error) {
134	b, err := ioutil.ReadFile(w.AbsPath(path))
135	if err != nil {
136		return "", err
137	}
138	return string(b), nil
139}
140
141func (w *Workdir) RegexpRange(path, re string) (Pos, Pos, error) {
142	content, err := w.ReadFile(path)
143	if err != nil {
144		return Pos{}, Pos{}, err
145	}
146	return regexpRange(content, re)
147}
148
149// RegexpSearch searches the file corresponding to path for the first position
150// matching re.
151func (w *Workdir) RegexpSearch(path string, re string) (Pos, error) {
152	content, err := w.ReadFile(path)
153	if err != nil {
154		return Pos{}, err
155	}
156	start, _, err := regexpRange(content, re)
157	return start, err
158}
159
160// ChangeFilesOnDisk executes the given on-disk file changes in a batch,
161// simulating the action of changing branches outside of an editor.
162func (w *Workdir) ChangeFilesOnDisk(ctx context.Context, events []FileEvent) error {
163	for _, e := range events {
164		switch e.ProtocolEvent.Type {
165		case protocol.Deleted:
166			fp := w.AbsPath(e.Path)
167			if err := os.Remove(fp); err != nil {
168				return errors.Errorf("removing %q: %w", e.Path, err)
169			}
170		case protocol.Changed, protocol.Created:
171			if _, err := w.writeFile(ctx, e.Path, e.Content); err != nil {
172				return err
173			}
174		}
175	}
176	w.sendEvents(ctx, events)
177	return nil
178}
179
180// RemoveFile removes a workdir-relative file path.
181func (w *Workdir) RemoveFile(ctx context.Context, path string) error {
182	fp := w.AbsPath(path)
183	if err := os.RemoveAll(fp); err != nil {
184		return errors.Errorf("removing %q: %w", path, err)
185	}
186	evts := []FileEvent{{
187		Path: path,
188		ProtocolEvent: protocol.FileEvent{
189			URI:  w.URI(path),
190			Type: protocol.Deleted,
191		},
192	}}
193	w.sendEvents(ctx, evts)
194	return nil
195}
196
197func (w *Workdir) sendEvents(ctx context.Context, evts []FileEvent) {
198	if len(evts) == 0 {
199		return
200	}
201	w.watcherMu.Lock()
202	watchers := make([]func(context.Context, []FileEvent), len(w.watchers))
203	copy(watchers, w.watchers)
204	w.watcherMu.Unlock()
205	for _, w := range watchers {
206		go w(ctx, evts)
207	}
208}
209
210// WriteFiles writes the text file content to workdir-relative paths.
211// It batches notifications rather than sending them consecutively.
212func (w *Workdir) WriteFiles(ctx context.Context, files map[string]string) error {
213	var evts []FileEvent
214	for filename, content := range files {
215		evt, err := w.writeFile(ctx, filename, content)
216		if err != nil {
217			return err
218		}
219		evts = append(evts, evt)
220	}
221	w.sendEvents(ctx, evts)
222	return nil
223}
224
225// WriteFile writes text file content to a workdir-relative path.
226func (w *Workdir) WriteFile(ctx context.Context, path, content string) error {
227	evt, err := w.writeFile(ctx, path, content)
228	if err != nil {
229		return err
230	}
231	w.sendEvents(ctx, []FileEvent{evt})
232	return nil
233}
234
235func (w *Workdir) writeFile(ctx context.Context, path, content string) (FileEvent, error) {
236	fp := w.AbsPath(path)
237	_, err := os.Stat(fp)
238	if err != nil && !os.IsNotExist(err) {
239		return FileEvent{}, errors.Errorf("checking if %q exists: %w", path, err)
240	}
241	var changeType protocol.FileChangeType
242	if os.IsNotExist(err) {
243		changeType = protocol.Created
244	} else {
245		changeType = protocol.Changed
246	}
247	if err := WriteFileData(path, []byte(content), w.RelativeTo); err != nil {
248		return FileEvent{}, err
249	}
250	return FileEvent{
251		Path: path,
252		ProtocolEvent: protocol.FileEvent{
253			URI:  w.URI(path),
254			Type: changeType,
255		},
256	}, nil
257}
258
259// ListFiles lists files in the given directory, returning a map of relative
260// path to modification time.
261func (w *Workdir) ListFiles(dir string) (map[string]time.Time, error) {
262	files := make(map[string]time.Time)
263	absDir := w.AbsPath(dir)
264	if err := filepath.Walk(absDir, func(fp string, info os.FileInfo, err error) error {
265		if err != nil {
266			return err
267		}
268		if info.IsDir() {
269			return nil
270		}
271		path := w.RelPath(fp)
272		files[path] = info.ModTime()
273		return nil
274	}); err != nil {
275		return nil, err
276	}
277	return files, nil
278}
279
280// CheckForFileChanges walks the working directory and checks for any files
281// that have changed since the last poll.
282func (w *Workdir) CheckForFileChanges(ctx context.Context) error {
283	evts, err := w.pollFiles()
284	if err != nil {
285		return err
286	}
287	w.sendEvents(ctx, evts)
288	return nil
289}
290
291// pollFiles updates w.files and calculates FileEvents corresponding to file
292// state changes since the last poll. It does not call sendEvents.
293func (w *Workdir) pollFiles() ([]FileEvent, error) {
294	w.fileMu.Lock()
295	defer w.fileMu.Unlock()
296
297	files, err := w.ListFiles(".")
298	if err != nil {
299		return nil, err
300	}
301	var evts []FileEvent
302	// Check which files have been added or modified.
303	for path, mtime := range files {
304		oldmtime, ok := w.files[path]
305		delete(w.files, path)
306		var typ protocol.FileChangeType
307		switch {
308		case !ok:
309			typ = protocol.Created
310		case oldmtime != mtime:
311			typ = protocol.Changed
312		default:
313			continue
314		}
315		evts = append(evts, FileEvent{
316			Path: path,
317			ProtocolEvent: protocol.FileEvent{
318				URI:  w.URI(path),
319				Type: typ,
320			},
321		})
322	}
323	// Any remaining files must have been deleted.
324	for path := range w.files {
325		evts = append(evts, FileEvent{
326			Path: path,
327			ProtocolEvent: protocol.FileEvent{
328				URI:  w.URI(path),
329				Type: protocol.Deleted,
330			},
331		})
332	}
333	w.files = files
334	return evts, nil
335}
336