blob: 1f9d534017b1279b1600437155668cb73cdcbb07 [file] [log] [blame]
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package checker_test
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/token"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"testing"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/checker"
"golang.org/x/tools/go/analysis/multichecker"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/go/packages"
"golang.org/x/tools/internal/diff"
"golang.org/x/tools/internal/expect"
"golang.org/x/tools/internal/testenv"
"golang.org/x/tools/internal/testfiles"
"golang.org/x/tools/txtar"
)
func TestMain(m *testing.M) {
// If the CHECKER_TEST_CHILD environment variable is set,
// this process should behave like a multichecker.
// Analyzers are selected by flags.
if _, ok := os.LookupEnv("CHECKER_TEST_CHILD"); ok {
multichecker.Main(
markerAnalyzer,
noendAnalyzer,
renameAnalyzer,
)
panic("unreachable")
}
// ordinary test
flag.Parse()
os.Exit(m.Run())
}
const (
exitCodeSuccess = 0 // success (no diagnostics, or successful -fix)
exitCodeFailed = 1 // analysis failed to run
exitCodeDiagnostics = 3 // diagnostics were reported (and no -fix)
)
// TestReportInvalidDiagnostic tests that a call to pass.Report with
// certain kind of invalid diagnostic (e.g. conflicting fixes)
// promptly results in a panic.
func TestReportInvalidDiagnostic(t *testing.T) {
testenv.NeedsGoPackages(t)
// Load the errors package.
cfg := &packages.Config{Mode: packages.LoadAllSyntax}
initial, err := packages.Load(cfg, "errors")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
want string
diag func(pos token.Pos) analysis.Diagnostic
}{
// Diagnostic has two alternative fixes with the same Message.
{
"duplicate message",
`analyzer "a" suggests two fixes with same Message \(fix\)`,
func(pos token.Pos) analysis.Diagnostic {
return analysis.Diagnostic{
Pos: pos,
Message: "oops",
SuggestedFixes: []analysis.SuggestedFix{
{Message: "fix"},
{Message: "fix"},
},
}
},
},
// TextEdit has invalid Pos.
{
"bad Pos",
`analyzer "a" suggests invalid fix .*: no token.File for TextEdit.Pos .0.`,
func(pos token.Pos) analysis.Diagnostic {
return analysis.Diagnostic{
Pos: pos,
Message: "oops",
SuggestedFixes: []analysis.SuggestedFix{
{
Message: "fix",
TextEdits: []analysis.TextEdit{{}},
},
},
}
},
},
// TextEdit has invalid End.
{
"End < Pos",
`analyzer "a" suggests invalid fix .*: TextEdit.Pos .* > TextEdit.End .*`,
func(pos token.Pos) analysis.Diagnostic {
return analysis.Diagnostic{
Pos: pos,
Message: "oops",
SuggestedFixes: []analysis.SuggestedFix{
{
Message: "fix",
TextEdits: []analysis.TextEdit{{
Pos: pos + 2,
End: pos,
}},
},
},
}
},
},
// Two TextEdits overlap.
{
"overlapping edits",
`analyzer "a" suggests invalid fix .*: overlapping edits to .*errors.go \(1:1-1:3 and 1:2-1:4\)`,
func(pos token.Pos) analysis.Diagnostic {
return analysis.Diagnostic{
Pos: pos,
Message: "oops",
SuggestedFixes: []analysis.SuggestedFix{
{
Message: "fix",
TextEdits: []analysis.TextEdit{
{Pos: pos, End: pos + 2},
{Pos: pos + 1, End: pos + 3},
},
},
},
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
reached := false
a := &analysis.Analyzer{Name: "a", Doc: "doc", Run: func(pass *analysis.Pass) (any, error) {
reached = true
panics(t, test.want, func() {
pos := pass.Files[0].FileStart
pass.Report(test.diag(pos))
})
return nil, nil
}}
if _, err := checker.Analyze([]*analysis.Analyzer{a}, initial, &checker.Options{}); err != nil {
t.Fatalf("Analyze failed: %v", err)
}
if !reached {
t.Error("analyzer was never invoked")
}
})
}
}
// TestScript runs script-driven tests in testdata/*.txt.
// Each file is a txtar archive, expanded to a temporary directory.
//
// The comment section of the archive is a script, with the following
// commands:
//
// # comment
// ignored
// blank line
// ignored
// skip k=v...
// Skip the test if any k=v string is a substring of the string
// "GOOS=darwin GOARCH=arm64" appropriate to the current build.
// checker args...
// Run the checker command with the specified space-separated
// arguments; this fork+execs the [TestMain] function above.
// If the archive has a "stdout" section, its contents must
// match the stdout output of the checker command.
// Do NOT use this for testing -diff: tests should not
// rely on the particulars of the diff algorithm.
// exit int
// Assert that previous checker command had this exit code.
// stderr regexp
// Assert that stderr output from previous checker run matches this pattern.
//
// The script must include at least one 'checker' command.
func TestScript(t *testing.T) {
testenv.NeedsExec(t)
testenv.NeedsGoPackages(t)
txtfiles, err := filepath.Glob("testdata/*.txt")
if err != nil {
t.Fatal(err)
}
for _, txtfile := range txtfiles {
t.Run(txtfile, func(t *testing.T) {
t.Parallel()
// Expand archive into tmp tree.
ar, err := txtar.ParseFile(txtfile)
if err != nil {
t.Fatal(err)
}
fs, err := txtar.FS(ar)
if err != nil {
t.Fatal(err)
}
dir := testfiles.CopyToTmp(t, fs)
// Parse txtar comment as a script.
const noExitCode = -999
var (
// state variables operated on by script
lastExitCode = noExitCode
lastStderr string
)
for i, line := range strings.Split(string(ar.Comment), "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' {
continue // skip blanks and comments
}
command, rest, _ := strings.Cut(line, " ")
prefix := fmt.Sprintf("%s:%d: %s", txtfile, i+1, command) // for error messages
switch command {
case "checker":
cmd := exec.Command(os.Args[0], strings.Fields(rest)...)
cmd.Dir = dir
cmd.Stdout = new(strings.Builder)
cmd.Stderr = new(strings.Builder)
cmd.Env = append(os.Environ(), "CHECKER_TEST_CHILD=1", "GOPROXY=off")
if err := cmd.Run(); err != nil {
if err, ok := err.(*exec.ExitError); ok {
lastExitCode = err.ExitCode()
// fall through
} else {
t.Fatalf("%s: failed to execute checker: %v (%s)", prefix, err, cmd)
}
} else {
lastExitCode = 0 // success
}
// Eliminate nondeterministic strings from the output.
clean := func(x any) string {
s := fmt.Sprint(x)
pwd, _ := os.Getwd()
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
// Work around checker's packages.Load failing to
// set Config.Dir to dir, causing the filenames
// of loaded packages not to be a subdir of dir.
s = strings.ReplaceAll(s, realDir, dir)
}
s = strings.ReplaceAll(s, dir, string(os.PathSeparator)+"TMP")
s = strings.ReplaceAll(s, pwd, string(os.PathSeparator)+"PWD")
s = strings.ReplaceAll(s, cmd.Path, filepath.Base(cmd.Path))
return s
}
lastStderr = clean(cmd.Stderr)
stdout := clean(cmd.Stdout)
// Detect bad markers out of band:
// though they cause a non-zero exit,
// that may be expected.
if strings.Contains(lastStderr, badMarker) {
t.Errorf("marker analyzer encountered errors; stderr=%s", lastStderr)
}
// debugging
if false {
t.Logf("%s: $ %s\nstdout:\n%s\nstderr:\n%s", prefix, clean(cmd), stdout, lastStderr)
}
// Keep error reporting logic below consistent with
// applyDiffsAndCompare in ../../analysistest/analysistest.go!
unified := func(xlabel, ylabel string, x, y []byte) string {
x = append(slices.Clip(bytes.TrimSpace(x)), '\n')
y = append(slices.Clip(bytes.TrimSpace(y)), '\n')
return diff.Unified(xlabel, ylabel, string(x), string(y))
}
// Check stdout, if there's a section of that name.
//
// Do not use this for testing -diff! It exposes tests to the
// internals of our (often suboptimal) diff algorithm.
// Instead, use the want/ mechanism.
if f := section(ar, "stdout"); f != nil {
got, want := []byte(stdout), f.Data
if diff := unified("got", "want", got, want); diff != "" {
t.Errorf("%s: unexpected stdout: -- got --\n%s-- want --\n%s-- diff --\n%s",
prefix,
got, want, diff)
}
}
for _, f := range ar.Files {
// For each file named want/X, assert that the
// current content of X now equals want/X.
if filename, ok := strings.CutPrefix(f.Name, "want/"); ok {
fixed, err := os.ReadFile(filepath.Join(dir, filename))
if err != nil {
t.Errorf("reading %s: %v", filename, err)
continue
}
var original []byte
if f := section(ar, filename); f != nil {
original = f.Data
}
want := f.Data
if diff := unified(filename+" (fixed)", filename+" (want)", fixed, want); diff != "" {
t.Errorf("%s: unexpected %s content:\n"+
"-- original --\n%s\n"+
"-- fixed --\n%s\n"+
"-- want --\n%s\n"+
"-- diff original fixed --\n%s\n"+
"-- diff fixed want --\n%s",
prefix, filename,
original,
fixed,
want,
unified(filename+" (original)", filename+" (fixed)", original, fixed),
diff)
}
}
}
case "skip":
config := fmt.Sprintf("GOOS=%s GOARCH=%s", runtime.GOOS, runtime.GOARCH)
for _, word := range strings.Fields(rest) {
if strings.Contains(config, word) {
t.Skip(word)
}
}
case "exit":
if lastExitCode == noExitCode {
t.Fatalf("%s: no prior 'checker' command", prefix)
}
var want int
if _, err := fmt.Sscanf(rest, "%d", &want); err != nil {
t.Fatalf("%s: requires one numeric operand", prefix)
}
if want != lastExitCode {
// plan9 ExitCode() currently only returns 0 for success or 1 for failure
if !(runtime.GOOS == "plan9" && want != exitCodeSuccess && lastExitCode != exitCodeSuccess) {
t.Errorf("%s: exit code was %d, want %d", prefix, lastExitCode, want)
}
}
case "stderr":
if lastExitCode == noExitCode {
t.Fatalf("%s: no prior 'checker' command", prefix)
}
if matched, err := regexp.MatchString(rest, lastStderr); err != nil {
t.Fatalf("%s: invalid regexp: %v", prefix, err)
} else if !matched {
t.Errorf("%s: output didn't match pattern %q:\n%s", prefix, rest, lastStderr)
}
default:
t.Errorf("%s: unknown command", prefix)
}
}
if lastExitCode == noExitCode {
t.Errorf("test script contains no 'checker' command")
}
})
}
}
const badMarker = "[bad marker]"
// The marker analyzer generates fixes from @marker annotations in the
// source. Each marker is of the form:
//
// @message("pattern", "replacement)
//
// The "message" is used for both the Diagnostic.Message and
// SuggestedFix.Message field. Multiple markers with the same
// message form a single diagnostic and fix with a list of textedits.
//
// The "pattern" is a regular expression that must match on the
// current line (though it may extend beyond if the pattern starts
// with "(?s)"), and whose extent forms the TextEdit.{Pos,End}
// deletion. If the pattern contains one subgroup, its range will be
// used; this allows contextual matching.
//
// The "replacement" is a literal string that forms the
// TextEdit.NewText.
//
// Fixes are applied in the order they are first mentioned in the
// source.
var markerAnalyzer = &analysis.Analyzer{
Name: "marker",
Doc: "doc",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: func(pass *analysis.Pass) (_ any, err error) {
// Errors returned by this analyzer cause the
// checker command to exit non-zero, but that
// may be the expected outcome for other reasons
// (e.g. there were diagnostics).
//
// So, we report these errors out of band by logging
// them with a special badMarker string that the
// TestScript harness looks for, to ensure that the
// test fails in that case.
defer func() {
if err != nil {
log.Printf("%s: %v", badMarker, err)
}
}()
// Parse all notes in the files.
var keys []string
edits := make(map[string][]analysis.TextEdit)
for _, file := range pass.Files {
tokFile := pass.Fset.File(file.FileStart)
content, err := pass.ReadFile(tokFile.Name())
if err != nil {
return nil, err
}
notes, err := expect.ExtractGo(pass.Fset, file)
if err != nil {
return nil, err
}
for _, note := range notes {
edit, err := markerEdit(tokFile, content, note)
if err != nil {
return nil, fmt.Errorf("%s: %v", tokFile.Position(note.Pos), err)
}
// Preserve note order as it determines fix order.
if edits[note.Name] == nil {
keys = append(keys, note.Name)
}
edits[note.Name] = append(edits[note.Name], edit)
}
}
// Report each fix in its own Diagnostic.
for _, key := range keys {
edits := edits[key]
// debugging
if false {
log.Printf("%s: marker: @%s: %+v", pass.Fset.Position(edits[0].Pos), key, edits)
}
pass.Report(analysis.Diagnostic{
Pos: edits[0].Pos,
End: edits[0].Pos,
Message: key,
SuggestedFixes: []analysis.SuggestedFix{{
Message: key,
TextEdits: edits,
}},
})
}
return nil, nil
},
}
// markerEdit returns the TextEdit denoted by note.
func markerEdit(tokFile *token.File, content []byte, note *expect.Note) (analysis.TextEdit, error) {
if len(note.Args) != 2 {
return analysis.TextEdit{}, fmt.Errorf("got %d args, want @%s(pattern, replacement)", len(note.Args), note.Name)
}
pattern, ok := note.Args[0].(string)
if !ok {
return analysis.TextEdit{}, fmt.Errorf("got %T for pattern, want string", note.Args[0])
}
rx, err := regexp.Compile(pattern)
if err != nil {
return analysis.TextEdit{}, fmt.Errorf("invalid pattern regexp: %v", err)
}
// Match the pattern against the current line.
lineStart := tokFile.LineStart(tokFile.Position(note.Pos).Line)
lineStartOff := tokFile.Offset(lineStart)
lineEndOff := tokFile.Offset(note.Pos)
matches := rx.FindSubmatchIndex(content[lineStartOff:])
if len(matches) == 0 {
return analysis.TextEdit{}, fmt.Errorf("no match for regexp %q", rx)
}
var start, end int // line-relative offset
switch len(matches) {
case 2:
// no subgroups: return the range of the regexp expression
start, end = matches[0], matches[1]
case 4:
// one subgroup: return its range
start, end = matches[2], matches[3]
default:
return analysis.TextEdit{}, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", rx, len(matches)/2-1)
}
if start > lineEndOff-lineStartOff {
// The start of the match must be between the start of the line and the
// marker position (inclusive).
return analysis.TextEdit{}, fmt.Errorf("no matching range found starting on the current line")
}
replacement, ok := note.Args[1].(string)
if !ok {
return analysis.TextEdit{}, fmt.Errorf("second argument must be pattern, got %T", note.Args[1])
}
// debugging: show matched portion
if false {
log.Printf("%s: %s: r%q (%q) -> %q",
tokFile.Position(note.Pos),
note.Name,
pattern,
content[lineStartOff+start:lineStartOff+end],
replacement)
}
return analysis.TextEdit{
Pos: lineStart + token.Pos(start),
End: lineStart + token.Pos(end),
NewText: []byte(replacement),
}, nil
}
var renameAnalyzer = &analysis.Analyzer{
Name: "rename",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Doc: "renames symbols named bar to baz",
RunDespiteErrors: true,
Run: func(pass *analysis.Pass) (any, error) {
const (
from = "bar"
to = "baz"
)
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
nodeFilter := []ast.Node{(*ast.Ident)(nil)}
inspect.Preorder(nodeFilter, func(n ast.Node) {
ident := n.(*ast.Ident)
if ident.Name == from {
msg := fmt.Sprintf("renaming %q to %q", from, to)
pass.Report(analysis.Diagnostic{
Pos: ident.Pos(),
End: ident.End(),
Message: msg,
SuggestedFixes: []analysis.SuggestedFix{{
Message: msg,
TextEdits: []analysis.TextEdit{{
Pos: ident.Pos(),
End: ident.End(),
NewText: []byte(to),
}},
}},
})
}
})
return nil, nil
},
}
var noendAnalyzer = &analysis.Analyzer{
Name: "noend",
Doc: "inserts /*hello*/ before first decl",
Run: func(pass *analysis.Pass) (any, error) {
decl := pass.Files[0].Decls[0]
pass.Report(analysis.Diagnostic{
Pos: decl.Pos(),
End: token.NoPos,
Message: "say hello",
SuggestedFixes: []analysis.SuggestedFix{{
Message: "say hello",
TextEdits: []analysis.TextEdit{{
Pos: decl.Pos(),
End: token.NoPos,
NewText: []byte("/*hello*/"),
}},
}},
})
return nil, nil
},
}
// panics asserts that f() panics with with a value whose printed form matches the regexp want.
func panics(t *testing.T, want string, f func()) {
defer func() {
if x := recover(); x == nil {
t.Errorf("function returned normally, wanted panic")
} else if m, err := regexp.MatchString(want, fmt.Sprint(x)); err != nil {
t.Errorf("panics: invalid regexp %q", want)
} else if !m {
t.Errorf("function panicked with value %q, want match for %q", x, want)
}
}()
f()
}
// section returns the named archive section, or nil.
func section(ar *txtar.Archive, name string) *txtar.File {
for i, f := range ar.Files {
if f.Name == name {
return &ar.Files[i]
}
}
return nil
}