You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
6.6 KiB
227 lines
6.6 KiB
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// Package subtestnames checks that t.Run subtest names don't contain characters
|
|
// that require quoting or escaping when re-running via "go test -run".
|
|
//
|
|
// Go's testing package rewrites subtest names: spaces become underscores,
|
|
// non-printable characters get escaped, and regex metacharacters require
|
|
// escaping in -run patterns. This makes it hard to re-run specific subtests
|
|
// or search for them in code.
|
|
//
|
|
// This analyzer flags:
|
|
// - Direct t.Run calls with string literal names containing bad characters
|
|
// - t.Run calls using tt.name (or similar) where tt ranges over a slice/map
|
|
// of test cases with string literal names containing bad characters
|
|
package subtestnames
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/token"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"golang.org/x/tools/go/analysis"
|
|
"golang.org/x/tools/go/analysis/passes/inspect"
|
|
"golang.org/x/tools/go/ast/inspector"
|
|
)
|
|
|
|
// Analyzer checks that t.Run subtest names are clean for use with "go test -run".
|
|
var Analyzer = &analysis.Analyzer{
|
|
Name: "subtestnames",
|
|
Doc: "check that t.Run subtest names don't require quoting when re-running via go test -run",
|
|
Requires: []*analysis.Analyzer{inspect.Analyzer},
|
|
Run: run,
|
|
}
|
|
|
|
// badChars are characters that are problematic in subtest names.
|
|
// Spaces are rewritten to underscores by testing.rewrite, and regex
|
|
// metacharacters require escaping in -run patterns.
|
|
const badChars = " \t\n\r^$.*+?()[]{}|\\'\"#"
|
|
|
|
// hasBadChar reports whether s contains any character that would be
|
|
// problematic in a subtest name.
|
|
func hasBadChar(s string) bool {
|
|
return strings.ContainsAny(s, badChars) || strings.ContainsFunc(s, func(r rune) bool {
|
|
return !unicode.IsPrint(r)
|
|
})
|
|
}
|
|
|
|
// hasBadDash reports whether s starts or ends with a dash, which is
|
|
// problematic in subtest names because "go test -run" may interpret a
|
|
// leading dash as a flag, and trailing dashes are confusing.
|
|
func hasBadDash(s string) bool {
|
|
return strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-")
|
|
}
|
|
|
|
func run(pass *analysis.Pass) (any, error) {
|
|
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
|
|
|
|
// Build a stack of enclosing nodes so we can find the RangeStmt
|
|
// enclosing a given t.Run call.
|
|
nodeFilter := []ast.Node{
|
|
(*ast.RangeStmt)(nil),
|
|
(*ast.CallExpr)(nil),
|
|
}
|
|
|
|
var rangeStack []*ast.RangeStmt
|
|
|
|
insp.Nodes(nodeFilter, func(n ast.Node, push bool) bool {
|
|
switch n := n.(type) {
|
|
case *ast.RangeStmt:
|
|
if push {
|
|
rangeStack = append(rangeStack, n)
|
|
} else {
|
|
rangeStack = rangeStack[:len(rangeStack)-1]
|
|
}
|
|
return true
|
|
case *ast.CallExpr:
|
|
if !push {
|
|
return true
|
|
}
|
|
checkCallExpr(pass, n, rangeStack)
|
|
return true
|
|
}
|
|
return true
|
|
})
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func checkCallExpr(pass *analysis.Pass, call *ast.CallExpr, rangeStack []*ast.RangeStmt) {
|
|
// Check if this is a t.Run(...) or b.Run(...) call.
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "Run" || len(call.Args) < 2 {
|
|
return
|
|
}
|
|
|
|
// Verify the receiver is *testing.T, *testing.B, or *testing.F.
|
|
if !isTestingTBF(pass, sel) {
|
|
return
|
|
}
|
|
|
|
nameArg := call.Args[0]
|
|
|
|
// Case 1: Direct string literal, e.g. t.Run("foo bar", ...)
|
|
if lit, ok := nameArg.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
|
val, err := strconv.Unquote(lit.Value)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if hasBadChar(val) {
|
|
pass.Reportf(lit.Pos(), "subtest name %s contains characters that require quoting in go test -run patterns", lit.Value)
|
|
} else if hasBadDash(val) {
|
|
pass.Reportf(lit.Pos(), "subtest name %s starts or ends with '-' which is problematic in go test -run patterns", lit.Value)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Case 2: Selector expression like tt.name, tc.name, etc.
|
|
// where tt is a range variable over a slice/map of test cases.
|
|
selExpr, ok := nameArg.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return
|
|
}
|
|
ident, ok := selExpr.X.(*ast.Ident)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Find the enclosing range statement where ident is the value variable.
|
|
for i := len(rangeStack) - 1; i >= 0; i-- {
|
|
rs := rangeStack[i]
|
|
valIdent, ok := rs.Value.(*ast.Ident)
|
|
if !ok || valIdent.Obj != ident.Obj {
|
|
continue
|
|
}
|
|
// Found the range statement. Check the source being iterated.
|
|
checkRangeSource(pass, rs.X, selExpr.Sel)
|
|
return
|
|
}
|
|
}
|
|
|
|
// isTestingTBF checks whether sel looks like a method call on *testing.T, *testing.B, or *testing.F.
|
|
func isTestingTBF(pass *analysis.Pass, sel *ast.SelectorExpr) bool {
|
|
typ := pass.TypesInfo.TypeOf(sel.X)
|
|
if typ != nil {
|
|
s := typ.String()
|
|
return s == "*testing.T" || s == "*testing.B" || s == "*testing.F"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkRangeSource examines the expression being ranged over and checks
|
|
// composite literal elements for bad subtest name fields.
|
|
func checkRangeSource(pass *analysis.Pass, rangeExpr ast.Expr, fieldName *ast.Ident) {
|
|
switch x := rangeExpr.(type) {
|
|
case *ast.Ident:
|
|
if x.Obj == nil {
|
|
return
|
|
}
|
|
switch decl := x.Obj.Decl.(type) {
|
|
case *ast.AssignStmt:
|
|
// e.g. tests := []struct{...}{...}
|
|
for _, rhs := range decl.Rhs {
|
|
checkCompositeLit(pass, rhs, fieldName)
|
|
}
|
|
case *ast.ValueSpec:
|
|
// e.g. var tests = []struct{...}{...}
|
|
for _, val := range decl.Values {
|
|
checkCompositeLit(pass, val, fieldName)
|
|
}
|
|
}
|
|
case *ast.CompositeLit:
|
|
checkCompositeLit(pass, x, fieldName)
|
|
}
|
|
}
|
|
|
|
// checkCompositeLit checks a composite literal (slice/map) for elements
|
|
// that have a field with a bad subtest name.
|
|
func checkCompositeLit(pass *analysis.Pass, expr ast.Expr, fieldName *ast.Ident) {
|
|
comp, ok := expr.(*ast.CompositeLit)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for _, elt := range comp.Elts {
|
|
// For map literals, check the value.
|
|
if kv, ok := elt.(*ast.KeyValueExpr); ok {
|
|
elt = kv.Value
|
|
}
|
|
checkStructLitField(pass, elt, fieldName)
|
|
}
|
|
}
|
|
|
|
// checkStructLitField checks a struct literal for a field with the given name
|
|
// that contains a bad subtest name string.
|
|
func checkStructLitField(pass *analysis.Pass, expr ast.Expr, fieldName *ast.Ident) {
|
|
comp, ok := expr.(*ast.CompositeLit)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for _, elt := range comp.Elts {
|
|
kv, ok := elt.(*ast.KeyValueExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key, ok := kv.Key.(*ast.Ident)
|
|
if !ok || key.Name != fieldName.Name {
|
|
continue
|
|
}
|
|
lit, ok := kv.Value.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
continue
|
|
}
|
|
val, err := strconv.Unquote(lit.Value)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if hasBadChar(val) {
|
|
pass.Reportf(lit.Pos(), "subtest name %s contains characters that require quoting in go test -run patterns", lit.Value)
|
|
} else if hasBadDash(val) {
|
|
pass.Reportf(lit.Pos(), "subtest name %s starts or ends with '-' which is problematic in go test -run patterns", lit.Value)
|
|
}
|
|
}
|
|
}
|
|
|