github.com/nya3jp/tast@v0.0.0-20230601000426-85c8e4d83a9b/src/go.chromium.org/tast/core/cmd/tast-lint/internal/check/declarations.go (about)

     1  // Copyright 2019 The ChromiumOS Authors
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package check
     6  
     7  import (
     8  	"go/ast"
     9  	"go/token"
    10  	"net/mail"
    11  	"regexp"
    12  	"strconv"
    13  	"strings"
    14  	"unicode"
    15  
    16  	"golang.org/x/tools/go/ast/astutil"
    17  
    18  	"go.chromium.org/tast/core/cmd/tast-lint/internal/git"
    19  )
    20  
    21  // Exposed here for unit tests.
    22  const (
    23  	notOnlyTopAddTestMsg = `testing.AddTest() should be the only top level statement of init()`
    24  	addTestArgLitMsg     = `testing.AddTest() should take &testing.Test{...} composite literal`
    25  
    26  	noDescMsg         = `Desc field should be filled to describe the registered entity`
    27  	nonLiteralDescMsg = `Desc should be string literal`
    28  	badDescMsg        = `Desc should be capitalized phrases without trailing punctuation, e.g. "Checks that foo is bar"`
    29  
    30  	noContactMsg          = `Contacts field should exist to list owners' email addresses`
    31  	nonLiteralContactsMsg = `Contacts field should be an array literal of syntactically valid email address`
    32  
    33  	noBugComponentMsg  = `BugComponent field should be specified`
    34  	nonBugComponentMsg = `BugComponent does not match 'b:<component_id>' or 'crbug:' syntax`
    35  
    36  	nonLiteralAttrMsg         = `Test Attr should be an array literal of string literals`
    37  	nonLiteralVarsMsg         = `Test Vars should be an array literal of string literals or constants, or append(array literal, ConstList...)`
    38  	nonLiteralSoftwareDepsMsg = `Test SoftwareDeps should be an array literal of string literals or constants, or append(array literal, ConstList...)`
    39  	nonLiteralParamsMsg       = `Test Params should be an array literal of Param struct literals`
    40  	nonLiteralParamNameMsg    = `Name of Param should be a string literal`
    41  
    42  	testRegistrationURL     = `https://chromium.googlesource.com/chromiumos/platform/tast/+/HEAD/docs/writing_tests.md#Test-registration`
    43  	testParamTestURL        = `https://chromium.googlesource.com/chromiumos/platform/tast/+/HEAD/docs/writing_tests.md#Parameterized-test-registration`
    44  	testRuntimeVariablesURL = `https://chromium.googlesource.com/chromiumos/platform/tast/+/HEAD/docs/writing_tests.md#Runtime-variables`
    45  )
    46  
    47  // TestDeclarations checks declarations of testing.Test structs.
    48  func TestDeclarations(fs *token.FileSet, f *ast.File, path git.CommitFile, fix bool) []*Issue {
    49  	filename := fs.Position(f.Package).Filename
    50  	if !isEntryFile(filename) {
    51  		return nil
    52  	}
    53  
    54  	var issues []*Issue
    55  	for _, decl := range f.Decls {
    56  		issues = append(issues, verifyInit(fs, decl, path, fix)...)
    57  	}
    58  	return issues
    59  }
    60  
    61  // FixtureDeclarations checks declarations of testing.Fixture structs.
    62  func FixtureDeclarations(fs *token.FileSet, f *ast.File, fix bool) []*Issue {
    63  	var issues []*Issue
    64  	for _, node := range f.Decls {
    65  		decl, ok := node.(*ast.FuncDecl)
    66  		if !ok || decl.Recv != nil || decl.Name.Name != "init" {
    67  			// Not an init() function declaration. Skip.
    68  			continue
    69  		}
    70  		for _, node := range decl.Body.List {
    71  			expr, ok := node.(*ast.ExprStmt)
    72  			if !ok {
    73  				continue
    74  			}
    75  			call, ok := expr.X.(*ast.CallExpr)
    76  			if !ok {
    77  				continue
    78  			}
    79  			if toQualifiedName(call.Fun) != "testing.AddFixture" {
    80  				continue
    81  			}
    82  			issues = append(issues, verifyAddFixtureCall(fs, call, fix)...)
    83  		}
    84  	}
    85  	return issues
    86  }
    87  
    88  // verifyInit checks init() function declared at node.
    89  // If the node is not init() function, returns nil.
    90  func verifyInit(fs *token.FileSet, node ast.Decl, path git.CommitFile, fix bool) []*Issue {
    91  	decl, ok := node.(*ast.FuncDecl)
    92  	if !ok || decl.Recv != nil || decl.Name.Name != "init" {
    93  		// Not an init() function declaration. Skip.
    94  		return nil
    95  	}
    96  
    97  	if len(decl.Body.List) == 1 {
    98  		if estmt, ok := decl.Body.List[0].(*ast.ExprStmt); ok && isTestingAddTestCall(estmt.X) {
    99  			// X's type is already verified in isTestingAddTestCall().
   100  			return verifyAddTestCall(fs, estmt.X.(*ast.CallExpr), path, fix)
   101  		}
   102  	}
   103  
   104  	var addTestNode ast.Node
   105  	ast.Walk(funcVisitor(func(n ast.Node) {
   106  		if addTestNode == nil && isTestingAddTestCall(n) {
   107  			addTestNode = n
   108  		}
   109  	}), node)
   110  
   111  	if addTestNode != nil {
   112  		return []*Issue{{
   113  			Pos:  fs.Position(addTestNode.Pos()),
   114  			Msg:  notOnlyTopAddTestMsg,
   115  			Link: testRegistrationURL,
   116  		}}
   117  	}
   118  	return nil
   119  }
   120  
   121  type entityFields map[string]*ast.KeyValueExpr
   122  
   123  // registeredEntityFields returns a mapping from field name to value, or issues
   124  // on error.
   125  // call must be a registration of an entity, e.g. testing.AddTest or
   126  // testing.AddFixture.
   127  func registeredEntityFields(fs *token.FileSet, call *ast.CallExpr) (entityFields, []*Issue) {
   128  	if len(call.Args) != 1 {
   129  		// This should be checked by a compiler, so skipped.
   130  		return nil, nil
   131  	}
   132  
   133  	// Verify the argument is "&testing.Test{...}"
   134  	arg, ok := call.Args[0].(*ast.UnaryExpr)
   135  	if !ok || arg.Op != token.AND {
   136  		return nil, []*Issue{{
   137  			Pos:  fs.Position(call.Args[0].Pos()),
   138  			Msg:  addTestArgLitMsg,
   139  			Link: testRegistrationURL,
   140  		}}
   141  	}
   142  	comp, ok := arg.X.(*ast.CompositeLit)
   143  	if !ok {
   144  		return nil, []*Issue{{
   145  			Pos:  fs.Position(call.Args[0].Pos()),
   146  			Msg:  addTestArgLitMsg,
   147  			Link: testRegistrationURL,
   148  		}}
   149  	}
   150  
   151  	res := make(entityFields)
   152  	for _, el := range comp.Elts {
   153  		kv, ok := el.(*ast.KeyValueExpr)
   154  		if !ok {
   155  			continue
   156  		}
   157  		ident, ok := kv.Key.(*ast.Ident)
   158  		if !ok {
   159  			continue
   160  		}
   161  		res[ident.Name] = kv
   162  	}
   163  	return res, nil
   164  }
   165  
   166  func verifyAddFixtureCall(fs *token.FileSet, call *ast.CallExpr, fix bool) []*Issue {
   167  	fields, issues := registeredEntityFields(fs, call)
   168  	if len(issues) > 0 {
   169  		return issues
   170  	}
   171  	issues = append(issues, verifyVars(fs, fields)...)
   172  	issues = append(issues, verifyDesc(fs, fields, call, fix)...)
   173  	issues = append(issues, verifyContacts(fs, fields, call)...)
   174  	return issues
   175  }
   176  
   177  // verifyAddTestCall verifies testing.AddTest calls. Specifically
   178  // - testing.AddTest() can take a pointer of a testing.Test composite literal.
   179  // - verifies each element of testing.Test literal.
   180  func verifyAddTestCall(fs *token.FileSet, call *ast.CallExpr, path git.CommitFile, fix bool) []*Issue {
   181  	fields, issues := registeredEntityFields(fs, call)
   182  	if len(issues) > 0 {
   183  		return issues
   184  	}
   185  
   186  	if kv, ok := fields["Attr"]; ok {
   187  		issues = append(issues, verifyAttr(fs, kv.Value)...)
   188  	}
   189  	if kv, ok := fields["SoftwareDeps"]; ok {
   190  		issues = append(issues, verifySoftwareDeps(fs, kv.Value)...)
   191  	}
   192  	issues = append(issues, verifyVars(fs, fields)...)
   193  	issues = append(issues, verifyParams(fs, fields)...)
   194  	issues = append(issues, verifyDesc(fs, fields, call, fix)...)
   195  	issues = append(issues, verifyContacts(fs, fields, call)...)
   196  	issues = append(issues, verifyBugComponent(fs, fields, call)...)
   197  	issues = append(issues, verifyLacrosStatus(fs, fields, path, call, fix)...)
   198  	issues = append(issues, verifyLacrosSoftwareDeps(fs, fields, call)...)
   199  
   200  	return issues
   201  }
   202  
   203  func verifyDesc(fs *token.FileSet, fields entityFields, call *ast.CallExpr, fix bool) []*Issue {
   204  	kv, ok := fields["Desc"]
   205  	if !ok {
   206  		return []*Issue{{
   207  			Pos:  fs.Position(call.Args[0].Pos()),
   208  			Msg:  noDescMsg,
   209  			Link: testRegistrationURL,
   210  		}}
   211  	}
   212  	node := kv.Value
   213  	s, ok := toString(node)
   214  	if !ok {
   215  		return []*Issue{{
   216  			Pos:  fs.Position(node.Pos()),
   217  			Msg:  nonLiteralDescMsg,
   218  			Link: testRegistrationURL,
   219  		}}
   220  	}
   221  	if s == "" || !unicode.IsUpper(rune(s[0])) || s[len(s)-1] == '.' {
   222  		if !fix {
   223  			return []*Issue{{
   224  				Pos:     fs.Position(node.Pos()),
   225  				Msg:     badDescMsg,
   226  				Link:    "https://chromium.googlesource.com/chromiumos/platform/tast/+/HEAD/docs/writing_tests.md#Formatting",
   227  				Fixable: true,
   228  			}}
   229  		}
   230  		astutil.Apply(kv, func(c *astutil.Cursor) bool {
   231  			lit, ok := c.Node().(*ast.BasicLit)
   232  			if !ok || lit.Kind != token.STRING {
   233  				return true
   234  			}
   235  			s, err := strconv.Unquote(lit.Value)
   236  			if err != nil {
   237  				return true
   238  			}
   239  			if strtype, ok := stringLitTypeOf(lit.Value); ok {
   240  				c.Replace(&ast.BasicLit{
   241  					Kind:  token.STRING,
   242  					Value: quoteAs(strings.TrimRight(strings.ToUpper(s[:1])+s[1:], "."), strtype),
   243  				})
   244  			}
   245  			return false
   246  		}, nil)
   247  	}
   248  	return nil
   249  }
   250  func verifyBugComponent(fs *token.FileSet, fields entityFields, call *ast.CallExpr) []*Issue {
   251  	//  Does BugComponent exist?
   252  	kv, ok := fields["BugComponent"]
   253  	if !ok {
   254  		return []*Issue{{
   255  			Pos:  fs.Position(call.Args[0].Pos()),
   256  			Msg:  noBugComponentMsg,
   257  			Link: testRegistrationURL,
   258  		}}
   259  	}
   260  
   261  	node := kv.Value
   262  	s, ok := toString(node)
   263  	if !ok {
   264  		return []*Issue{{
   265  			Pos:  fs.Position(node.Pos()),
   266  			Msg:  nonBugComponentMsg,
   267  			Link: testRegistrationURL,
   268  		}}
   269  	}
   270  
   271  	componentPatterns := []string{"^b:[0-9]+$", "^crbug:[a-zA-Z>]+$", "^TBA$"}
   272  	for _, pattern := range componentPatterns {
   273  		var matched, _ = regexp.Match(pattern, []byte(s))
   274  		if matched {
   275  			return nil
   276  		}
   277  	}
   278  	return []*Issue{{
   279  		Pos:  fs.Position(node.Pos()),
   280  		Msg:  s + " " + nonBugComponentMsg,
   281  		Link: testRegistrationURL,
   282  	}}
   283  }
   284  
   285  func verifyContacts(fs *token.FileSet, fields entityFields, call *ast.CallExpr) []*Issue {
   286  	kv, ok := fields["Contacts"]
   287  	if !ok {
   288  		return []*Issue{{
   289  			Pos:  fs.Position(call.Args[0].Pos()),
   290  			Msg:  noContactMsg,
   291  			Link: testRegistrationURL,
   292  		}}
   293  	}
   294  
   295  	comp, ok := kv.Value.(*ast.CompositeLit)
   296  	if !ok {
   297  		return []*Issue{{
   298  			Pos:  fs.Position(kv.Value.Pos()),
   299  			Msg:  nonLiteralContactsMsg,
   300  			Link: testRegistrationURL,
   301  		}}
   302  	}
   303  
   304  	var issues []*Issue
   305  	for _, el := range comp.Elts {
   306  		contact, ok := toString(el)
   307  		if !ok {
   308  			issues = append(issues, &Issue{
   309  				Pos:  fs.Position(el.Pos()),
   310  				Msg:  nonLiteralContactsMsg,
   311  				Link: testRegistrationURL,
   312  			})
   313  			continue
   314  		}
   315  
   316  		if _, err := mail.ParseAddress(contact); err != nil {
   317  			issues = append(issues, &Issue{
   318  				Pos:  fs.Position(el.Pos()),
   319  				Msg:  nonLiteralContactsMsg,
   320  				Link: testRegistrationURL,
   321  			})
   322  		}
   323  	}
   324  	return issues
   325  }
   326  
   327  func verifyAttr(fs *token.FileSet, node ast.Node) []*Issue {
   328  	comp, ok := node.(*ast.CompositeLit)
   329  	if !ok {
   330  		return []*Issue{{
   331  			Pos:  fs.Position(node.Pos()),
   332  			Msg:  nonLiteralAttrMsg,
   333  			Link: testRegistrationURL,
   334  		}}
   335  	}
   336  
   337  	var issues []*Issue
   338  	for _, el := range comp.Elts {
   339  		if _, ok := toString(el); !ok {
   340  			issues = append(issues, &Issue{
   341  				Pos:  fs.Position(el.Pos()),
   342  				Msg:  nonLiteralAttrMsg,
   343  				Link: testRegistrationURL,
   344  			})
   345  		}
   346  	}
   347  	return issues
   348  }
   349  
   350  func isStaticString(expr ast.Expr) bool {
   351  	_, isString := expr.(*ast.BasicLit)
   352  	_, isIdent := expr.(*ast.Ident)
   353  	_, isSelector := expr.(*ast.SelectorExpr)
   354  	return isString || isSelector || isIdent
   355  }
   356  
   357  func isStaticStringList(expr ast.Expr) bool {
   358  	_, isSelector := expr.(*ast.SelectorExpr)
   359  	if isSelector {
   360  		return true
   361  	}
   362  	if compositeLit, ok := expr.(*ast.CompositeLit); ok {
   363  		for _, arg := range compositeLit.Elts {
   364  			if !isStaticString(arg) {
   365  				return false
   366  			}
   367  		}
   368  		return true
   369  	}
   370  
   371  	if callExpr, ok := expr.(*ast.CallExpr); ok {
   372  		fun, ok := callExpr.Fun.(*ast.Ident)
   373  		if !ok || fun.Name != "append" {
   374  			return false
   375  		}
   376  		for i, arg := range callExpr.Args {
   377  			isVarList := i == 0 || (i == len(callExpr.Args)-1 && callExpr.Ellipsis != token.NoPos)
   378  			if isVarList && !isStaticStringList(arg) {
   379  				return false
   380  			}
   381  			if !isVarList && !isStaticString(arg) {
   382  				return false
   383  			}
   384  		}
   385  		return true
   386  	}
   387  	// Since the type of the expression is a list, any selector must be a list constant.
   388  	_, ok := expr.(*ast.SelectorExpr)
   389  	return ok
   390  }
   391  
   392  func verifyVars(fs *token.FileSet, fields entityFields) []*Issue {
   393  	kv, ok := fields["Vars"]
   394  	if !ok {
   395  		return nil
   396  	}
   397  
   398  	if !isStaticStringList(kv.Value) {
   399  		return []*Issue{{
   400  			Pos:  fs.Position(kv.Value.Pos()),
   401  			Msg:  nonLiteralVarsMsg,
   402  			Link: testRegistrationURL,
   403  		}}
   404  	}
   405  	return nil
   406  }
   407  
   408  func verifySoftwareDeps(fs *token.FileSet, node ast.Expr) []*Issue {
   409  	if !isStaticStringList(node) {
   410  		return []*Issue{{
   411  			Pos:  fs.Position(node.Pos()),
   412  			Msg:  nonLiteralSoftwareDepsMsg,
   413  			Link: testRegistrationURL,
   414  		}}
   415  	}
   416  	return nil
   417  }
   418  
   419  func verifyParams(fs *token.FileSet, fields entityFields) []*Issue {
   420  	kv, ok := fields["Params"]
   421  	if !ok {
   422  		return nil
   423  	}
   424  
   425  	comp, ok := kv.Value.(*ast.CompositeLit)
   426  	if !ok {
   427  		return []*Issue{{
   428  			Pos:  fs.Position(kv.Value.Pos()),
   429  			Msg:  nonLiteralParamsMsg,
   430  			Link: testParamTestURL,
   431  		}}
   432  	}
   433  
   434  	var issues []*Issue
   435  	for _, el := range comp.Elts {
   436  		issues = append(issues, verifyParamElement(fs, el)...)
   437  	}
   438  	return issues
   439  }
   440  
   441  func verifyParamElement(fs *token.FileSet, node ast.Node) []*Issue {
   442  	comp, ok := node.(*ast.CompositeLit)
   443  	if !ok {
   444  		return []*Issue{{
   445  			Pos:  fs.Position(node.Pos()),
   446  			Msg:  nonLiteralParamsMsg,
   447  			Link: testParamTestURL,
   448  		}}
   449  	}
   450  
   451  	var issues []*Issue
   452  	for _, el := range comp.Elts {
   453  		kv, ok := el.(*ast.KeyValueExpr)
   454  		if !ok {
   455  			continue
   456  		}
   457  		ident, ok := kv.Key.(*ast.Ident)
   458  		if !ok {
   459  			continue
   460  		}
   461  		switch ident.Name {
   462  		case "Name":
   463  			if _, ok := toString(kv.Value); !ok {
   464  				issues = append(issues, &Issue{
   465  					Pos:  fs.Position(kv.Value.Pos()),
   466  					Msg:  nonLiteralParamNameMsg,
   467  					Link: testParamTestURL,
   468  				})
   469  			}
   470  		case "ExtraAttr":
   471  			issues = append(issues, verifyAttr(fs, kv.Value)...)
   472  		case "ExtraSoftwareDeps":
   473  			issues = append(issues, verifySoftwareDeps(fs, kv.Value)...)
   474  		}
   475  	}
   476  	return issues
   477  }
   478  
   479  // isTestingAddTestCall returns true if the call is an expression
   480  // to invoke testing.AddTest().
   481  func isTestingAddTestCall(node ast.Node) bool {
   482  	call, ok := node.(*ast.CallExpr)
   483  	if !ok {
   484  		return false
   485  	}
   486  	return toQualifiedName(call.Fun) == "testing.AddTest"
   487  }
   488  
   489  func isStringLiteralOrIdent(node ast.Node) bool {
   490  	if _, ok := toString(node); ok {
   491  		return true
   492  	}
   493  	return toQualifiedName(node) != ""
   494  }
   495  
   496  // toString converts the given node representing a string literal
   497  // into string value. If the node is not a string literal, returns
   498  // false for ok.
   499  func toString(node ast.Node) (s string, ok bool) {
   500  	lit, ok := node.(*ast.BasicLit)
   501  	if !ok || lit.Kind != token.STRING {
   502  		return "", false
   503  	}
   504  	s, err := strconv.Unquote(lit.Value)
   505  	if err != nil {
   506  		return "", false
   507  	}
   508  	return s, true
   509  }