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 }