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  }