1
2
3
4
5
6 package analysistest
7
8 import (
9 "bytes"
10 "fmt"
11 "go/format"
12 "go/token"
13 "go/types"
14 "log"
15 "os"
16 "path/filepath"
17 "regexp"
18 "runtime"
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
34
35
36
37 func WriteFiles(filemap map[string]string) (dir string, cleanup func(), err error) {
38 gopath, err := os.MkdirTemp("", "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)
47 if err := os.WriteFile(filename, []byte(content), 0666); err != nil {
48 cleanup()
49 return "", nil, err
50 }
51 }
52 return gopath, cleanup, nil
53 }
54
55
56
57
58
59
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
69 type Testing interface {
70 Errorf(format string, args ...interface{})
71 }
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129 func RunWithSuggestedFixes(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
130 r := Run(t, dir, a, patterns...)
131
132
133
134 inTools := false
135 {
136 var pcs [1]uintptr
137 n := runtime.Callers(1, pcs[:])
138 frames := runtime.CallersFrames(pcs[:n])
139 fr, _ := frames.Next()
140 if fr.Func != nil && strings.HasPrefix(fr.Func.Name(), "golang.org/x/tools/") {
141 inTools = true
142 }
143 }
144
145
146
147
148
149
150
151
152
153
154
155 for _, act := range r {
156
157 fileEdits := make(map[*token.File]map[string][]diff.Edit)
158 fileContents := make(map[*token.File][]byte)
159
160
161 for _, diag := range act.Diagnostics {
162 for _, fix := range diag.SuggestedFixes {
163
164
165 if inTools && len(fix.TextEdits) == 0 && diag.Category == "" {
166 t.Errorf("missing Diagnostic.Category for SuggestedFix without TextEdits (gopls requires the category for the name of the fix command")
167 }
168
169 for _, edit := range fix.TextEdits {
170 start, end := edit.Pos, edit.End
171 if !end.IsValid() {
172 end = start
173 }
174
175 if start > end {
176 t.Errorf(
177 "diagnostic for analysis %v contains Suggested Fix with malformed edit: pos (%v) > end (%v)",
178 act.Pass.Analyzer.Name, start, end)
179 continue
180 }
181 file, endfile := act.Pass.Fset.File(start), act.Pass.Fset.File(end)
182 if file == nil || endfile == nil || file != endfile {
183 t.Errorf(
184 "diagnostic for analysis %v contains Suggested Fix with malformed spanning files %v and %v",
185 act.Pass.Analyzer.Name, file.Name(), endfile.Name())
186 continue
187 }
188 if _, ok := fileContents[file]; !ok {
189 contents, err := os.ReadFile(file.Name())
190 if err != nil {
191 t.Errorf("error reading %s: %v", file.Name(), err)
192 }
193 fileContents[file] = contents
194 }
195 if _, ok := fileEdits[file]; !ok {
196 fileEdits[file] = make(map[string][]diff.Edit)
197 }
198 fileEdits[file][fix.Message] = append(fileEdits[file][fix.Message], diff.Edit{
199 Start: file.Offset(start),
200 End: file.Offset(end),
201 New: string(edit.NewText),
202 })
203 }
204 }
205 }
206
207 for file, fixes := range fileEdits {
208
209 orig, ok := fileContents[file]
210 if !ok {
211 t.Errorf("could not find file contents for %s", file.Name())
212 continue
213 }
214
215
216 ar, err := txtar.ParseFile(file.Name() + ".golden")
217 if err != nil {
218 t.Errorf("error reading %s.golden: %v", file.Name(), err)
219 continue
220 }
221
222 if len(ar.Files) > 0 {
223
224
225 if len(ar.Comment) != 0 {
226
227
228
229 t.Errorf("%s.golden has leading comment; we don't know what to do with it", file.Name())
230 continue
231 }
232
233 for sf, edits := range fixes {
234 found := false
235 for _, vf := range ar.Files {
236 if vf.Name == sf {
237 found = true
238
239
240
241
242 golden := append(bytes.TrimRight(vf.Data, "\n"), '\n')
243
244 if err := applyDiffsAndCompare(orig, golden, edits, file.Name()); err != nil {
245 t.Errorf("%s", err)
246 }
247 break
248 }
249 }
250 if !found {
251 t.Errorf("no section for suggested fix %q in %s.golden", sf, file.Name())
252 }
253 }
254 } else {
255
256
257 var catchallEdits []diff.Edit
258 for _, edits := range fixes {
259 catchallEdits = append(catchallEdits, edits...)
260 }
261
262 if err := applyDiffsAndCompare(orig, ar.Comment, catchallEdits, file.Name()); err != nil {
263 t.Errorf("%s", err)
264 }
265 }
266 }
267 }
268 return r
269 }
270
271
272
273 func applyDiffsAndCompare(src, golden []byte, edits []diff.Edit, fileName string) error {
274 out, err := diff.ApplyBytes(src, edits)
275 if err != nil {
276 return fmt.Errorf("%s: error applying fixes: %v (see possible explanations at RunWithSuggestedFixes)", fileName, err)
277 }
278 wantRaw, err := format.Source(golden)
279 if err != nil {
280 return fmt.Errorf("%s.golden: error formatting golden file: %v\n%s", fileName, err, out)
281 }
282 want := string(wantRaw)
283
284 formatted, err := format.Source(out)
285 if err != nil {
286 return fmt.Errorf("%s: error formatting resulting source: %v\n%s", fileName, err, out)
287 }
288 if got := string(formatted); got != want {
289 unified := diff.Unified(fileName+".golden", "actual", want, got)
290 return fmt.Errorf("suggested fixes failed for %s:\n%s", fileName, unified)
291 }
292 return nil
293 }
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337 func Run(t Testing, dir string, a *analysis.Analyzer, patterns ...string) []*Result {
338 if t, ok := t.(testing.TB); ok {
339 testenv.NeedsGoPackages(t)
340 }
341
342 pkgs, err := loadPackages(a, dir, patterns...)
343 if err != nil {
344 t.Errorf("loading %s: %v", patterns, err)
345 return nil
346 }
347
348 if err := analysis.Validate([]*analysis.Analyzer{a}); err != nil {
349 t.Errorf("Validate: %v", err)
350 return nil
351 }
352
353 results := checker.TestAnalyzer(a, pkgs)
354 for _, result := range results {
355 if result.Err != nil {
356 t.Errorf("error analyzing %s: %v", result.Pass, result.Err)
357 } else {
358 check(t, dir, result.Pass, result.Diagnostics, result.Facts)
359 }
360 }
361 return results
362 }
363
364
365 type Result = checker.TestAnalyzerResult
366
367
368
369
370
371 func loadPackages(a *analysis.Analyzer, dir string, patterns ...string) ([]*packages.Package, error) {
372 env := []string{"GOPATH=" + dir, "GO111MODULE=off", "GOWORK=off"}
373
374
375 if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
376 gowork := filepath.Join(dir, "go.work")
377 if _, err := os.Stat(gowork); err != nil {
378 gowork = "off"
379 }
380
381 env = []string{"GO111MODULE=on", "GOPROXY=off", "GOWORK=" + gowork}
382 }
383
384
385
386
387
388
389
390
391 mode := packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports |
392 packages.NeedTypes | packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo |
393 packages.NeedDeps | packages.NeedModule
394 cfg := &packages.Config{
395 Mode: mode,
396 Dir: dir,
397 Tests: true,
398 Env: append(os.Environ(), env...),
399 }
400 pkgs, err := packages.Load(cfg, patterns...)
401 if err != nil {
402 return nil, err
403 }
404
405
406
407 for _, pkg := range pkgs {
408 if pkg.Name == "" {
409 return nil, fmt.Errorf("failed to load %q: Errors=%v",
410 pkg.PkgPath, pkg.Errors)
411 }
412 }
413
414
415
416
417
418 if !a.RunDespiteErrors {
419 if packages.PrintErrors(pkgs) > 0 {
420 return nil, fmt.Errorf("there were package loading errors (and RunDespiteErrors is false)")
421 }
422 }
423
424 if len(pkgs) == 0 {
425 return nil, fmt.Errorf("no packages matched %s", patterns)
426 }
427 return pkgs, nil
428 }
429
430
431
432
433
434 func check(t Testing, gopath string, pass *analysis.Pass, diagnostics []analysis.Diagnostic, facts map[types.Object][]analysis.Fact) {
435 type key struct {
436 file string
437 line int
438 }
439
440 want := make(map[key][]expectation)
441
442
443 processComment := func(filename string, linenum int, text string) {
444 text = strings.TrimSpace(text)
445
446
447
448 if rest := strings.TrimPrefix(text, "want"); rest != text {
449 lineDelta, expects, err := parseExpectations(rest)
450 if err != nil {
451 t.Errorf("%s:%d: in 'want' comment: %s", filename, linenum, err)
452 return
453 }
454 if expects != nil {
455 want[key{filename, linenum + lineDelta}] = expects
456 }
457 }
458 }
459
460
461 for _, f := range pass.Files {
462 for _, cgroup := range f.Comments {
463 for _, c := range cgroup.List {
464
465 text := strings.TrimPrefix(c.Text, "//")
466 if text == c.Text {
467 text = strings.TrimPrefix(text, "/*")
468 text = strings.TrimSuffix(text, "*/")
469 }
470
471
472
473
474
475
476 if i := strings.Index(text, "// want"); i >= 0 {
477 text = text[i+len("// "):]
478 }
479
480
481
482
483
484 posn := pass.Fset.Position(c.Pos())
485 filename := sanitize(gopath, posn.Filename)
486 processComment(filename, posn.Line, text)
487 }
488 }
489 }
490
491
492
493 for _, filename := range pass.OtherFiles {
494 data, err := os.ReadFile(filename)
495 if err != nil {
496 t.Errorf("can't read '// want' comments from %s: %v", filename, err)
497 continue
498 }
499 filename := sanitize(gopath, filename)
500 linenum := 0
501 for _, line := range strings.Split(string(data), "\n") {
502 linenum++
503
504
505
506
507
508
509 if i := strings.Index(line, "// want"); i >= 0 {
510 line = line[i:]
511 }
512
513 if i := strings.Index(line, "//"); i >= 0 {
514 line = line[i+len("//"):]
515 processComment(filename, linenum, line)
516 }
517 }
518 }
519
520 checkMessage := func(posn token.Position, kind, name, message string) {
521 posn.Filename = sanitize(gopath, posn.Filename)
522 k := key{posn.Filename, posn.Line}
523 expects := want[k]
524 var unmatched []string
525 for i, exp := range expects {
526 if exp.kind == kind && exp.name == name {
527 if exp.rx.MatchString(message) {
528
529 expects[i] = expects[len(expects)-1]
530 expects = expects[:len(expects)-1]
531 want[k] = expects
532 return
533 }
534 unmatched = append(unmatched, fmt.Sprintf("%#q", exp.rx))
535 }
536 }
537 if unmatched == nil {
538 t.Errorf("%v: unexpected %s: %v", posn, kind, message)
539 } else {
540 t.Errorf("%v: %s %q does not match pattern %s",
541 posn, kind, message, strings.Join(unmatched, " or "))
542 }
543 }
544
545
546 for _, f := range diagnostics {
547
548 posn := pass.Fset.Position(f.Pos)
549 checkMessage(posn, "diagnostic", "", f.Message)
550 }
551
552
553
554
555
556
557 var objects []types.Object
558 for obj := range facts {
559 objects = append(objects, obj)
560 }
561 sort.Slice(objects, func(i, j int) bool {
562
563 ip, jp := objects[i] == nil, objects[j] == nil
564 if ip != jp {
565 return ip && !jp
566 }
567 return objects[i].Pos() < objects[j].Pos()
568 })
569 for _, obj := range objects {
570 var posn token.Position
571 var name string
572 if obj != nil {
573
574 name = obj.Name()
575 posn = pass.Fset.Position(obj.Pos())
576 } else {
577
578 name = "package"
579 posn = pass.Fset.Position(pass.Files[0].Pos())
580 posn.Line = 1
581 }
582
583 for _, fact := range facts[obj] {
584 checkMessage(posn, "fact", name, fmt.Sprint(fact))
585 }
586 }
587
588
589
590
591
592
593
594
595 var surplus []string
596 for key, expects := range want {
597 for _, exp := range expects {
598 err := fmt.Sprintf("%s:%d: no %s was reported matching %#q", key.file, key.line, exp.kind, exp.rx)
599 surplus = append(surplus, err)
600 }
601 }
602 sort.Strings(surplus)
603 for _, err := range surplus {
604 t.Errorf("%s", err)
605 }
606 }
607
608 type expectation struct {
609 kind string
610 name string
611 rx *regexp.Regexp
612 }
613
614 func (ex expectation) String() string {
615 return fmt.Sprintf("%s %s:%q", ex.kind, ex.name, ex.rx)
616 }
617
618
619
620
621 func parseExpectations(text string) (lineDelta int, expects []expectation, err error) {
622 var scanErr string
623 sc := new(scanner.Scanner).Init(strings.NewReader(text))
624 sc.Error = func(s *scanner.Scanner, msg string) {
625 scanErr = msg
626 }
627 sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanInts
628
629 scanRegexp := func(tok rune) (*regexp.Regexp, error) {
630 if tok != scanner.String && tok != scanner.RawString {
631 return nil, fmt.Errorf("got %s, want regular expression",
632 scanner.TokenString(tok))
633 }
634 pattern, _ := strconv.Unquote(sc.TokenText())
635 return regexp.Compile(pattern)
636 }
637
638 for {
639 tok := sc.Scan()
640 switch tok {
641 case '+':
642 tok = sc.Scan()
643 if tok != scanner.Int {
644 return 0, nil, fmt.Errorf("got +%s, want +Int", scanner.TokenString(tok))
645 }
646 lineDelta, _ = strconv.Atoi(sc.TokenText())
647 case scanner.String, scanner.RawString:
648 rx, err := scanRegexp(tok)
649 if err != nil {
650 return 0, nil, err
651 }
652 expects = append(expects, expectation{"diagnostic", "", rx})
653
654 case scanner.Ident:
655 name := sc.TokenText()
656 tok = sc.Scan()
657 if tok != ':' {
658 return 0, nil, fmt.Errorf("got %s after %s, want ':'",
659 scanner.TokenString(tok), name)
660 }
661 tok = sc.Scan()
662 rx, err := scanRegexp(tok)
663 if err != nil {
664 return 0, nil, err
665 }
666 expects = append(expects, expectation{"fact", name, rx})
667
668 case scanner.EOF:
669 if scanErr != "" {
670 return 0, nil, fmt.Errorf("%s", scanErr)
671 }
672 return lineDelta, expects, nil
673
674 default:
675 return 0, nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok))
676 }
677 }
678 }
679
680
681
682 func sanitize(gopath, filename string) string {
683 prefix := gopath + string(os.PathSeparator) + "src" + string(os.PathSeparator)
684 return filepath.ToSlash(strings.TrimPrefix(filename, prefix))
685 }
686
View as plain text