+++ /dev/null
-// Package analysistest provides utilities for testing analyzers.
-package analysistest
-
-import (
- "bytes"
- "fmt"
- "go/format"
- "go/token"
- "go/types"
- "io/ioutil"
- "log"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "text/scanner"
-
- "golang.org/x/tools/go/analysis"
- "golang.org/x/tools/go/analysis/internal/checker"
- "golang.org/x/tools/go/packages"
- "golang.org/x/tools/internal/lsp/diff"
- "golang.org/x/tools/internal/lsp/diff/myers"
- "golang.org/x/tools/internal/span"
- "golang.org/x/tools/internal/testenv"
- "golang.org/x/tools/txtar"
-)
-
-// 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{})
-}
-
-// RunWithSuggestedFixes behaves like Run, but additionally verifies suggested fixes.
-// It uses golden files placed alongside the source code under analysis:
-// suggested fixes for code in example.go will be compared against example.go.golden.
-//
-// Golden files can be formatted in one of two ways: as plain Go source code, or as txtar archives.
-// In the first case, all suggested fixes will be applied to the original source, which will then be compared against the golden file.
-// In the second case, suggested fixes will be grouped by their messages, and each set of fixes will be applied and tested separately.
-// Each section in the archive corresponds to a single message.
-//
-// A golden file using txtar may look like this:
-// -- turn into single negation --
-// package pkg
-//
-// func fn(b1, b2 bool) {
-// if !b1 { // want `negating a boolean twice`
-// println()
-// }
-// }
-//
-// -- remove double negation --
-// package pkg
-//
-// func fn(b1, b2 bool) {
-// if b1 { // want `negating a boolean twice`
-// println()
-// }
-// }
-func RunWithSuggestedFixes(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
- r := Run(t, dir, a, patterns...)
-
- // Process each result (package) separately, matching up the suggested
- // fixes into a diff, which we will compare to the .golden file. We have
- // to do this per-result in case a file appears in two packages, such as in
- // packages with tests, where mypkg/a.go will appear in both mypkg and
- // mypkg.test. In that case, the analyzer may suggest the same set of
- // changes to a.go for each package. If we merge all the results, those
- // changes get doubly applied, which will cause conflicts or mismatches.
- // Validating the results separately means as long as the two analyses
- // don't produce conflicting suggestions for a single file, everything
- // should match up.
- for _, act := range r {
- // file -> message -> edits
- fileEdits := make(map[*token.File]map[string][]diff.TextEdit)
- fileContents := make(map[*token.File][]byte)
-
- // Validate edits, prepare the fileEdits map and read the file contents.
- for _, diag := range act.Diagnostics {
- for _, sf := range diag.SuggestedFixes {
- for _, edit := range sf.TextEdits {
- // Validate the edit.
- if edit.Pos > edit.End {
- t.Errorf(
- "diagnostic for analysis %v contains Suggested Fix with malformed edit: pos (%v) > end (%v)",
- act.Pass.Analyzer.Name, edit.Pos, edit.End)
- continue
- }
- file, endfile := act.Pass.Fset.File(edit.Pos), act.Pass.Fset.File(edit.End)
- if file == nil || endfile == nil || file != endfile {
- t.Errorf(
- "diagnostic for analysis %v contains Suggested Fix with malformed spanning files %v and %v",
- act.Pass.Analyzer.Name, file.Name(), endfile.Name())
- continue
- }
- if _, ok := fileContents[file]; !ok {
- contents, err := ioutil.ReadFile(file.Name())
- if err != nil {
- t.Errorf("error reading %s: %v", file.Name(), err)
- }
- fileContents[file] = contents
- }
- spn, err := span.NewRange(act.Pass.Fset, edit.Pos, edit.End).Span()
- if err != nil {
- t.Errorf("error converting edit to span %s: %v", file.Name(), err)
- }
-
- if _, ok := fileEdits[file]; !ok {
- fileEdits[file] = make(map[string][]diff.TextEdit)
- }
- fileEdits[file][sf.Message] = append(fileEdits[file][sf.Message], diff.TextEdit{
- Span: spn,
- NewText: string(edit.NewText),
- })
- }
- }
- }
-
- for file, fixes := range fileEdits {
- // Get the original file contents.
- orig, ok := fileContents[file]
- if !ok {
- t.Errorf("could not find file contents for %s", file.Name())
- continue
- }
-
- // Get the golden file and read the contents.
- ar, err := txtar.ParseFile(file.Name() + ".golden")
- if err != nil {
- t.Errorf("error reading %s.golden: %v", file.Name(), err)
- continue
- }
-
- if len(ar.Files) > 0 {
- // one virtual file per kind of suggested fix
-
- if len(ar.Comment) != 0 {
- // we allow either just the comment, or just virtual
- // files, not both. it is not clear how "both" should
- // behave.
- t.Errorf("%s.golden has leading comment; we don't know what to do with it", file.Name())
- continue
- }
-
- for sf, edits := range fixes {
- found := false
- for _, vf := range ar.Files {
- if vf.Name == sf {
- found = true
- out := diff.ApplyEdits(string(orig), edits)
- // the file may contain multiple trailing
- // newlines if the user places empty lines
- // between files in the archive. normalize
- // this to a single newline.
- want := string(bytes.TrimRight(vf.Data, "\n")) + "\n"
- formatted, err := format.Source([]byte(out))
- if err != nil {
- continue
- }
- if want != string(formatted) {
- d := myers.ComputeEdits("", want, string(formatted))
- t.Errorf("suggested fixes failed for %s:\n%s", file.Name(), diff.ToUnified(fmt.Sprintf("%s.golden [%s]", file.Name(), sf), "actual", want, d))
- }
- break
- }
- }
- if !found {
- t.Errorf("no section for suggested fix %q in %s.golden", sf, file.Name())
- }
- }
- } else {
- // all suggested fixes are represented by a single file
-
- var catchallEdits []diff.TextEdit
- for _, edits := range fixes {
- catchallEdits = append(catchallEdits, edits...)
- }
-
- out := diff.ApplyEdits(string(orig), catchallEdits)
- want := string(ar.Comment)
-
- formatted, err := format.Source([]byte(out))
- if err != nil {
- continue
- }
- if want != string(formatted) {
- d := myers.ComputeEdits("", want, string(formatted))
- t.Errorf("suggested fixes failed for %s:\n%s", file.Name(), diff.ToUnified(file.Name()+".golden", "actual", want, d))
- }
- }
- }
- }
- return r
-}
-
-// Run applies an analysis to the packages denoted by the "go list" patterns.
-//
-// It loads the packages from the specified GOPATH-style project
-// directory using golang.org/x/tools/go/packages, runs the analysis on
-// them, and checks that each analysis emits the expected diagnostics
-// and facts specified by the contents of '// want ...' comments in the
-// package's source files.
-//
-// An expectation of a Diagnostic is specified by a string literal
-// containing a regular expression that must match the diagnostic
-// message. For example:
-//
-// fmt.Printf("%s", 1) // want `cannot provide int 1 to %s`
-//
-// An expectation of a Fact associated with an object is specified by
-// 'name:"pattern"', where name is the name of the object, which must be
-// declared on the same line as the comment, and pattern is a regular
-// expression that must match the string representation of the fact,
-// fmt.Sprint(fact). For example:
-//
-// func panicf(format string, args interface{}) { // want panicf:"printfWrapper"
-//
-// Package facts are specified by the name "package" and appear on
-// line 1 of the first source file of the package.
-//
-// A single 'want' comment may contain a mixture of diagnostic and fact
-// expectations, including multiple facts about the same object:
-//
-// // want "diag" "diag2" x:"fact1" x:"fact2" y:"fact3"
-//
-// Unexpected diagnostics and facts, and unmatched expectations, are
-// reported as errors to the Testing.
-//
-// Run reports an error to the Testing if loading or analysis failed.
-// Run also returns a Result for each package for which analysis was
-// attempted, even if unsuccessful. It is safe for a test to ignore all
-// the results, but a test may use it to perform additional checks.
-func Run(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
- if t, ok := t.(testenv.Testing); ok {
- testenv.NeedsGoPackages(t)
- }
-
- pkgs, err := loadPackages(dir, patterns...)
- if err != nil {
- t.Errorf("loading %s: %v", patterns, err)
- return nil
- }
-
- results := checker.TestAnalyzer(a, pkgs)
- for _, result := range results {
- if result.Err != nil {
- t.Errorf("error analyzing %s: %v", result.Pass, result.Err)
- } else {
- check(t, dir, result.Pass, result.Diagnostics, result.Facts)
- }
- }
- return results
-}
-
-// A Result holds the result of applying an analyzer to a package.
-type Result = checker.TestAnalyzerResult
-
-// loadPackages uses go/packages to load a specified packages (from source, with
-// dependencies) from dir, which is the root of a GOPATH-style project
-// tree. It returns an error if any package had an error, or the pattern
-// matched no packages.
-func loadPackages(dir string, patterns ...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.
-
- cfg := &packages.Config{
- Mode: packages.LoadAllSyntax,
- Dir: dir,
- Tests: true,
- Env: append(os.Environ(), "GOPATH="+dir, "GO111MODULE=off", "GOPROXY=off"),
- }
- pkgs, err := packages.Load(cfg, patterns...)
- if err != nil {
- return nil, err
- }
-
- // Print errors but do not stop:
- // some Analyzers may be disposed to RunDespiteErrors.
- packages.PrintErrors(pkgs)
-
- if len(pkgs) == 0 {
- return nil, fmt.Errorf("no packages matched %s", patterns)
- }
- return pkgs, nil
-}
-
-// check inspects an analysis pass on which the analysis has already
-// been run, and verifies that all reported diagnostics and facts match
-// specified by the contents of "// want ..." comments in the package's
-// source files, which must have been parsed with comments enabled.
-func check(t Testing, gopath string, pass *analysis.Pass, diagnostics []analysis.Diagnostic, facts map[types.Object][]analysis.Fact) {
- type key struct {
- file string
- line int
- }
-
- want := make(map[key][]expectation)
-
- // processComment parses expectations out of comments.
- processComment := func(filename string, linenum int, text string) {
- text = strings.TrimSpace(text)
-
- // Any comment starting with "want" is treated
- // as an expectation, even without following whitespace.
- if rest := strings.TrimPrefix(text, "want"); rest != text {
- expects, err := parseExpectations(rest)
- if err != nil {
- t.Errorf("%s:%d: in 'want' comment: %s", filename, linenum, err)
- return
- }
- if expects != nil {
- want[key{filename, linenum}] = expects
- }
- }
- }
-
- // Extract 'want' comments from parsed Go files.
- for _, f := range pass.Files {
- for _, cgroup := range f.Comments {
- for _, c := range cgroup.List {
-
- text := strings.TrimPrefix(c.Text, "//")
- if text == c.Text { // not a //-comment.
- text = strings.TrimPrefix(text, "/*")
- text = strings.TrimSuffix(text, "*/")
- }
-
- // Hack: treat a comment of the form "//...// want..."
- // or "/*...// want... */
- // as if it starts at 'want'.
- // This allows us to add comments on comments,
- // as required when testing the buildtag analyzer.
- if i := strings.Index(text, "// want"); i >= 0 {
- text = text[i+len("// "):]
- }
-
- // It's tempting to compute the filename
- // once outside the loop, but it's
- // incorrect because it can change due
- // to //line directives.
- posn := pass.Fset.Position(c.Pos())
- filename := sanitize(gopath, posn.Filename)
- processComment(filename, posn.Line, text)
- }
- }
- }
-
- // Extract 'want' comments from non-Go files.
- // TODO(adonovan): we may need to handle //line directives.
- for _, filename := range pass.OtherFiles {
- data, err := ioutil.ReadFile(filename)
- if err != nil {
- t.Errorf("can't read '// want' comments from %s: %v", filename, err)
- continue
- }
- filename := sanitize(gopath, filename)
- linenum := 0
- for _, line := range strings.Split(string(data), "\n") {
- linenum++
-
- // Hack: treat a comment of the form "//...// want..."
- // or "/*...// want... */
- // as if it starts at 'want'.
- // This allows us to add comments on comments,
- // as required when testing the buildtag analyzer.
- if i := strings.Index(line, "// want"); i >= 0 {
- line = line[i:]
- }
-
- if i := strings.Index(line, "//"); i >= 0 {
- line = line[i+len("//"):]
- processComment(filename, linenum, line)
- }
- }
- }
-
- checkMessage := func(posn token.Position, kind, name, message string) {
- posn.Filename = sanitize(gopath, posn.Filename)
- k := key{posn.Filename, posn.Line}
- expects := want[k]
- var unmatched []string
- for i, exp := range expects {
- if exp.kind == kind && exp.name == name {
- if exp.rx.MatchString(message) {
- // matched: remove the expectation.
- expects[i] = expects[len(expects)-1]
- expects = expects[:len(expects)-1]
- want[k] = expects
- return
- }
- unmatched = append(unmatched, fmt.Sprintf("%q", exp.rx))
- }
- }
- if unmatched == nil {
- t.Errorf("%v: unexpected %s: %v", posn, kind, message)
- } else {
- t.Errorf("%v: %s %q does not match pattern %s",
- posn, kind, message, strings.Join(unmatched, " or "))
- }
- }
-
- // Check the diagnostics match expectations.
- for _, f := range diagnostics {
- // TODO(matloob): Support ranges in analysistest.
- posn := pass.Fset.Position(f.Pos)
- checkMessage(posn, "diagnostic", "", f.Message)
- }
-
- // Check the facts match expectations.
- // Report errors in lexical order for determinism.
- // (It's only deterministic within each file, not across files,
- // because go/packages does not guarantee file.Pos is ascending
- // across the files of a single compilation unit.)
- var objects []types.Object
- for obj := range facts {
- objects = append(objects, obj)
- }
- sort.Slice(objects, func(i, j int) bool {
- // Package facts compare less than object facts.
- ip, jp := objects[i] == nil, objects[j] == nil // whether i, j is a package fact
- if ip != jp {
- return ip && !jp
- }
- return objects[i].Pos() < objects[j].Pos()
- })
- for _, obj := range objects {
- var posn token.Position
- var name string
- if obj != nil {
- // Object facts are reported on the declaring line.
- name = obj.Name()
- posn = pass.Fset.Position(obj.Pos())
- } else {
- // Package facts are reported at the start of the file.
- name = "package"
- posn = pass.Fset.Position(pass.Files[0].Pos())
- posn.Line = 1
- }
-
- for _, fact := range facts[obj] {
- checkMessage(posn, "fact", name, fmt.Sprint(fact))
- }
- }
-
- // Reject surplus expectations.
- //
- // Sometimes an Analyzer reports two similar diagnostics on a
- // line with only one expectation. The reader may be confused by
- // the error message.
- // TODO(adonovan): print a better error:
- // "got 2 diagnostics here; each one needs its own expectation".
- var surplus []string
- for key, expects := range want {
- for _, exp := range expects {
- err := fmt.Sprintf("%s:%d: no %s was reported matching %q", key.file, key.line, exp.kind, exp.rx)
- surplus = append(surplus, err)
- }
- }
- sort.Strings(surplus)
- for _, err := range surplus {
- t.Errorf("%s", err)
- }
-}
-
-type expectation struct {
- kind string // either "fact" or "diagnostic"
- name string // name of object to which fact belongs, or "package" ("fact" only)
- rx *regexp.Regexp
-}
-
-func (ex expectation) String() string {
- return fmt.Sprintf("%s %s:%q", ex.kind, ex.name, ex.rx) // for debugging
-}
-
-// parseExpectations parses the content of a "// want ..." comment
-// and returns the expectations, a mixture of diagnostics ("rx") and
-// facts (name:"rx").
-func parseExpectations(text string) ([]expectation, error) {
- var scanErr string
- sc := new(scanner.Scanner).Init(strings.NewReader(text))
- sc.Error = func(s *scanner.Scanner, msg string) {
- scanErr = msg // e.g. bad string escape
- }
- sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings
-
- scanRegexp := func(tok rune) (*regexp.Regexp, error) {
- if tok != scanner.String && tok != scanner.RawString {
- return nil, fmt.Errorf("got %s, want regular expression",
- scanner.TokenString(tok))
- }
- pattern, _ := strconv.Unquote(sc.TokenText()) // can't fail
- return regexp.Compile(pattern)
- }
-
- var expects []expectation
- for {
- tok := sc.Scan()
- switch tok {
- case scanner.String, scanner.RawString:
- rx, err := scanRegexp(tok)
- if err != nil {
- return nil, err
- }
- expects = append(expects, expectation{"diagnostic", "", rx})
-
- case scanner.Ident:
- name := sc.TokenText()
- tok = sc.Scan()
- if tok != ':' {
- return nil, fmt.Errorf("got %s after %s, want ':'",
- scanner.TokenString(tok), name)
- }
- tok = sc.Scan()
- rx, err := scanRegexp(tok)
- if err != nil {
- return nil, err
- }
- expects = append(expects, expectation{"fact", name, rx})
-
- case scanner.EOF:
- if scanErr != "" {
- return nil, fmt.Errorf("%s", scanErr)
- }
- return expects, nil
-
- default:
- return nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok))
- }
- }
-}
-
-// sanitize removes the GOPATH portion of the filename,
-// typically a gnarly /tmp directory, and returns the rest.
-func sanitize(gopath, filename string) string {
- prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator)
- return filepath.ToSlash(strings.TrimPrefix(filename, prefix))
-}