internal/testfiles: add a helper package for test files

Adds a new package testfiles to help write tests around go files
and directories. These are especially helpful when dealing with
go.mod files.

Adds a new CopyTestFiles function that copies a directory and
removes the ".test" file extension, e.g. "go.mod.test" becomes
"go.mod". This allows for easily creating analysistests
with go.mod files. This change also adds a new TestDir helper
to extract to a temporary directory.

Consolidates txtar usage around ExtractTxtar, which writes a
txtar archive to a directory, and adds a convenience helper
TestTxtar that extracts a txtar at a path to a temporary
directory.

Updates golang/go#61336
Updates golang/go#53063
Updates golang/go#46041
Updates golang/go#37054

Change-Id: I09210f751bbc6ac3f01c34fba22b7e8fa1ddf93f
Reviewed-on: https://go-review.googlesource.com/c/tools/+/577995
Reviewed-by: Alan Donovan <adonovan@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/go/analysis/analysistest/analysistest_test.go b/go/analysis/analysistest/analysistest_test.go
index 8e4767a..b22e2a1 100644
--- a/go/analysis/analysistest/analysistest_test.go
+++ b/go/analysis/analysistest/analysistest_test.go
@@ -20,12 +20,8 @@
 )
 
 func init() {
-	// This test currently requires GOPATH mode.
-	// Explicitly disabling module mode should suffice, but
-	// we'll also turn off GOPROXY just for good measure.
-	if err := os.Setenv("GO111MODULE", "off"); err != nil {
-		log.Fatal(err)
-	}
+	// Run() decides when tests use GOPATH mode or modules.
+	// We turn off GOPROXY just for good measure.
 	if err := os.Setenv("GOPROXY", "off"); err != nil {
 		log.Fatal(err)
 	}
diff --git a/go/analysis/passes/loopclosure/loopclosure_test.go b/go/analysis/passes/loopclosure/loopclosure_test.go
index 683f91e..8c17949 100644
--- a/go/analysis/passes/loopclosure/loopclosure_test.go
+++ b/go/analysis/passes/loopclosure/loopclosure_test.go
@@ -5,15 +5,13 @@
 package loopclosure_test
 
 import (
-	"os"
 	"path/filepath"
 	"testing"
 
-	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/analysis/analysistest"
 	"golang.org/x/tools/go/analysis/passes/loopclosure"
 	"golang.org/x/tools/internal/testenv"
-	"golang.org/x/tools/txtar"
+	"golang.org/x/tools/internal/testfiles"
 )
 
 func Test(t *testing.T) {
@@ -28,37 +26,13 @@
 func TestVersions22(t *testing.T) {
 	testenv.NeedsGo1Point(t, 22)
 
-	testfile := filepath.Join(analysistest.TestData(), "src", "versions", "go22.txtar")
-	runTxtarFile(t, testfile, loopclosure.Analyzer, "golang.org/fake/versions")
+	txtar := filepath.Join(analysistest.TestData(), "src", "versions", "go22.txtar")
+	dir := testfiles.ExtractTxtarToTmp(t, txtar)
+	analysistest.Run(t, dir, loopclosure.Analyzer, "golang.org/fake/versions")
 }
 
 func TestVersions18(t *testing.T) {
-	testfile := filepath.Join(analysistest.TestData(), "src", "versions", "go18.txtar")
-	runTxtarFile(t, testfile, loopclosure.Analyzer, "golang.org/fake/versions")
-}
-
-// runTxtarFile unpacks a txtar archive to a directory, and runs
-// analyzer on the given patterns.
-//
-// This is compatible with a go.mod file.
-//
-// TODO(taking): Consider unifying with analysistest.
-func runTxtarFile(t *testing.T, path string, analyzer *analysis.Analyzer, patterns ...string) {
-	ar, err := txtar.ParseFile(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	dir := t.TempDir()
-	for _, file := range ar.Files {
-		name, content := file.Name, file.Data
-
-		filename := filepath.Join(dir, name)
-		os.MkdirAll(filepath.Dir(filename), 0777) // ignore error
-		if err := os.WriteFile(filename, content, 0666); err != nil {
-			t.Fatal(err)
-		}
-	}
-
-	analysistest.Run(t, dir, analyzer, patterns...)
+	txtar := filepath.Join(analysistest.TestData(), "src", "versions", "go18.txtar")
+	dir := testfiles.ExtractTxtarToTmp(t, txtar)
+	analysistest.Run(t, dir, loopclosure.Analyzer, "golang.org/fake/versions")
 }
diff --git a/go/analysis/passes/stdversion/stdversion_test.go b/go/analysis/passes/stdversion/stdversion_test.go
index efee7ba..9186527 100644
--- a/go/analysis/passes/stdversion/stdversion_test.go
+++ b/go/analysis/passes/stdversion/stdversion_test.go
@@ -5,15 +5,13 @@
 package stdversion_test
 
 import (
-	"os"
 	"path/filepath"
 	"testing"
 
-	"golang.org/x/tools/go/analysis"
 	"golang.org/x/tools/go/analysis/analysistest"
 	"golang.org/x/tools/go/analysis/passes/stdversion"
 	"golang.org/x/tools/internal/testenv"
-	"golang.org/x/tools/txtar"
+	"golang.org/x/tools/internal/testfiles"
 )
 
 func Test(t *testing.T) {
@@ -21,36 +19,9 @@
 	// itself requires the go1.22 implementation of versions.FileVersions.
 	testenv.NeedsGo1Point(t, 22)
 
-	testfile := filepath.Join(analysistest.TestData(), "test.txtar")
-	runTxtarFile(t, testfile, stdversion.Analyzer,
+	dir := testfiles.ExtractTxtarToTmp(t, filepath.Join(analysistest.TestData(), "test.txtar"))
+	analysistest.Run(t, dir, stdversion.Analyzer,
 		"example.com/a",
 		"example.com/sub",
 		"example.com/old")
 }
-
-// runTxtarFile unpacks a txtar archive to a directory, and runs
-// analyzer on the given patterns.
-//
-// This is compatible with a go.mod file.
-//
-// Plundered from loopclosure_test.go.
-// TODO(golang/go#46136): add module support to analysistest.
-func runTxtarFile(t *testing.T, path string, analyzer *analysis.Analyzer, patterns ...string) {
-	ar, err := txtar.ParseFile(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	dir := t.TempDir()
-	for _, file := range ar.Files {
-		name, content := file.Name, file.Data
-
-		filename := filepath.Join(dir, name)
-		os.MkdirAll(filepath.Dir(filename), 0777) // ignore error
-		if err := os.WriteFile(filename, content, 0666); err != nil {
-			t.Fatal(err)
-		}
-	}
-
-	analysistest.Run(t, dir, analyzer, patterns...)
-}
diff --git a/go/analysis/unitchecker/separate_test.go b/go/analysis/unitchecker/separate_test.go
index 37e74e4..00c5aec 100644
--- a/go/analysis/unitchecker/separate_test.go
+++ b/go/analysis/unitchecker/separate_test.go
@@ -24,6 +24,7 @@
 	"golang.org/x/tools/go/gcexportdata"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/tools/internal/testenv"
+	"golang.org/x/tools/internal/testfiles"
 	"golang.org/x/tools/txtar"
 )
 
@@ -82,7 +83,7 @@
 
 	// Expand archive into tmp tree.
 	tmpdir := t.TempDir()
-	if err := extractTxtar(txtar.Parse([]byte(src)), tmpdir); err != nil {
+	if err := testfiles.ExtractTxtar(tmpdir, txtar.Parse([]byte(src))); err != nil {
 		t.Fatal(err)
 	}
 
@@ -291,19 +292,3 @@
 type importerFunc func(path string) (*types.Package, error)
 
 func (f importerFunc) Import(path string) (*types.Package, error) { return f(path) }
-
-// extractTxtar writes each archive file to the corresponding location beneath dir.
-//
-// TODO(adonovan): move this to txtar package, we need it all the time (#61386).
-func extractTxtar(ar *txtar.Archive, dir string) error {
-	for _, file := range ar.Files {
-		name := filepath.Join(dir, file.Name)
-		if err := os.MkdirAll(filepath.Dir(name), 0777); err != nil {
-			return err
-		}
-		if err := os.WriteFile(name, file.Data, 0666); err != nil {
-			return err
-		}
-	}
-	return nil
-}
diff --git a/go/ssa/builder_test.go b/go/ssa/builder_test.go
index 607e64f..07b4a3c 100644
--- a/go/ssa/builder_test.go
+++ b/go/ssa/builder_test.go
@@ -20,6 +20,7 @@
 	"strings"
 	"testing"
 
+	"golang.org/x/tools/go/analysis/analysistest"
 	"golang.org/x/tools/go/buildutil"
 	"golang.org/x/tools/go/loader"
 	"golang.org/x/tools/go/packages"
@@ -27,7 +28,7 @@
 	"golang.org/x/tools/go/ssa/ssautil"
 	"golang.org/x/tools/internal/aliases"
 	"golang.org/x/tools/internal/testenv"
-	"golang.org/x/tools/txtar"
+	"golang.org/x/tools/internal/testfiles"
 )
 
 func isEmpty(f *ssa.Function) bool { return f.Blocks == nil }
@@ -172,38 +173,7 @@
 func TestNoIndirectCreatePackage(t *testing.T) {
 	testenv.NeedsGoBuild(t) // for go/packages
 
-	src := `
--- go.mod --
-module testdata
-go 1.18
-
--- a/a.go --
-package a
-
-import "testdata/b"
-
-func A() {
-	var x b.B
-	x.F()
-}
-
--- b/b.go --
-package b
-
-import "testdata/c"
-
-type B struct { c.C }
-
--- c/c.go --
-package c
-
-type C int
-func (C) F() {}
-`
-	dir := t.TempDir()
-	if err := extractArchive(dir, src); err != nil {
-		t.Fatal(err)
-	}
+	dir := testfiles.ExtractTxtarToTmp(t, filepath.Join(analysistest.TestData(), "indirect.txtar"))
 	pkgs, err := loadPackages(dir, "testdata/a")
 	if err != nil {
 		t.Fatal(err)
@@ -235,27 +205,6 @@
 	}
 }
 
-// extractArchive extracts the txtar archive into the specified directory.
-func extractArchive(dir, arch string) error {
-	// TODO(adonovan): publish this a helper (#61386).
-	extractTxtar := func(ar *txtar.Archive, dir string) error {
-		for _, file := range ar.Files {
-			name := filepath.Join(dir, file.Name)
-			if err := os.MkdirAll(filepath.Dir(name), 0777); err != nil {
-				return err
-			}
-			if err := os.WriteFile(name, file.Data, 0666); err != nil {
-				return err
-			}
-		}
-		return nil
-	}
-
-	// Extract archive to temporary tree.
-	ar := txtar.Parse([]byte(arch))
-	return extractTxtar(ar, dir)
-}
-
 // loadPackages loads packages from the specified directory, using LoadSyntax.
 func loadPackages(dir string, patterns ...string) ([]*packages.Package, error) {
 	cfg := &packages.Config{
diff --git a/go/ssa/testdata/indirect.txtar b/go/ssa/testdata/indirect.txtar
new file mode 100644
index 0000000..595bd2e
--- /dev/null
+++ b/go/ssa/testdata/indirect.txtar
@@ -0,0 +1,26 @@
+-- go.mod --
+module testdata
+go 1.18
+
+-- a/a.go --
+package a
+
+import "testdata/b"
+
+func A() {
+	var x b.B
+	x.F()
+}
+
+-- b/b.go --
+package b
+
+import "testdata/c"
+
+type B struct { c.C }
+
+-- c/c.go --
+package c
+
+type C int
+func (C) F() {}
\ No newline at end of file
diff --git a/internal/testfiles/testdata/somefile.txt b/internal/testfiles/testdata/somefile.txt
new file mode 100644
index 0000000..8d9c108
--- /dev/null
+++ b/internal/testfiles/testdata/somefile.txt
@@ -0,0 +1 @@
+A file to try to load.
\ No newline at end of file
diff --git a/internal/testfiles/testdata/versions/go.mod.test b/internal/testfiles/testdata/versions/go.mod.test
new file mode 100644
index 0000000..55f69e1
--- /dev/null
+++ b/internal/testfiles/testdata/versions/go.mod.test
@@ -0,0 +1,5 @@
+// File is versions/go.mod after expansion with TestDir()
+
+module golang.org/fake/versions
+
+go 1.21
diff --git a/internal/testfiles/testdata/versions/mod.go b/internal/testfiles/testdata/versions/mod.go
new file mode 100644
index 0000000..13117fd
--- /dev/null
+++ b/internal/testfiles/testdata/versions/mod.go
@@ -0,0 +1,3 @@
+// The file will be go1.21 from the go.mod.
+
+package versions // want "mod.go@go1.21"
diff --git a/internal/testfiles/testdata/versions/post.go b/internal/testfiles/testdata/versions/post.go
new file mode 100644
index 0000000..8c1afde
--- /dev/null
+++ b/internal/testfiles/testdata/versions/post.go
@@ -0,0 +1,3 @@
+//go:build go1.22
+
+package versions // want "post.go@go1.22"
diff --git a/internal/testfiles/testdata/versions/pre.go b/internal/testfiles/testdata/versions/pre.go
new file mode 100644
index 0000000..2b5ca67
--- /dev/null
+++ b/internal/testfiles/testdata/versions/pre.go
@@ -0,0 +1,3 @@
+//go:build go1.20
+
+package versions // want "pre.go@go1.20"
diff --git a/internal/testfiles/testdata/versions/sub.test/sub.go b/internal/testfiles/testdata/versions/sub.test/sub.go
new file mode 100644
index 0000000..3b2721b
--- /dev/null
+++ b/internal/testfiles/testdata/versions/sub.test/sub.go
@@ -0,0 +1 @@
+package sub // want "sub.go@go1.21"
diff --git a/internal/testfiles/testfiles.go b/internal/testfiles/testfiles.go
new file mode 100644
index 0000000..ff73955
--- /dev/null
+++ b/internal/testfiles/testfiles.go
@@ -0,0 +1,140 @@
+// Copyright 2024 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 testfiles provides utilities for writing Go tests with files
+// in testdata.
+package testfiles
+
+import (
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"golang.org/x/tools/txtar"
+)
+
+// CopyDirToTmp copies dir to a temporary test directory using
+// CopyTestFiles and returns the path to the test directory.
+func CopyDirToTmp(t testing.TB, srcdir string) string {
+	dst := t.TempDir()
+	if err := CopyFS(dst, os.DirFS(srcdir)); err != nil {
+		t.Fatal(err)
+	}
+	return dst
+}
+
+// CopyFS copies the files and directories in src to a
+// destination directory dst. Paths to files and directories
+// ending in a ".test" extension have the ".test" extension
+// removed. This allows tests to hide files whose names have
+// special meaning, such as "go.mod" files or "testdata" directories
+// from the go command, or ill-formed Go source files from gofmt.
+//
+// For example if we copy the directory testdata:
+//
+//	testdata/
+//	    go.mod.test
+//	    a/a.go
+//	    b/b.go
+//
+// The resulting files will be:
+//
+//	dst/
+//	    go.mod
+//	    a/a.go
+//	    b/b.go
+func CopyFS(dstdir string, src fs.FS) error {
+	if err := copyFS(dstdir, src); err != nil {
+		return err
+	}
+
+	// Collect ".test" paths in lexical order.
+	var rename []string
+	err := fs.WalkDir(os.DirFS(dstdir), ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if strings.HasSuffix(path, ".test") {
+			rename = append(rename, path)
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	// Rename the .test paths in reverse lexical order, e.g.
+	// in d.test/a.test renames a.test to d.test/a then d.test to d.
+	for i := len(rename) - 1; i >= 0; i-- {
+		oldpath := filepath.Join(dstdir, rename[i])
+		newpath := strings.TrimSuffix(oldpath, ".test")
+		if err != os.Rename(oldpath, newpath) {
+			return err
+		}
+	}
+	return nil
+}
+
+// Copy the files in src to dst.
+// Use os.CopyFS when 1.23 can be used in x/tools.
+func copyFS(dstdir string, src fs.FS) error {
+	return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		newpath := filepath.Join(dstdir, path)
+		if d.IsDir() {
+			return os.MkdirAll(newpath, 0777)
+		}
+		r, err := src.Open(path)
+		if err != nil {
+			return err
+		}
+		defer r.Close()
+
+		w, err := os.Create(newpath)
+		if err != nil {
+			return err
+		}
+		defer w.Close()
+		_, err = io.Copy(w, r)
+		return err
+	})
+}
+
+// ExtractTxtar writes each archive file to the corresponding location beneath dir.
+//
+// TODO(adonovan): move this to txtar package, we need it all the time (#61386).
+func ExtractTxtar(dstdir string, ar *txtar.Archive) error {
+	for _, file := range ar.Files {
+		name := filepath.Join(dstdir, file.Name)
+		if err := os.MkdirAll(filepath.Dir(name), 0777); err != nil {
+			return err
+		}
+		if err := os.WriteFile(name, file.Data, 0666); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ExtractTxtarToTmp read a txtar archive on a given path,
+// extracts it to a temporary directory, and returns the
+// temporary directory.
+func ExtractTxtarToTmp(t testing.TB, archive string) string {
+	ar, err := txtar.ParseFile(archive)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	dir := t.TempDir()
+	err = ExtractTxtar(dir, ar)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return dir
+}
diff --git a/internal/testfiles/testfiles_test.go b/internal/testfiles/testfiles_test.go
new file mode 100644
index 0000000..d956326
--- /dev/null
+++ b/internal/testfiles/testfiles_test.go
@@ -0,0 +1,54 @@
+// Copyright 2024 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 testfiles_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"golang.org/x/tools/go/analysis"
+	"golang.org/x/tools/go/analysis/analysistest"
+	"golang.org/x/tools/internal/testenv"
+	"golang.org/x/tools/internal/testfiles"
+	"golang.org/x/tools/internal/versions"
+)
+
+func TestTestDir(t *testing.T) {
+	testenv.NeedsGo1Point(t, 22)
+
+	// TODO(taking): Expose a helper for this pattern?
+	// dir must contain a go.mod file to be picked up by Run().
+	// So this pattern or Join(TestDir(t, TestData()), "versions")  are
+	// probably what everyone will want.
+	dir := testfiles.CopyDirToTmp(t, filepath.Join(analysistest.TestData(), "versions"))
+
+	filever := &analysis.Analyzer{
+		Name: "filever",
+		Doc:  "reports file go versions",
+		Run: func(pass *analysis.Pass) (any, error) {
+			for _, file := range pass.Files {
+				ver := versions.FileVersion(pass.TypesInfo, file)
+				name := filepath.Base(pass.Fset.Position(file.Package).Filename)
+				pass.Reportf(file.Package, "%s@%s", name, ver)
+			}
+			return nil, nil
+		},
+	}
+	analysistest.Run(t, dir, filever, "golang.org/fake/versions", "golang.org/fake/versions/sub")
+}
+
+func TestCopyTestFilesErrors(t *testing.T) {
+	tmp := t.TempDir() // a real tmp dir
+	for _, dir := range []string{
+		filepath.Join(analysistest.TestData(), "not_there"),    // dir does not exist
+		filepath.Join(analysistest.TestData(), "somefile.txt"), // not a dir
+	} {
+		err := testfiles.CopyFS(tmp, os.DirFS(dir))
+		if err == nil {
+			t.Error("Expected an error from CopyTestFiles")
+		}
+	}
+}