blob: ed4042b192ccb9abb66336ce7e7e08f50b108a35 [file] [log] [blame]
// Package analysistest provides utilities for testing analyzers.
package analysistest
import (
"fmt"
"go/token"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/internal/checker"
"golang.org/x/tools/go/packages"
)
// WriteFiles is a helper function that creates a temporary directory
// and populates it with a GOPATH-style project using filemap (which
// maps file names to contents). On success it returns the name of the
// directory and a cleanup function to delete it.
func WriteFiles(filemap map[string]string) (dir string, cleanup func(), err error) {
gopath, err := ioutil.TempDir("", "analysistest")
if err != nil {
return "", nil, err
}
cleanup = func() { os.RemoveAll(gopath) }
for name, content := range filemap {
filename := filepath.Join(gopath, "src", name)
os.MkdirAll(filepath.Dir(filename), 0777) // ignore error
if err := ioutil.WriteFile(filename, []byte(content), 0666); err != nil {
cleanup()
return "", nil, err
}
}
return gopath, cleanup, nil
}
// TestData returns the effective filename of
// the program's "testdata" directory.
// This function may be overridden by projects using
// an alternative build system (such as Blaze) that
// does not run a test in its package directory.
var TestData = func() string {
testdata, err := filepath.Abs("testdata")
if err != nil {
log.Fatal(err)
}
return testdata
}
// Testing is an abstraction of a *testing.T.
type Testing interface {
Errorf(format string, args ...interface{})
}
// Run applies an analysis to each named package.
// It loads each package from the specified GOPATH-style project
// directory using golang.org/x/tools/go/packages, runs the analysis on
// it, and checks that each the analysis generates the diagnostics
// specified by 'want "..."' comments in the package's source files.
//
// You may wish to call this function from within a (*testing.T).Run
// subtest to ensure that errors have adequate contextual description.
func Run(t Testing, dir string, a *analysis.Analyzer, pkgnames ...string) {
for _, pkgname := range pkgnames {
pkg, err := loadPackage(dir, pkgname)
if err != nil {
t.Errorf("loading %s: %v", pkgname, err)
continue
}
pass, diagnostics, err := checker.Analyze(pkg, a)
if err != nil {
t.Errorf("analyzing %s: %v", pkgname, err)
continue
}
checkDiagnostics(t, dir, pass, diagnostics)
}
}
// loadPackage loads the specified package (from source, with
// dependencies) from dir, which is the root of a GOPATH-style project tree.
func loadPackage(dir, pkgpath string) (*packages.Package, error) {
// packages.Load loads the real standard library, not a minimal
// fake version, which would be more efficient, especially if we
// have many small tests that import, say, net/http.
// However there is no easy way to make go/packages to consume
// a list of packages we generate and then do the parsing and
// typechecking, though this feature seems to be a recurring need.
//
// It is possible to write a custom driver, but it's fairly
// involved and requires setting a global (environment) variable.
//
// Also, using the "go list" driver will probably not work in google3.
//
// TODO(adonovan): extend go/packages to allow bypassing the driver.
cfg := &packages.Config{
Mode: packages.LoadAllSyntax,
Dir: dir,
Tests: true,
Env: append(os.Environ(), "GOPATH="+dir),
}
pkgs, err := packages.Load(cfg, pkgpath)
if err != nil {
return nil, err
}
if len(pkgs) != 1 {
return nil, fmt.Errorf("pattern %q expanded to %d packages, want 1",
pkgpath, len(pkgs))
}
return pkgs[0], nil
}
// checkDiagnostics inspects an analysis pass on which the analysis has
// already been run, and verifies that all reported diagnostics match those
// specified by 'want "..."' comments in the package's source files,
// which must have been parsed with comments enabled. Surplus diagnostics
// and unmatched expectations are reported as errors to the Testing.
func checkDiagnostics(t Testing, gopath string, pass *analysis.Pass, diagnostics []analysis.Diagnostic) {
// Read expectations out of comments.
type key struct {
file string
line int
}
wantErrs := make(map[key]*regexp.Regexp)
for _, f := range pass.Files {
for _, c := range f.Comments {
posn := pass.Fset.Position(c.Pos())
sanitize(gopath, &posn)
text := strings.TrimSpace(c.Text())
if !strings.HasPrefix(text, "want") {
continue
}
text = strings.TrimSpace(text[len("want"):])
pattern, err := strconv.Unquote(text)
if err != nil {
t.Errorf("%s: in 'want' comment: %v", posn, err)
continue
}
rx, err := regexp.Compile(pattern)
if err != nil {
t.Errorf("%s: %v", posn, err)
continue
}
wantErrs[key{posn.Filename, posn.Line}] = rx
}
}
// Check the diagnostics match expectations.
for _, f := range diagnostics {
posn := pass.Fset.Position(f.Pos)
sanitize(gopath, &posn)
rx, ok := wantErrs[key{posn.Filename, posn.Line}]
if !ok {
t.Errorf("%v: unexpected diagnostic: %v", posn, f.Message)
continue
}
delete(wantErrs, key{posn.Filename, posn.Line})
if !rx.MatchString(f.Message) {
t.Errorf("%v: diagnostic %q does not match pattern %q", posn, f.Message, rx)
}
}
for key, rx := range wantErrs {
t.Errorf("%s:%d: expected diagnostic matching %q", key.file, key.line, rx)
}
}
// sanitize removes the GOPATH portion of the filename,
// typically a gnarly /tmp directory.
func sanitize(gopath string, posn *token.Position) {
prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator)
posn.Filename = strings.TrimPrefix(posn.Filename, prefix)
}