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