|  | // 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...) | 
|  |  | 
|  | // 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 _, act := range r { | 
|  | 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 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++ | 
|  | 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)) | 
|  | } |