honnef.co/go/tools@v0.5.0-0.dev.0.20240520180541-dcae280a5e87/analysis/lint/testutil/check.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 // This file is a modified copy of x/tools/go/analysis/analysistest/analysistest.go 6 7 package testutil 8 9 import ( 10 "bytes" 11 "fmt" 12 "go/format" 13 "go/token" 14 "os" 15 "path/filepath" 16 "regexp" 17 "sort" 18 "strings" 19 "testing" 20 21 "honnef.co/go/tools/internal/diff/myers" 22 "honnef.co/go/tools/lintcmd/runner" 23 24 "golang.org/x/tools/go/expect" 25 "golang.org/x/tools/txtar" 26 ) 27 28 func CheckSuggestedFixes(t *testing.T, diagnostics []runner.Diagnostic) { 29 // Process each result (package) separately, matching up the suggested 30 // fixes into a diff, which we will compare to the .golden file. We have 31 // to do this per-result in case a file appears in two packages, such as in 32 // packages with tests, where mypkg/a.go will appear in both mypkg and 33 // mypkg.test. In that case, the analyzer may suggest the same set of 34 // changes to a.go for each package. If we merge all the results, those 35 // changes get doubly applied, which will cause conflicts or mismatches. 36 // Validating the results separately means as long as the two analyses 37 // don't produce conflicting suggestions for a single file, everything 38 // should match up. 39 // file -> message -> edits 40 fileEdits := make(map[string]map[string][]runner.TextEdit) 41 fileContents := make(map[string][]byte) 42 43 // Validate edits, prepare the fileEdits map and read the file contents. 44 for _, diag := range diagnostics { 45 for _, sf := range diag.SuggestedFixes { 46 for _, edit := range sf.TextEdits { 47 // Validate the edit. 48 if edit.Position.Offset > edit.End.Offset { 49 t.Errorf( 50 "diagnostic for analysis %v contains Suggested Fix with malformed edit: pos (%v) > end (%v)", 51 diag.Category, edit.Position.Offset, edit.End.Offset) 52 continue 53 } 54 if edit.Position.Filename != edit.End.Filename { 55 t.Errorf( 56 "diagnostic for analysis %v contains Suggested Fix with malformed edit spanning files %v and %v", 57 diag.Category, edit.Position.Filename, edit.End.Filename) 58 continue 59 } 60 if _, ok := fileContents[edit.Position.Filename]; !ok { 61 contents, err := os.ReadFile(edit.Position.Filename) 62 if err != nil { 63 t.Errorf("error reading %s: %v", edit.Position.Filename, err) 64 } 65 fileContents[edit.Position.Filename] = contents 66 } 67 68 if _, ok := fileEdits[edit.Position.Filename]; !ok { 69 fileEdits[edit.Position.Filename] = make(map[string][]runner.TextEdit) 70 } 71 fileEdits[edit.Position.Filename][sf.Message] = append(fileEdits[edit.Position.Filename][sf.Message], edit) 72 } 73 } 74 } 75 76 for file, fixes := range fileEdits { 77 // Get the original file contents. 78 orig, ok := fileContents[file] 79 if !ok { 80 t.Errorf("could not find file contents for %s", file) 81 continue 82 } 83 84 // Get the golden file and read the contents. 85 ar, err := txtar.ParseFile(file + ".golden") 86 if err != nil { 87 t.Errorf("error reading %s.golden: %v", file, err) 88 continue 89 } 90 91 if len(ar.Files) > 0 { 92 // one virtual file per kind of suggested fix 93 94 if len(ar.Comment) != 0 { 95 // we allow either just the comment, or just virtual 96 // files, not both. it is not clear how "both" should 97 // behave. 98 t.Errorf("%s.golden has leading comment; we don't know what to do with it", file) 99 continue 100 } 101 102 var sfs []string 103 for sf := range fixes { 104 sfs = append(sfs, sf) 105 } 106 sort.Slice(sfs, func(i, j int) bool { 107 return sfs[i] < sfs[j] 108 }) 109 for _, sf := range sfs { 110 edits := fixes[sf] 111 found := false 112 for _, vf := range ar.Files { 113 if vf.Name == sf { 114 found = true 115 out := applyEdits(orig, edits) 116 // the file may contain multiple trailing 117 // newlines if the user places empty lines 118 // between files in the archive. normalize 119 // this to a single newline. 120 want := string(bytes.TrimRight(vf.Data, "\n")) + "\n" 121 formatted, err := format.Source([]byte(out)) 122 if err != nil { 123 t.Errorf("%s: error formatting edited source: %v\n%s", file, err, out) 124 continue 125 } 126 if want != string(formatted) { 127 d := myers.ComputeEdits(want, string(formatted)) 128 diff := "" 129 for _, op := range d { 130 diff += op.String() 131 } 132 t.Errorf("suggested fixes failed for %s[%s]:\n%s", file, sf, diff) 133 } 134 break 135 } 136 } 137 if !found { 138 t.Errorf("no section for suggested fix %q in %s.golden", sf, file) 139 } 140 } 141 } else { 142 // all suggested fixes are represented by a single file 143 144 var catchallEdits []runner.TextEdit 145 for _, edits := range fixes { 146 catchallEdits = append(catchallEdits, edits...) 147 } 148 149 out := applyEdits(orig, catchallEdits) 150 want := string(ar.Comment) 151 152 formatted, err := format.Source([]byte(out)) 153 if err != nil { 154 t.Errorf("%s: error formatting resulting source: %v\n%s", file, err, out) 155 continue 156 } 157 if want != string(formatted) { 158 d := myers.ComputeEdits(want, string(formatted)) 159 diff := "" 160 for _, op := range d { 161 diff += op.String() 162 } 163 t.Errorf("suggested fixes failed for %s:\n%s", file, diff) 164 } 165 } 166 } 167 } 168 169 func Check(t *testing.T, gopath string, files []string, diagnostics []runner.Diagnostic, facts []runner.TestFact) { 170 relativePath := func(path string) string { 171 cwd, err := os.Getwd() 172 if err != nil { 173 return path 174 } 175 rel, err := filepath.Rel(cwd, path) 176 if err != nil { 177 return path 178 } 179 return rel 180 } 181 182 type key struct { 183 file string 184 line int 185 } 186 187 // the 'files' argument contains a list of all files that were part of the tested package 188 want := make(map[key][]*expect.Note) 189 190 fset := token.NewFileSet() 191 seen := map[string]struct{}{} 192 for _, file := range files { 193 seen[file] = struct{}{} 194 195 notes, err := expect.Parse(fset, file, nil) 196 if err != nil { 197 t.Fatal(err) 198 } 199 for _, note := range notes { 200 k := key{ 201 file: file, 202 line: fset.PositionFor(note.Pos, false).Line, 203 } 204 want[k] = append(want[k], note) 205 } 206 } 207 208 for _, diag := range diagnostics { 209 file := diag.Position.Filename 210 if _, ok := seen[file]; !ok { 211 t.Errorf("got diagnostic in file %q, but that file isn't part of the checked package", relativePath(file)) 212 return 213 } 214 } 215 216 check := func(posn token.Position, message string, kind string, argIdx int, identifier string) { 217 k := key{posn.Filename, posn.Line} 218 expects := want[k] 219 var unmatched []string 220 for i, exp := range expects { 221 if exp.Name == kind { 222 if kind == "fact" && exp.Args[0] != expect.Identifier(identifier) { 223 continue 224 } 225 matched := false 226 switch arg := exp.Args[argIdx].(type) { 227 case string: 228 matched = strings.Contains(message, arg) 229 case *regexp.Regexp: 230 matched = arg.MatchString(message) 231 default: 232 t.Fatalf("unexpected argument type %T", arg) 233 } 234 if matched { 235 // matched: remove the expectation. 236 expects[i] = expects[len(expects)-1] 237 expects = expects[:len(expects)-1] 238 want[k] = expects 239 return 240 } 241 unmatched = append(unmatched, fmt.Sprintf("%q", exp.Args[argIdx])) 242 } 243 } 244 if unmatched == nil { 245 posn.Filename = relativePath(posn.Filename) 246 t.Errorf("%v: unexpected diag: %v", posn, message) 247 } else { 248 posn.Filename = relativePath(posn.Filename) 249 t.Errorf("%v: diag %q does not match pattern %s", 250 posn, message, strings.Join(unmatched, " or ")) 251 } 252 } 253 254 checkDiag := func(posn token.Position, message string) { 255 check(posn, message, "diag", 0, "") 256 } 257 258 checkFact := func(posn token.Position, name, message string) { 259 check(posn, message, "fact", 1, name) 260 } 261 262 // Check the diagnostics match expectations. 263 for _, f := range diagnostics { 264 // TODO(matloob): Support ranges in analysistest. 265 posn := f.Position 266 checkDiag(posn, f.Message) 267 } 268 269 // Check the facts match expectations. 270 for _, fact := range facts { 271 name := fact.ObjectName 272 posn := fact.Position 273 if name == "" { 274 name = "package" 275 posn.Line = 1 276 } 277 278 checkFact(posn, name, fact.FactString) 279 } 280 281 // Reject surplus expectations. 282 // 283 // Sometimes an Analyzer reports two similar diagnostics on a 284 // line with only one expectation. The reader may be confused by 285 // the error message. 286 // TODO(adonovan): print a better error: 287 // "got 2 diagnostics here; each one needs its own expectation". 288 var surplus []string 289 for key, expects := range want { 290 for _, exp := range expects { 291 surplus = append(surplus, fmt.Sprintf("%s:%d: no %s was reported matching %q", relativePath(key.file), key.line, exp.Name, exp.Args)) 292 } 293 } 294 sort.Strings(surplus) 295 for _, err := range surplus { 296 t.Errorf("%s", err) 297 } 298 } 299 300 func applyEdits(src []byte, edits []runner.TextEdit) []byte { 301 // This function isn't efficient, but it doesn't have to be. 302 303 edits = append([]runner.TextEdit(nil), edits...) 304 sort.Slice(edits, func(i, j int) bool { 305 if edits[i].Position.Offset < edits[j].Position.Offset { 306 return true 307 } 308 if edits[i].Position.Offset == edits[j].Position.Offset { 309 return edits[i].End.Offset < edits[j].End.Offset 310 } 311 return false 312 }) 313 314 out := append([]byte(nil), src...) 315 offset := 0 316 for _, edit := range edits { 317 start := edit.Position.Offset + offset 318 end := edit.End.Offset + offset 319 if edit.End == (token.Position{}) { 320 end = -1 321 } 322 if len(edit.NewText) == 0 { 323 // pure deletion 324 copy(out[start:], out[end:]) 325 out = out[:len(out)-(end-start)] 326 offset -= end - start 327 } else if end == -1 || end == start { 328 // pure insertion 329 tmp := make([]byte, len(out)+len(edit.NewText)) 330 copy(tmp, out[:start]) 331 copy(tmp[start:], edit.NewText) 332 copy(tmp[start+len(edit.NewText):], out[start:]) 333 offset += len(edit.NewText) 334 out = tmp 335 } else if end-start == len(edit.NewText) { 336 // exact replacement 337 copy(out[start:], edit.NewText) 338 } else if end-start < len(edit.NewText) { 339 // replace with longer string 340 growth := len(edit.NewText) - (end - start) 341 tmp := make([]byte, len(out)+growth) 342 copy(tmp, out[:start]) 343 copy(tmp[start:], edit.NewText) 344 copy(tmp[start+len(edit.NewText):], out[end:]) 345 offset += growth 346 out = tmp 347 } else if end-start > len(edit.NewText) { 348 // replace with shorter string 349 shrinkage := (end - start) - len(edit.NewText) 350 351 copy(out[start:], edit.NewText) 352 copy(out[start+len(edit.NewText):], out[end:]) 353 out = out[:len(out)-shrinkage] 354 offset -= shrinkage 355 } 356 } 357 358 // Debug code 359 if false { 360 fmt.Println("input:") 361 fmt.Println(string(src)) 362 fmt.Println() 363 fmt.Println("edits:") 364 for _, edit := range edits { 365 fmt.Printf("%d:%d - %d:%d <- %q\n", edit.Position.Line, edit.Position.Column, edit.End.Line, edit.End.Column, edit.NewText) 366 } 367 fmt.Println("output:") 368 fmt.Println(string(out)) 369 panic("") 370 } 371 372 return out 373 }