1// Cosmetic warnings (e.g. for improved readability of Starlark files) 2 3package warn 4 5import ( 6 "fmt" 7 "regexp" 8 "sort" 9 "strings" 10 11 "github.com/bazelbuild/buildtools/build" 12 "github.com/bazelbuild/buildtools/edit" 13) 14 15func sameOriginLoadWarning(f *build.File) []*LinterFinding { 16 var findings []*LinterFinding 17 18 type loadInfo = struct { 19 load *build.LoadStmt 20 index int 21 } 22 23 // Indices of first load statements for each module 24 first := make(map[string]loadInfo) 25 26 // Repeated load statements for each module together with their indices 27 repeated := make(map[string][]loadInfo) 28 29 for stmtIndex := 0; stmtIndex < len(f.Stmt); stmtIndex++ { 30 load, ok := f.Stmt[stmtIndex].(*build.LoadStmt) 31 if !ok { 32 continue 33 } 34 module := load.Module.Value 35 36 if _, ok := first[module]; ok { 37 repeated[module] = append(repeated[module], loadInfo{load, stmtIndex}) 38 } else { 39 first[load.Module.Value] = loadInfo{load, stmtIndex} 40 } 41 } 42 43 for module, info := range first { 44 reps, ok := repeated[module] 45 if !ok { 46 // Not duplicated 47 continue 48 } 49 50 previousStart, _ := info.load.Span() 51 message := fmt.Sprintf( 52 "There is already a load from %q on line %d. Please merge all loads from the same origin into a single one.", 53 module, 54 previousStart.Line, 55 ) 56 57 // Fix 58 newLoad := *info.load 59 newLoad.To = append([]*build.Ident{}, info.load.To...) 60 newLoad.From = append([]*build.Ident{}, info.load.From...) 61 62 start, end := newLoad.Span() 63 if start.Line == end.Line { 64 newLoad.ForceCompact = true 65 } 66 67 replacements := []LinterReplacement{{&f.Stmt[info.index], &newLoad}} 68 69 for _, rep := range reps { 70 newLoad.To = append(newLoad.To, rep.load.To...) 71 newLoad.From = append(newLoad.From, rep.load.From...) 72 73 start, end := rep.load.Span() 74 if start.Line != end.Line { 75 newLoad.ForceCompact = false 76 } 77 replacements = append(replacements, LinterReplacement{&f.Stmt[rep.index], nil}) 78 } 79 80 build.SortLoadArgs(&newLoad) 81 82 for _, rep := range reps { 83 findings = append(findings, makeLinterFinding(rep.load.Module, message, replacements...)) 84 } 85 } 86 return findings 87} 88 89func packageOnTopWarning(f *build.File) []*LinterFinding { 90 seenRule := false 91 for _, stmt := range f.Stmt { 92 _, isString := stmt.(*build.StringExpr) // typically a docstring 93 _, isComment := stmt.(*build.CommentBlock) 94 _, isAssignExpr := stmt.(*build.AssignExpr) // e.g. variable declaration 95 _, isLoad := stmt.(*build.LoadStmt) 96 _, isPackageGroup := edit.ExprToRule(stmt, "package_group") 97 _, isLicense := edit.ExprToRule(stmt, "licenses") 98 if isString || isComment || isAssignExpr || isLoad || isPackageGroup || isLicense || stmt == nil { 99 continue 100 } 101 if rule, ok := edit.ExprToRule(stmt, "package"); ok { 102 if !seenRule { // OK: package is on top of the file 103 return nil 104 } 105 return []*LinterFinding{makeLinterFinding(rule.Call, 106 "Package declaration should be at the top of the file, after the load() statements, "+ 107 "but before any call to a rule or a macro. "+ 108 "package_group() and licenses() may be called before package().")} 109 } 110 seenRule = true 111 } 112 return nil 113} 114 115func loadOnTopWarning(f *build.File) []*LinterFinding { 116 if f.Type == build.TypeWorkspace { 117 // Not applicable to WORKSPACE files 118 return nil 119 } 120 121 // Find the misplaced load statements 122 misplacedLoads := make(map[int]*build.LoadStmt) 123 firstStmtIndex := -1 // index of the first seen non-load statement 124 for i := 0; i < len(f.Stmt); i++ { 125 stmt := f.Stmt[i] 126 _, isString := stmt.(*build.StringExpr) // typically a docstring 127 _, isComment := stmt.(*build.CommentBlock) 128 if isString || isComment || stmt == nil { 129 continue 130 } 131 load, ok := stmt.(*build.LoadStmt) 132 if !ok { 133 if firstStmtIndex == -1 { 134 firstStmtIndex = i 135 } 136 continue 137 } 138 if firstStmtIndex == -1 { 139 continue 140 } 141 misplacedLoads[i] = load 142 } 143 144 // Calculate a fix 145 if firstStmtIndex == -1 { 146 firstStmtIndex = 0 147 } 148 offset := len(misplacedLoads) 149 var replacements []LinterReplacement 150 for i := range f.Stmt { 151 if i < firstStmtIndex { 152 // Docstring or a comment in the beginning, skip 153 continue 154 } else if _, ok := misplacedLoads[i]; ok { 155 // A misplaced load statement, should be moved up to the `firstStmtIndex` position 156 replacements = append(replacements, LinterReplacement{&f.Stmt[firstStmtIndex], f.Stmt[i]}) 157 firstStmtIndex++ 158 offset-- 159 if offset == 0 { 160 // No more statements should be moved 161 break 162 } 163 } else { 164 // An actual statement (not a docstring or a comment in the beginning), should be moved 165 // `offset` positions down. 166 replacements = append(replacements, LinterReplacement{&f.Stmt[i+offset], f.Stmt[i]}) 167 } 168 } 169 170 var findings []*LinterFinding 171 for _, load := range misplacedLoads { 172 findings = append(findings, makeLinterFinding(load, "Load statements should be at the top of the file.", replacements...)) 173 } 174 175 return findings 176} 177 178// compareLoadLabels compares two module names 179// If one label has explicit repository path (starts with @), it goes first 180// If the packages are different, labels are sorted by package name (empty package goes first) 181// If the packages are the same, labels are sorted by their name 182func compareLoadLabels(load1Label, load2Label string) bool { 183 // handle absolute labels with explicit repositories separately to 184 // make sure they precede absolute and relative labels without repos 185 isExplicitRepo1 := strings.HasPrefix(load1Label, "@") 186 isExplicitRepo2 := strings.HasPrefix(load2Label, "@") 187 188 if isExplicitRepo1 != isExplicitRepo2 { 189 // Exactly one label has an explicit repository name, it should be the first one. 190 return isExplicitRepo1 191 } 192 193 // Either both labels have explicit repository names or both don't, compare their packages 194 // and break ties using file names if necessary 195 module1Parts := strings.SplitN(load1Label, ":", 2) 196 package1, filename1 := "", module1Parts[0] 197 if len(module1Parts) == 2 { 198 package1, filename1 = module1Parts[0], module1Parts[1] 199 } 200 module2Parts := strings.SplitN(load2Label, ":", 2) 201 package2, filename2 := "", module2Parts[0] 202 if len(module2Parts) == 2 { 203 package2, filename2 = module2Parts[0], module2Parts[1] 204 } 205 206 // in case both packages are the same, use file names to break ties 207 if package1 == package2 { 208 return filename1 < filename2 209 } 210 211 // in case one of the packages is empty, the empty one goes first 212 if len(package1) == 0 || len(package2) == 0 { 213 return len(package1) > 0 214 } 215 216 // both packages are non-empty and not equal, so compare them 217 return package1 < package2 218} 219 220// outOfOrderLoadWarning only sorts consequent chunks of load statements. If applied together with 221// loadOnTopWarning, should be applied after it. This is currently guaranteed by sorting the 222// warning categories names before applying them ("load-on-top" < "out-of-order-load") 223func outOfOrderLoadWarning(f *build.File) []*LinterFinding { 224 var findings []*LinterFinding 225 226 // Consequent chunks of load statements (i.e. without statements of other types between them) 227 var loadsChunks [][]*build.LoadStmt 228 lastLoadIndex := -2 229 230 for i := 0; i < len(f.Stmt); i++ { 231 load, ok := f.Stmt[i].(*build.LoadStmt) 232 if !ok { 233 continue 234 } 235 if i-lastLoadIndex > 1 { 236 // There's a non-load statement between this load and the previous load 237 loadsChunks = append(loadsChunks, []*build.LoadStmt{}) 238 } 239 loadsChunks[len(loadsChunks)-1] = append(loadsChunks[len(loadsChunks)-1], load) 240 lastLoadIndex = i 241 } 242 243 // Sort and flatten the chunks 244 var sortedLoads []*build.LoadStmt 245 for _, chunk := range loadsChunks { 246 sortedChunk := append([]*build.LoadStmt{}, chunk...) 247 248 sort.SliceStable(sortedChunk, func(i, j int) bool { 249 load1Label := (sortedChunk)[i].Module.Value 250 load2Label := (sortedChunk)[j].Module.Value 251 return compareLoadLabels(load1Label, load2Label) 252 }) 253 sortedLoads = append(sortedLoads, sortedChunk...) 254 } 255 256 // Calculate the replacements 257 var replacements []LinterReplacement 258 loadIndex := 0 259 for stmtIndex := range f.Stmt { 260 if _, ok := f.Stmt[stmtIndex].(*build.LoadStmt); !ok { 261 continue 262 } 263 replacements = append(replacements, LinterReplacement{&f.Stmt[stmtIndex], sortedLoads[loadIndex]}) 264 loadIndex++ 265 } 266 267 for _, chunk := range loadsChunks { 268 for i, load := range chunk { 269 if i == 0 || !compareLoadLabels(chunk[i].Module.Value, chunk[i-1].Module.Value) { 270 // Correct position 271 continue 272 } 273 findings = append(findings, makeLinterFinding(load, "Load statement is out of its lexicographical order.", replacements...)) 274 } 275 } 276 return findings 277} 278 279func unsortedDictItemsWarning(f *build.File) []*LinterFinding { 280 var findings []*LinterFinding 281 282 compareItems := func(item1, item2 *build.KeyValueExpr) bool { 283 key1 := item1.Key.(*build.StringExpr).Value 284 key2 := item2.Key.(*build.StringExpr).Value 285 // regular keys should precede private ones (start with "_") 286 if strings.HasPrefix(key1, "_") { 287 return strings.HasPrefix(key2, "_") && key1 < key2 288 } 289 if strings.HasPrefix(key2, "_") { 290 return true 291 } 292 293 // "//conditions:default" should always be the last 294 const conditionsDefault = "//conditions:default" 295 if key1 == conditionsDefault { 296 return false 297 } else if key2 == conditionsDefault { 298 return true 299 } 300 301 return key1 < key2 302 } 303 304 build.WalkPointers(f, func(expr *build.Expr, stack []build.Expr) { 305 dict, ok := (*expr).(*build.DictExpr) 306 307 mustSkipCheck := func(expr build.Expr) bool { 308 return edit.ContainsComments(expr, "@unsorted-dict-items") 309 } 310 311 if !ok || mustSkipCheck(dict) { 312 return 313 } 314 // do not process dictionaries nested within expressions that do not 315 // want dict items to be sorted 316 for i := len(stack) - 1; i >= 0; i-- { 317 if mustSkipCheck(stack[i]) { 318 return 319 } 320 } 321 var sortedItems []*build.KeyValueExpr 322 for _, item := range dict.List { 323 // include only string literal keys into consideration 324 if _, ok = item.Key.(*build.StringExpr); !ok { 325 continue 326 } 327 sortedItems = append(sortedItems, item) 328 } 329 330 // Fix 331 comp := func(i, j int) bool { 332 return compareItems(sortedItems[i], sortedItems[j]) 333 } 334 335 var misplacedItems []*build.KeyValueExpr 336 for i := 1; i < len(sortedItems); i++ { 337 if comp(i, i-1) { 338 misplacedItems = append(misplacedItems, sortedItems[i]) 339 } 340 } 341 342 if len(misplacedItems) == 0 { 343 // Already sorted 344 return 345 } 346 newDict := *dict 347 newDict.List = append([]*build.KeyValueExpr{}, dict.List...) 348 349 sort.SliceStable(sortedItems, comp) 350 sortedItemIndex := 0 351 for originalItemIndex := 0; originalItemIndex < len(dict.List); originalItemIndex++ { 352 item := dict.List[originalItemIndex] 353 if _, ok := item.Key.(*build.StringExpr); !ok { 354 continue 355 } 356 newDict.List[originalItemIndex] = sortedItems[sortedItemIndex] 357 sortedItemIndex++ 358 } 359 360 for _, item := range misplacedItems { 361 findings = append(findings, makeLinterFinding(item, 362 "Dictionary items are out of their lexicographical order.", 363 LinterReplacement{expr, &newDict})) 364 } 365 return 366 }) 367 return findings 368} 369 370// skylarkToStarlark converts a string "skylark" in different cases to "starlark" 371// trying to preserve the same case style, e.g. capitalized "Skylark" becomes "Starlark". 372func skylarkToStarlark(s string) string { 373 switch { 374 case s == "SKYLARK": 375 return "STARLARK" 376 case strings.HasPrefix(s, "S"): 377 return "Starlark" 378 default: 379 return "starlark" 380 } 381} 382 383// replaceSkylark replaces a substring "skylark" (case-insensitive) with a 384// similar cased string "starlark". Doesn't replace it if the previous or the 385// next symbol is '/', which may indicate it's a part of a URL. 386// Normally that should be done with look-ahead and look-behind assertions in a 387// regular expression, but negative look-aheads and look-behinds are not 388// supported by Go regexp module. 389func replaceSkylark(s string) (newString string, changed bool) { 390 skylarkRegex := regexp.MustCompile("(?i)skylark") 391 newString = s 392 for _, r := range skylarkRegex.FindAllStringIndex(s, -1) { 393 if r[0] > 0 && s[r[0]-1] == '/' { 394 continue 395 } 396 if r[1] < len(s)-1 && s[r[1]+1] == '/' { 397 continue 398 } 399 newString = newString[:r[0]] + skylarkToStarlark(newString[r[0]:r[1]]) + newString[r[1]:] 400 } 401 return newString, newString != s 402} 403 404func skylarkCommentWarning(f *build.File) []*LinterFinding { 405 var findings []*LinterFinding 406 msg := `"Skylark" is an outdated name of the language, please use "starlark" instead.` 407 408 // Check comments 409 build.WalkPointers(f, func(expr *build.Expr, stack []build.Expr) { 410 comments := (*expr).Comment() 411 newComments := build.Comments{ 412 Before: append([]build.Comment{}, comments.Before...), 413 Suffix: append([]build.Comment{}, comments.Suffix...), 414 After: append([]build.Comment{}, comments.After...), 415 } 416 isModified := false 417 var start, end build.Position 418 419 for _, block := range []*[]build.Comment{&newComments.Before, &newComments.Suffix, &newComments.After} { 420 for i, comment := range *block { 421 // Don't trigger on disabling comments 422 if strings.Contains(comment.Token, "disable=skylark-docstring") { 423 continue 424 } 425 newValue, changed := replaceSkylark(comment.Token) 426 (*block)[i] = build.Comment{ 427 Start: comment.Start, 428 Token: newValue, 429 } 430 if changed { 431 isModified = true 432 start, end = comment.Span() 433 } 434 } 435 } 436 if !isModified { 437 return 438 } 439 newExpr := (*expr).Copy() 440 newExpr.Comment().Before = newComments.Before 441 newExpr.Comment().Suffix = newComments.Suffix 442 newExpr.Comment().After = newComments.After 443 finding := makeLinterFinding(*expr, msg, LinterReplacement{expr, newExpr}) 444 finding.Start = start 445 finding.End = end 446 findings = append(findings, finding) 447 }) 448 449 return findings 450} 451 452func checkSkylarkDocstring(stmts []build.Expr) *LinterFinding { 453 msg := `"Skylark" is an outdated name of the language, please use "starlark" instead.` 454 455 doc, ok := getDocstring(stmts) 456 if !ok { 457 return nil 458 } 459 docString := (*doc).(*build.StringExpr) 460 newValue, updated := replaceSkylark(docString.Value) 461 if !updated { 462 return nil 463 } 464 newDocString := *docString 465 newDocString.Value = newValue 466 return makeLinterFinding(docString, msg, LinterReplacement{doc, &newDocString}) 467} 468 469func skylarkDocstringWarning(f *build.File) []*LinterFinding { 470 var findings []*LinterFinding 471 472 // File docstring 473 if finding := checkSkylarkDocstring(f.Stmt); finding != nil { 474 findings = append(findings, finding) 475 } 476 477 // Function docstrings 478 for _, stmt := range f.Stmt { 479 def, ok := stmt.(*build.DefStmt) 480 if !ok { 481 continue 482 } 483 if finding := checkSkylarkDocstring(def.Body); finding != nil { 484 findings = append(findings, finding) 485 } 486 } 487 488 return findings 489} 490