github.com/jhump/golang-x-tools@v0.0.0-20220218190644-4958d6d39439/go/analysis/analysistest/analysistest.go (about)

     1  // Copyright 2018 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  
     5  // Package analysistest provides utilities for testing analyzers.
     6  package analysistest
     7  
     8  import (
     9  	"bytes"
    10  	"fmt"
    11  	"go/format"
    12  	"go/token"
    13  	"go/types"
    14  	"io/ioutil"
    15  	"log"
    16  	"os"
    17  	"path/filepath"
    18  	"regexp"
    19  	"sort"
    20  	"strconv"
    21  	"strings"
    22  	"text/scanner"
    23  
    24  	"github.com/jhump/golang-x-tools/go/analysis"
    25  	"github.com/jhump/golang-x-tools/go/analysis/internal/checker"
    26  	"github.com/jhump/golang-x-tools/go/packages"
    27  	"github.com/jhump/golang-x-tools/internal/lsp/diff"
    28  	"github.com/jhump/golang-x-tools/internal/lsp/diff/myers"
    29  	"github.com/jhump/golang-x-tools/internal/span"
    30  	"github.com/jhump/golang-x-tools/internal/testenv"
    31  	"github.com/jhump/golang-x-tools/txtar"
    32  )
    33  
    34  // WriteFiles is a helper function that creates a temporary directory
    35  // and populates it with a GOPATH-style project using filemap (which
    36  // maps file names to contents). On success it returns the name of the
    37  // directory and a cleanup function to delete it.
    38  func WriteFiles(filemap map[string]string) (dir string, cleanup func(), err error) {
    39  	gopath, err := ioutil.TempDir("", "analysistest")
    40  	if err != nil {
    41  		return "", nil, err
    42  	}
    43  	cleanup = func() { os.RemoveAll(gopath) }
    44  
    45  	for name, content := range filemap {
    46  		filename := filepath.Join(gopath, "src", name)
    47  		os.MkdirAll(filepath.Dir(filename), 0777) // ignore error
    48  		if err := ioutil.WriteFile(filename, []byte(content), 0666); err != nil {
    49  			cleanup()
    50  			return "", nil, err
    51  		}
    52  	}
    53  	return gopath, cleanup, nil
    54  }
    55  
    56  // TestData returns the effective filename of
    57  // the program's "testdata" directory.
    58  // This function may be overridden by projects using
    59  // an alternative build system (such as Blaze) that
    60  // does not run a test in its package directory.
    61  var TestData = func() string {
    62  	testdata, err := filepath.Abs("testdata")
    63  	if err != nil {
    64  		log.Fatal(err)
    65  	}
    66  	return testdata
    67  }
    68  
    69  // Testing is an abstraction of a *testing.T.
    70  type Testing interface {
    71  	Errorf(format string, args ...interface{})
    72  }
    73  
    74  // RunWithSuggestedFixes behaves like Run, but additionally verifies suggested fixes.
    75  // It uses golden files placed alongside the source code under analysis:
    76  // suggested fixes for code in example.go will be compared against example.go.golden.
    77  //
    78  // Golden files can be formatted in one of two ways: as plain Go source code, or as txtar archives.
    79  // In the first case, all suggested fixes will be applied to the original source, which will then be compared against the golden file.
    80  // In the second case, suggested fixes will be grouped by their messages, and each set of fixes will be applied and tested separately.
    81  // Each section in the archive corresponds to a single message.
    82  //
    83  // A golden file using txtar may look like this:
    84  // 	-- turn into single negation --
    85  // 	package pkg
    86  //
    87  // 	func fn(b1, b2 bool) {
    88  // 		if !b1 { // want `negating a boolean twice`
    89  // 			println()
    90  // 		}
    91  // 	}
    92  //
    93  // 	-- remove double negation --
    94  // 	package pkg
    95  //
    96  // 	func fn(b1, b2 bool) {
    97  // 		if b1 { // want `negating a boolean twice`
    98  // 			println()
    99  // 		}
   100  // 	}
   101  func RunWithSuggestedFixes(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
   102  	r := Run(t, dir, a, patterns...)
   103  
   104  	// Process each result (package) separately, matching up the suggested
   105  	// fixes into a diff, which we will compare to the .golden file.  We have
   106  	// to do this per-result in case a file appears in two packages, such as in
   107  	// packages with tests, where mypkg/a.go will appear in both mypkg and
   108  	// mypkg.test.  In that case, the analyzer may suggest the same set of
   109  	// changes to a.go for each package.  If we merge all the results, those
   110  	// changes get doubly applied, which will cause conflicts or mismatches.
   111  	// Validating the results separately means as long as the two analyses
   112  	// don't produce conflicting suggestions for a single file, everything
   113  	// should match up.
   114  	for _, act := range r {
   115  		// file -> message -> edits
   116  		fileEdits := make(map[*token.File]map[string][]diff.TextEdit)
   117  		fileContents := make(map[*token.File][]byte)
   118  
   119  		// Validate edits, prepare the fileEdits map and read the file contents.
   120  		for _, diag := range act.Diagnostics {
   121  			for _, sf := range diag.SuggestedFixes {
   122  				for _, edit := range sf.TextEdits {
   123  					// Validate the edit.
   124  					if edit.Pos > edit.End {
   125  						t.Errorf(
   126  							"diagnostic for analysis %v contains Suggested Fix with malformed edit: pos (%v) > end (%v)",
   127  							act.Pass.Analyzer.Name, edit.Pos, edit.End)
   128  						continue
   129  					}
   130  					file, endfile := act.Pass.Fset.File(edit.Pos), act.Pass.Fset.File(edit.End)
   131  					if file == nil || endfile == nil || file != endfile {
   132  						t.Errorf(
   133  							"diagnostic for analysis %v contains Suggested Fix with malformed spanning files %v and %v",
   134  							act.Pass.Analyzer.Name, file.Name(), endfile.Name())
   135  						continue
   136  					}
   137  					if _, ok := fileContents[file]; !ok {
   138  						contents, err := ioutil.ReadFile(file.Name())
   139  						if err != nil {
   140  							t.Errorf("error reading %s: %v", file.Name(), err)
   141  						}
   142  						fileContents[file] = contents
   143  					}
   144  					spn, err := span.NewRange(act.Pass.Fset, edit.Pos, edit.End).Span()
   145  					if err != nil {
   146  						t.Errorf("error converting edit to span %s: %v", file.Name(), err)
   147  					}
   148  
   149  					if _, ok := fileEdits[file]; !ok {
   150  						fileEdits[file] = make(map[string][]diff.TextEdit)
   151  					}
   152  					fileEdits[file][sf.Message] = append(fileEdits[file][sf.Message], diff.TextEdit{
   153  						Span:    spn,
   154  						NewText: string(edit.NewText),
   155  					})
   156  				}
   157  			}
   158  		}
   159  
   160  		for file, fixes := range fileEdits {
   161  			// Get the original file contents.
   162  			orig, ok := fileContents[file]
   163  			if !ok {
   164  				t.Errorf("could not find file contents for %s", file.Name())
   165  				continue
   166  			}
   167  
   168  			// Get the golden file and read the contents.
   169  			ar, err := txtar.ParseFile(file.Name() + ".golden")
   170  			if err != nil {
   171  				t.Errorf("error reading %s.golden: %v", file.Name(), err)
   172  				continue
   173  			}
   174  
   175  			if len(ar.Files) > 0 {
   176  				// one virtual file per kind of suggested fix
   177  
   178  				if len(ar.Comment) != 0 {
   179  					// we allow either just the comment, or just virtual
   180  					// files, not both. it is not clear how "both" should
   181  					// behave.
   182  					t.Errorf("%s.golden has leading comment; we don't know what to do with it", file.Name())
   183  					continue
   184  				}
   185  
   186  				for sf, edits := range fixes {
   187  					found := false
   188  					for _, vf := range ar.Files {
   189  						if vf.Name == sf {
   190  							found = true
   191  							out := diff.ApplyEdits(string(orig), edits)
   192  							// the file may contain multiple trailing
   193  							// newlines if the user places empty lines
   194  							// between files in the archive. normalize
   195  							// this to a single newline.
   196  							want := string(bytes.TrimRight(vf.Data, "\n")) + "\n"
   197  							formatted, err := format.Source([]byte(out))
   198  							if err != nil {
   199  								t.Errorf("%s: error formatting edited source: %v\n%s", file.Name(), err, out)
   200  								continue
   201  							}
   202  							if want != string(formatted) {
   203  								d, err := myers.ComputeEdits("", want, string(formatted))
   204  								if err != nil {
   205  									t.Errorf("failed to compute suggested fix diff: %v", err)
   206  								}
   207  								t.Errorf("suggested fixes failed for %s:\n%s", file.Name(), diff.ToUnified(fmt.Sprintf("%s.golden [%s]", file.Name(), sf), "actual", want, d))
   208  							}
   209  							break
   210  						}
   211  					}
   212  					if !found {
   213  						t.Errorf("no section for suggested fix %q in %s.golden", sf, file.Name())
   214  					}
   215  				}
   216  			} else {
   217  				// all suggested fixes are represented by a single file
   218  
   219  				var catchallEdits []diff.TextEdit
   220  				for _, edits := range fixes {
   221  					catchallEdits = append(catchallEdits, edits...)
   222  				}
   223  
   224  				out := diff.ApplyEdits(string(orig), catchallEdits)
   225  				want := string(ar.Comment)
   226  
   227  				formatted, err := format.Source([]byte(out))
   228  				if err != nil {
   229  					t.Errorf("%s: error formatting resulting source: %v\n%s", file.Name(), err, out)
   230  					continue
   231  				}
   232  				if want != string(formatted) {
   233  					d, err := myers.ComputeEdits("", want, string(formatted))
   234  					if err != nil {
   235  						t.Errorf("%s: failed to compute suggested fix diff: %s", file.Name(), err)
   236  					}
   237  					t.Errorf("suggested fixes failed for %s:\n%s", file.Name(), diff.ToUnified(file.Name()+".golden", "actual", want, d))
   238  				}
   239  			}
   240  		}
   241  	}
   242  	return r
   243  }
   244  
   245  // Run applies an analysis to the packages denoted by the "go list" patterns.
   246  //
   247  // It loads the packages from the specified GOPATH-style project
   248  // directory using golang.org/x/tools/go/packages, runs the analysis on
   249  // them, and checks that each analysis emits the expected diagnostics
   250  // and facts specified by the contents of '// want ...' comments in the
   251  // package's source files.
   252  //
   253  // An expectation of a Diagnostic is specified by a string literal
   254  // containing a regular expression that must match the diagnostic
   255  // message. For example:
   256  //
   257  //	fmt.Printf("%s", 1) // want `cannot provide int 1 to %s`
   258  //
   259  // An expectation of a Fact associated with an object is specified by
   260  // 'name:"pattern"', where name is the name of the object, which must be
   261  // declared on the same line as the comment, and pattern is a regular
   262  // expression that must match the string representation of the fact,
   263  // fmt.Sprint(fact). For example:
   264  //
   265  //	func panicf(format string, args interface{}) { // want panicf:"printfWrapper"
   266  //
   267  // Package facts are specified by the name "package" and appear on
   268  // line 1 of the first source file of the package.
   269  //
   270  // A single 'want' comment may contain a mixture of diagnostic and fact
   271  // expectations, including multiple facts about the same object:
   272  //
   273  //	// want "diag" "diag2" x:"fact1" x:"fact2" y:"fact3"
   274  //
   275  // Unexpected diagnostics and facts, and unmatched expectations, are
   276  // reported as errors to the Testing.
   277  //
   278  // Run reports an error to the Testing if loading or analysis failed.
   279  // Run also returns a Result for each package for which analysis was
   280  // attempted, even if unsuccessful. It is safe for a test to ignore all
   281  // the results, but a test may use it to perform additional checks.
   282  func Run(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
   283  	if t, ok := t.(testenv.Testing); ok {
   284  		testenv.NeedsGoPackages(t)
   285  	}
   286  
   287  	pkgs, err := loadPackages(a, dir, patterns...)
   288  	if err != nil {
   289  		t.Errorf("loading %s: %v", patterns, err)
   290  		return nil
   291  	}
   292  
   293  	results := checker.TestAnalyzer(a, pkgs)
   294  	for _, result := range results {
   295  		if result.Err != nil {
   296  			t.Errorf("error analyzing %s: %v", result.Pass, result.Err)
   297  		} else {
   298  			check(t, dir, result.Pass, result.Diagnostics, result.Facts)
   299  		}
   300  	}
   301  	return results
   302  }
   303  
   304  // A Result holds the result of applying an analyzer to a package.
   305  type Result = checker.TestAnalyzerResult
   306  
   307  // loadPackages uses go/packages to load a specified packages (from source, with
   308  // dependencies) from dir, which is the root of a GOPATH-style project
   309  // tree. It returns an error if any package had an error, or the pattern
   310  // matched no packages.
   311  func loadPackages(a *analysis.Analyzer, dir string, patterns ...string) ([]*packages.Package, error) {
   312  	// packages.Load loads the real standard library, not a minimal
   313  	// fake version, which would be more efficient, especially if we
   314  	// have many small tests that import, say, net/http.
   315  	// However there is no easy way to make go/packages to consume
   316  	// a list of packages we generate and then do the parsing and
   317  	// typechecking, though this feature seems to be a recurring need.
   318  
   319  	cfg := &packages.Config{
   320  		Mode:  packages.LoadAllSyntax,
   321  		Dir:   dir,
   322  		Tests: true,
   323  		Env:   append(os.Environ(), "GOPATH="+dir, "GO111MODULE=off", "GOPROXY=off"),
   324  	}
   325  	pkgs, err := packages.Load(cfg, patterns...)
   326  	if err != nil {
   327  		return nil, err
   328  	}
   329  
   330  	// Do NOT print errors if the analyzer will continue running.
   331  	// It is incredibly confusing for tests to be printing to stderr
   332  	// willy-nilly instead of their test logs, especially when the
   333  	// errors are expected and are going to be fixed.
   334  	if !a.RunDespiteErrors {
   335  		packages.PrintErrors(pkgs)
   336  	}
   337  
   338  	if len(pkgs) == 0 {
   339  		return nil, fmt.Errorf("no packages matched %s", patterns)
   340  	}
   341  	return pkgs, nil
   342  }
   343  
   344  // check inspects an analysis pass on which the analysis has already
   345  // been run, and verifies that all reported diagnostics and facts match
   346  // specified by the contents of "// want ..." comments in the package's
   347  // source files, which must have been parsed with comments enabled.
   348  func check(t Testing, gopath string, pass *analysis.Pass, diagnostics []analysis.Diagnostic, facts map[types.Object][]analysis.Fact) {
   349  	type key struct {
   350  		file string
   351  		line int
   352  	}
   353  
   354  	want := make(map[key][]expectation)
   355  
   356  	// processComment parses expectations out of comments.
   357  	processComment := func(filename string, linenum int, text string) {
   358  		text = strings.TrimSpace(text)
   359  
   360  		// Any comment starting with "want" is treated
   361  		// as an expectation, even without following whitespace.
   362  		if rest := strings.TrimPrefix(text, "want"); rest != text {
   363  			lineDelta, expects, err := parseExpectations(rest)
   364  			if err != nil {
   365  				t.Errorf("%s:%d: in 'want' comment: %s", filename, linenum, err)
   366  				return
   367  			}
   368  			if expects != nil {
   369  				want[key{filename, linenum + lineDelta}] = expects
   370  			}
   371  		}
   372  	}
   373  
   374  	// Extract 'want' comments from parsed Go files.
   375  	for _, f := range pass.Files {
   376  		for _, cgroup := range f.Comments {
   377  			for _, c := range cgroup.List {
   378  
   379  				text := strings.TrimPrefix(c.Text, "//")
   380  				if text == c.Text { // not a //-comment.
   381  					text = strings.TrimPrefix(text, "/*")
   382  					text = strings.TrimSuffix(text, "*/")
   383  				}
   384  
   385  				// Hack: treat a comment of the form "//...// want..."
   386  				// or "/*...// want... */
   387  				// as if it starts at 'want'.
   388  				// This allows us to add comments on comments,
   389  				// as required when testing the buildtag analyzer.
   390  				if i := strings.Index(text, "// want"); i >= 0 {
   391  					text = text[i+len("// "):]
   392  				}
   393  
   394  				// It's tempting to compute the filename
   395  				// once outside the loop, but it's
   396  				// incorrect because it can change due
   397  				// to //line directives.
   398  				posn := pass.Fset.Position(c.Pos())
   399  				filename := sanitize(gopath, posn.Filename)
   400  				processComment(filename, posn.Line, text)
   401  			}
   402  		}
   403  	}
   404  
   405  	// Extract 'want' comments from non-Go files.
   406  	// TODO(adonovan): we may need to handle //line directives.
   407  	for _, filename := range pass.OtherFiles {
   408  		data, err := ioutil.ReadFile(filename)
   409  		if err != nil {
   410  			t.Errorf("can't read '// want' comments from %s: %v", filename, err)
   411  			continue
   412  		}
   413  		filename := sanitize(gopath, filename)
   414  		linenum := 0
   415  		for _, line := range strings.Split(string(data), "\n") {
   416  			linenum++
   417  
   418  			// Hack: treat a comment of the form "//...// want..."
   419  			// or "/*...// want... */
   420  			// as if it starts at 'want'.
   421  			// This allows us to add comments on comments,
   422  			// as required when testing the buildtag analyzer.
   423  			if i := strings.Index(line, "// want"); i >= 0 {
   424  				line = line[i:]
   425  			}
   426  
   427  			if i := strings.Index(line, "//"); i >= 0 {
   428  				line = line[i+len("//"):]
   429  				processComment(filename, linenum, line)
   430  			}
   431  		}
   432  	}
   433  
   434  	checkMessage := func(posn token.Position, kind, name, message string) {
   435  		posn.Filename = sanitize(gopath, posn.Filename)
   436  		k := key{posn.Filename, posn.Line}
   437  		expects := want[k]
   438  		var unmatched []string
   439  		for i, exp := range expects {
   440  			if exp.kind == kind && exp.name == name {
   441  				if exp.rx.MatchString(message) {
   442  					// matched: remove the expectation.
   443  					expects[i] = expects[len(expects)-1]
   444  					expects = expects[:len(expects)-1]
   445  					want[k] = expects
   446  					return
   447  				}
   448  				unmatched = append(unmatched, fmt.Sprintf("%#q", exp.rx))
   449  			}
   450  		}
   451  		if unmatched == nil {
   452  			t.Errorf("%v: unexpected %s: %v", posn, kind, message)
   453  		} else {
   454  			t.Errorf("%v: %s %q does not match pattern %s",
   455  				posn, kind, message, strings.Join(unmatched, " or "))
   456  		}
   457  	}
   458  
   459  	// Check the diagnostics match expectations.
   460  	for _, f := range diagnostics {
   461  		// TODO(matloob): Support ranges in analysistest.
   462  		posn := pass.Fset.Position(f.Pos)
   463  		checkMessage(posn, "diagnostic", "", f.Message)
   464  	}
   465  
   466  	// Check the facts match expectations.
   467  	// Report errors in lexical order for determinism.
   468  	// (It's only deterministic within each file, not across files,
   469  	// because go/packages does not guarantee file.Pos is ascending
   470  	// across the files of a single compilation unit.)
   471  	var objects []types.Object
   472  	for obj := range facts {
   473  		objects = append(objects, obj)
   474  	}
   475  	sort.Slice(objects, func(i, j int) bool {
   476  		// Package facts compare less than object facts.
   477  		ip, jp := objects[i] == nil, objects[j] == nil // whether i, j is a package fact
   478  		if ip != jp {
   479  			return ip && !jp
   480  		}
   481  		return objects[i].Pos() < objects[j].Pos()
   482  	})
   483  	for _, obj := range objects {
   484  		var posn token.Position
   485  		var name string
   486  		if obj != nil {
   487  			// Object facts are reported on the declaring line.
   488  			name = obj.Name()
   489  			posn = pass.Fset.Position(obj.Pos())
   490  		} else {
   491  			// Package facts are reported at the start of the file.
   492  			name = "package"
   493  			posn = pass.Fset.Position(pass.Files[0].Pos())
   494  			posn.Line = 1
   495  		}
   496  
   497  		for _, fact := range facts[obj] {
   498  			checkMessage(posn, "fact", name, fmt.Sprint(fact))
   499  		}
   500  	}
   501  
   502  	// Reject surplus expectations.
   503  	//
   504  	// Sometimes an Analyzer reports two similar diagnostics on a
   505  	// line with only one expectation. The reader may be confused by
   506  	// the error message.
   507  	// TODO(adonovan): print a better error:
   508  	// "got 2 diagnostics here; each one needs its own expectation".
   509  	var surplus []string
   510  	for key, expects := range want {
   511  		for _, exp := range expects {
   512  			err := fmt.Sprintf("%s:%d: no %s was reported matching %#q", key.file, key.line, exp.kind, exp.rx)
   513  			surplus = append(surplus, err)
   514  		}
   515  	}
   516  	sort.Strings(surplus)
   517  	for _, err := range surplus {
   518  		t.Errorf("%s", err)
   519  	}
   520  }
   521  
   522  type expectation struct {
   523  	kind string // either "fact" or "diagnostic"
   524  	name string // name of object to which fact belongs, or "package" ("fact" only)
   525  	rx   *regexp.Regexp
   526  }
   527  
   528  func (ex expectation) String() string {
   529  	return fmt.Sprintf("%s %s:%q", ex.kind, ex.name, ex.rx) // for debugging
   530  }
   531  
   532  // parseExpectations parses the content of a "// want ..." comment
   533  // and returns the expectations, a mixture of diagnostics ("rx") and
   534  // facts (name:"rx").
   535  func parseExpectations(text string) (lineDelta int, expects []expectation, err error) {
   536  	var scanErr string
   537  	sc := new(scanner.Scanner).Init(strings.NewReader(text))
   538  	sc.Error = func(s *scanner.Scanner, msg string) {
   539  		scanErr = msg // e.g. bad string escape
   540  	}
   541  	sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanInts
   542  
   543  	scanRegexp := func(tok rune) (*regexp.Regexp, error) {
   544  		if tok != scanner.String && tok != scanner.RawString {
   545  			return nil, fmt.Errorf("got %s, want regular expression",
   546  				scanner.TokenString(tok))
   547  		}
   548  		pattern, _ := strconv.Unquote(sc.TokenText()) // can't fail
   549  		return regexp.Compile(pattern)
   550  	}
   551  
   552  	for {
   553  		tok := sc.Scan()
   554  		switch tok {
   555  		case '+':
   556  			tok = sc.Scan()
   557  			if tok != scanner.Int {
   558  				return 0, nil, fmt.Errorf("got +%s, want +Int", scanner.TokenString(tok))
   559  			}
   560  			lineDelta, _ = strconv.Atoi(sc.TokenText())
   561  		case scanner.String, scanner.RawString:
   562  			rx, err := scanRegexp(tok)
   563  			if err != nil {
   564  				return 0, nil, err
   565  			}
   566  			expects = append(expects, expectation{"diagnostic", "", rx})
   567  
   568  		case scanner.Ident:
   569  			name := sc.TokenText()
   570  			tok = sc.Scan()
   571  			if tok != ':' {
   572  				return 0, nil, fmt.Errorf("got %s after %s, want ':'",
   573  					scanner.TokenString(tok), name)
   574  			}
   575  			tok = sc.Scan()
   576  			rx, err := scanRegexp(tok)
   577  			if err != nil {
   578  				return 0, nil, err
   579  			}
   580  			expects = append(expects, expectation{"fact", name, rx})
   581  
   582  		case scanner.EOF:
   583  			if scanErr != "" {
   584  				return 0, nil, fmt.Errorf("%s", scanErr)
   585  			}
   586  			return lineDelta, expects, nil
   587  
   588  		default:
   589  			return 0, nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok))
   590  		}
   591  	}
   592  }
   593  
   594  // sanitize removes the GOPATH portion of the filename,
   595  // typically a gnarly /tmp directory, and returns the rest.
   596  func sanitize(gopath, filename string) string {
   597  	prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator)
   598  	return filepath.ToSlash(strings.TrimPrefix(filename, prefix))
   599  }