internal/lsp/fake: factor out working in a temp directory

In subsequent CLs, tests are added that don't need the full machinery of
fake.Workdir (file tracking, etc.). Factor out some of the facility for
writing a txtar-encoded temp directory, and working with relative paths.

Change-Id: Iae25c37a8fd8de2dfe6c91e507d9be01440d3492
Reviewed-on: https://go-review.googlesource.com/c/tools/+/263523
Run-TryBot: Robert Findley <rfindley@google.com>
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Robert Findley <rfindley@google.com>
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/internal/lsp/fake/editor.go b/internal/lsp/fake/editor.go
index 5567f46..dba8997 100644
--- a/internal/lsp/fake/editor.go
+++ b/internal/lsp/fake/editor.go
@@ -221,7 +221,7 @@
 	if !withoutWorkspaceFolders {
 		rootURI := e.sandbox.Workdir.RootURI()
 		if editorRootPath != "" {
-			rootURI = toURI(e.sandbox.Workdir.filePath(editorRootPath))
+			rootURI = toURI(e.sandbox.Workdir.AbsPath(editorRootPath))
 		}
 		params.WorkspaceFolders = []protocol.WorkspaceFolder{{
 			URI:  string(rootURI),
@@ -775,7 +775,7 @@
 	if e.Server == nil {
 		return nil
 	}
-	absDir := e.sandbox.Workdir.filePath(dir)
+	absDir := e.sandbox.Workdir.AbsPath(dir)
 	jsonArgs, err := source.MarshalArgs(span.URIFromPath(absDir), false)
 	if err != nil {
 		return err
diff --git a/internal/lsp/fake/sandbox.go b/internal/lsp/fake/sandbox.go
index 549cb2c..f240f9b 100644
--- a/internal/lsp/fake/sandbox.go
+++ b/internal/lsp/fake/sandbox.go
@@ -132,6 +132,20 @@
 	return sb, nil
 }
 
+// Tempdir creates a new temp directory with the given txtar-encoded files. It
+// is the responsibility of the caller to call os.RemoveAll on the returned
+// file path when it is no longer needed.
+func Tempdir(txt string) (string, error) {
+	dir, err := ioutil.TempDir("", "gopls-tempdir-")
+	if err != nil {
+		return "", err
+	}
+	if err := writeTxtar(txt, RelativeTo(dir)); err != nil {
+		return "", err
+	}
+	return dir, nil
+}
+
 func unpackTxt(txt string) map[string][]byte {
 	dataMap := make(map[string][]byte)
 	archive := txtar.Parse([]byte(txt))
@@ -197,9 +211,9 @@
 	// Close to clean up any partial state from the constructor, which calls
 	// RunGoCommand).
 	if dir != "" {
-		inv.WorkingDir = sb.Workdir.filePath(dir)
+		inv.WorkingDir = sb.Workdir.AbsPath(dir)
 	} else if sb.Workdir != nil {
-		inv.WorkingDir = sb.Workdir.workdir
+		inv.WorkingDir = string(sb.Workdir.RelativeTo)
 	}
 	gocmdRunner := &gocommand.Runner{}
 	_, _, _, err := gocmdRunner.RunRaw(ctx, inv)
diff --git a/internal/lsp/fake/workdir.go b/internal/lsp/fake/workdir.go
index 6b7f734..f036d42 100644
--- a/internal/lsp/fake/workdir.go
+++ b/internal/lsp/fake/workdir.go
@@ -26,11 +26,57 @@
 	ProtocolEvent protocol.FileEvent
 }
 
+// RelativeTo is a helper for operations relative to a given directory.
+type RelativeTo string
+
+// AbsPath returns an absolute filesystem path for the workdir-relative path.
+func (r RelativeTo) AbsPath(path string) string {
+	fp := filepath.FromSlash(path)
+	if filepath.IsAbs(fp) {
+		return fp
+	}
+	return filepath.Join(string(r), filepath.FromSlash(path))
+}
+
+// RelPath returns a '/'-encoded path relative to the working directory (or an
+// absolute path if the file is outside of workdir)
+func (r RelativeTo) RelPath(fp string) string {
+	root := string(r)
+	if rel, err := filepath.Rel(root, fp); err == nil && !strings.HasPrefix(rel, "..") {
+		return filepath.ToSlash(rel)
+	}
+	return filepath.ToSlash(fp)
+}
+
+func writeTxtar(txt string, rel RelativeTo) error {
+	files := unpackTxt(txt)
+	for name, data := range files {
+		if err := WriteFileData(name, data, rel); err != nil {
+			return errors.Errorf("writing to workdir: %w", err)
+		}
+	}
+	return nil
+}
+
+// WriteFileData writes content to the relative path, replacing the special
+// token $SANDBOX_WORKDIR with the relative root given by rel.
+func WriteFileData(path string, content []byte, rel RelativeTo) error {
+	content = bytes.ReplaceAll(content, []byte("$SANDBOX_WORKDIR"), []byte(rel))
+	fp := rel.AbsPath(path)
+	if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
+		return errors.Errorf("creating nested directory: %w", err)
+	}
+	if err := ioutil.WriteFile(fp, []byte(content), 0644); err != nil {
+		return errors.Errorf("writing %q: %w", path, err)
+	}
+	return nil
+}
+
 // Workdir is a temporary working directory for tests. It exposes file
 // operations in terms of relative paths, and fakes file watching by triggering
 // events on file operations.
 type Workdir struct {
-	workdir string
+	RelativeTo
 
 	watcherMu sync.Mutex
 	watchers  []func(context.Context, []FileEvent)
@@ -42,17 +88,11 @@
 // NewWorkdir writes the txtar-encoded file data in txt to dir, and returns a
 // Workir for operating on these files using
 func NewWorkdir(dir string) *Workdir {
-	return &Workdir{workdir: dir}
+	return &Workdir{RelativeTo: RelativeTo(dir)}
 }
 
 func (w *Workdir) writeInitialFiles(txt string) error {
-	files := unpackTxt(txt)
-	for name, data := range files {
-		data = bytes.ReplaceAll(data, []byte("$SANDBOX_WORKDIR"), []byte(w.workdir))
-		if err := w.writeFileData(name, data); err != nil {
-			return errors.Errorf("writing to workdir: %w", err)
-		}
-	}
+	writeTxtar(txt, w.RelativeTo)
 	// Poll to capture the current file state.
 	if _, err := w.pollFiles(); err != nil {
 		return errors.Errorf("polling files: %w", err)
@@ -63,7 +103,7 @@
 // RootURI returns the root URI for this working directory of this scratch
 // environment.
 func (w *Workdir) RootURI() protocol.DocumentURI {
-	return toURI(w.workdir)
+	return toURI(string(w.RelativeTo))
 }
 
 // AddWatcher registers the given func to be called on any file change.
@@ -73,35 +113,16 @@
 	w.watcherMu.Unlock()
 }
 
-// filePath returns an absolute filesystem path for the workdir-relative path.
-func (w *Workdir) filePath(path string) string {
-	fp := filepath.FromSlash(path)
-	if filepath.IsAbs(fp) {
-		return fp
-	}
-	return filepath.Join(w.workdir, filepath.FromSlash(path))
-}
-
 // URI returns the URI to a the workdir-relative path.
 func (w *Workdir) URI(path string) protocol.DocumentURI {
-	return toURI(w.filePath(path))
+	return toURI(w.AbsPath(path))
 }
 
 // URIToPath converts a uri to a workdir-relative path (or an absolute path,
 // if the uri is outside of the workdir).
 func (w *Workdir) URIToPath(uri protocol.DocumentURI) string {
 	fp := uri.SpanURI().Filename()
-	return w.relPath(fp)
-}
-
-// relPath returns a '/'-encoded path relative to the working directory (or an
-// absolute path if the file is outside of workdir)
-func (w *Workdir) relPath(fp string) string {
-	root := w.RootURI().SpanURI().Filename()
-	if rel, err := filepath.Rel(root, fp); err == nil && !strings.HasPrefix(rel, "..") {
-		return filepath.ToSlash(rel)
-	}
-	return filepath.ToSlash(fp)
+	return w.RelPath(fp)
 }
 
 func toURI(fp string) protocol.DocumentURI {
@@ -110,7 +131,7 @@
 
 // ReadFile reads a text file specified by a workdir-relative path.
 func (w *Workdir) ReadFile(path string) (string, error) {
-	b, err := ioutil.ReadFile(w.filePath(path))
+	b, err := ioutil.ReadFile(w.AbsPath(path))
 	if err != nil {
 		return "", err
 	}
@@ -142,7 +163,7 @@
 	for _, e := range events {
 		switch e.ProtocolEvent.Type {
 		case protocol.Deleted:
-			fp := w.filePath(e.Path)
+			fp := w.AbsPath(e.Path)
 			if err := os.Remove(fp); err != nil {
 				return errors.Errorf("removing %q: %w", e.Path, err)
 			}
@@ -158,7 +179,7 @@
 
 // RemoveFile removes a workdir-relative file path.
 func (w *Workdir) RemoveFile(ctx context.Context, path string) error {
-	fp := w.filePath(path)
+	fp := w.AbsPath(path)
 	if err := os.Remove(fp); err != nil {
 		return errors.Errorf("removing %q: %w", path, err)
 	}
@@ -212,7 +233,7 @@
 }
 
 func (w *Workdir) writeFile(ctx context.Context, path, content string) (FileEvent, error) {
-	fp := w.filePath(path)
+	fp := w.AbsPath(path)
 	_, err := os.Stat(fp)
 	if err != nil && !os.IsNotExist(err) {
 		return FileEvent{}, errors.Errorf("checking if %q exists: %w", path, err)
@@ -223,7 +244,7 @@
 	} else {
 		changeType = protocol.Changed
 	}
-	if err := w.writeFileData(path, []byte(content)); err != nil {
+	if err := WriteFileData(path, []byte(content), w.RelativeTo); err != nil {
 		return FileEvent{}, err
 	}
 	return FileEvent{
@@ -235,22 +256,11 @@
 	}, nil
 }
 
-func (w *Workdir) writeFileData(path string, content []byte) error {
-	fp := w.filePath(path)
-	if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
-		return errors.Errorf("creating nested directory: %w", err)
-	}
-	if err := ioutil.WriteFile(fp, []byte(content), 0644); err != nil {
-		return errors.Errorf("writing %q: %w", path, err)
-	}
-	return nil
-}
-
 // ListFiles lists files in the given directory, returning a map of relative
 // path to modification time.
 func (w *Workdir) ListFiles(dir string) (map[string]time.Time, error) {
 	files := make(map[string]time.Time)
-	absDir := w.filePath(dir)
+	absDir := w.AbsPath(dir)
 	if err := filepath.Walk(absDir, func(fp string, info os.FileInfo, err error) error {
 		if err != nil {
 			return err
@@ -258,7 +268,7 @@
 		if info.IsDir() {
 			return nil
 		}
-		path := w.relPath(fp)
+		path := w.RelPath(fp)
 		files[path] = info.ModTime()
 		return nil
 	}); err != nil {
diff --git a/internal/lsp/fake/workdir_test.go b/internal/lsp/fake/workdir_test.go
index 2438f35..e51f8c0 100644
--- a/internal/lsp/fake/workdir_test.go
+++ b/internal/lsp/fake/workdir_test.go
@@ -153,15 +153,15 @@
 	}
 	// Sleep some positive amount of time to ensure a distinct mtime.
 	time.Sleep(100 * time.Millisecond)
-	if err := wd.writeFileData("go.mod", []byte("module foo.test\n")); err != nil {
+	if err := WriteFileData("go.mod", []byte("module foo.test\n"), wd.RelativeTo); err != nil {
 		t.Fatal(err)
 	}
 	checkChange("go.mod", protocol.Changed)
-	if err := wd.writeFileData("newFile", []byte("something")); err != nil {
+	if err := WriteFileData("newFile", []byte("something"), wd.RelativeTo); err != nil {
 		t.Fatal(err)
 	}
 	checkChange("newFile", protocol.Created)
-	fp := wd.filePath("newFile")
+	fp := wd.AbsPath("newFile")
 	if err := os.Remove(fp); err != nil {
 		t.Fatal(err)
 	}