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 }