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