1// Package warn implements functions that generate warnings for BUILD files.
2package warn
3
4import (
5	"fmt"
6	"log"
7	"os"
8	"sort"
9
10	"github.com/bazelbuild/buildtools/build"
11	"github.com/bazelbuild/buildtools/edit"
12)
13
14// LintMode is an enum representing a linter mode. Can be either "warn", "fix", or "suggest"
15type LintMode int
16
17const (
18	// ModeWarn means only warnings should be returned for each finding.
19	ModeWarn LintMode = iota
20	// ModeFix means that all warnings that can be fixed automatically should be fixed and
21	// no warnings should be returned for them.
22	ModeFix
23	// ModeSuggest means that automatic fixes shouldn't be applied, but instead corresponding
24	// suggestions should be attached to all warnings that can be fixed automatically.
25	ModeSuggest
26)
27
28// LinterFinding is a low-level warning reported by single linter/fixer functions.
29type LinterFinding struct {
30	Start       build.Position
31	End         build.Position
32	Message     string
33	URL         string
34	Replacement []LinterReplacement
35}
36
37// LinterReplacement is a low-level object returned by single fixer functions.
38type LinterReplacement struct {
39	Old *build.Expr
40	New build.Expr
41}
42
43// A Finding is a warning reported by the analyzer. It may contain an optional suggested fix.
44type Finding struct {
45	File        *build.File
46	Start       build.Position
47	End         build.Position
48	Category    string
49	Message     string
50	URL         string
51	Actionable  bool
52	Replacement *Replacement
53}
54
55// A Replacement is a suggested fix. Text between Start and End should be replaced with Content.
56type Replacement struct {
57	Description string
58	Start       int
59	End         int
60	Content     string
61}
62
63func docURL(cat string) string {
64	return "https://github.com/bazelbuild/buildtools/blob/master/WARNINGS.md#" + cat
65}
66
67// makeFinding creates a Finding object
68func makeFinding(f *build.File, start, end build.Position, cat, url, msg string, actionable bool, fix *Replacement) *Finding {
69	if url == "" {
70		url = docURL(cat)
71	}
72	return &Finding{
73		File:        f,
74		Start:       start,
75		End:         end,
76		Category:    cat,
77		URL:         url,
78		Message:     msg,
79		Actionable:  actionable,
80		Replacement: fix,
81	}
82}
83
84// makeLinterFinding creates a LinterFinding object
85func makeLinterFinding(node build.Expr, message string, replacement ...LinterReplacement) *LinterFinding {
86	start, end := node.Span()
87	return &LinterFinding{
88		Start:       start,
89		End:         end,
90		Message:     message,
91		Replacement: replacement,
92	}
93}
94
95// RuleWarningMap lists the warnings that run on a single rule.
96// These warnings run only on BUILD files (not bzl files).
97var RuleWarningMap = map[string]func(call *build.CallExpr, pkg string) *LinterFinding{
98	"positional-args": positionalArgumentsWarning,
99}
100
101// FileWarningMap lists the warnings that run on the whole file.
102var FileWarningMap = map[string]func(f *build.File) []*LinterFinding{
103	"attr-cfg":                  attrConfigurationWarning,
104	"attr-license":              attrLicenseWarning,
105	"attr-non-empty":            attrNonEmptyWarning,
106	"attr-output-default":       attrOutputDefaultWarning,
107	"attr-single-file":          attrSingleFileWarning,
108	"build-args-kwargs":         argsKwargsInBuildFilesWarning,
109	"bzl-visibility":            bzlVisibilityWarning,
110	"confusing-name":            confusingNameWarning,
111	"constant-glob":             constantGlobWarning,
112	"ctx-actions":               ctxActionsWarning,
113	"ctx-args":                  contextArgsAPIWarning,
114	"depset-items":              depsetItemsWarning,
115	"depset-iteration":          depsetIterationWarning,
116	"depset-union":              depsetUnionWarning,
117	"dict-concatenation":        dictionaryConcatenationWarning,
118	"duplicated-name":           duplicatedNameWarning,
119	"filetype":                  fileTypeWarning,
120	"function-docstring":        functionDocstringWarning,
121	"function-docstring-header": functionDocstringHeaderWarning,
122	"function-docstring-args":   functionDocstringArgsWarning,
123	"function-docstring-return": functionDocstringReturnWarning,
124	"git-repository":            nativeGitRepositoryWarning,
125	"http-archive":              nativeHTTPArchiveWarning,
126	"integer-division":          integerDivisionWarning,
127	"keyword-positional-params": keywordPositionalParametersWarning,
128	"list-append":               listAppendWarning,
129	"load":                      unusedLoadWarning,
130	"load-on-top":               loadOnTopWarning,
131	"module-docstring":          moduleDocstringWarning,
132	"name-conventions":          nameConventionsWarning,
133	"native-android":            nativeAndroidRulesWarning,
134	"native-build":              nativeInBuildFilesWarning,
135	"native-cc":                 nativeCcRulesWarning,
136	"native-java":               nativeJavaRulesWarning,
137	"native-package":            nativePackageWarning,
138	"native-proto":              nativeProtoRulesWarning,
139	"native-py":                 nativePyRulesWarning,
140	"no-effect":                 noEffectWarning,
141	"output-group":              outputGroupWarning,
142	"out-of-order-load":         outOfOrderLoadWarning,
143	"overly-nested-depset":      overlyNestedDepsetWarning,
144	"package-name":              packageNameWarning,
145	"package-on-top":            packageOnTopWarning,
146	"print":                     printWarning,
147	"redefined-variable":        redefinedVariableWarning,
148	"repository-name":           repositoryNameWarning,
149	"rule-impl-return":          ruleImplReturnWarning,
150	"return-value":              missingReturnValueWarning,
151	"same-origin-load":          sameOriginLoadWarning,
152	"skylark-comment":           skylarkCommentWarning,
153	"skylark-docstring":         skylarkDocstringWarning,
154	"string-iteration":          stringIterationWarning,
155	"uninitialized":             uninitializedVariableWarning,
156	"unreachable":               unreachableStatementWarning,
157	"unsorted-dict-items":       unsortedDictItemsWarning,
158	"unused-variable":           unusedVariableWarning,
159}
160
161// MultiFileWarningMap lists the warnings that run on the whole file, but may use other files.
162var MultiFileWarningMap = map[string]func(f *build.File, fileReader *FileReader) []*LinterFinding{
163	"deprecated-function": deprecatedFunctionWarning,
164	"unnamed-macro":       unnamedMacroWarning,
165}
166
167// nonDefaultWarnings contains warnings that are enabled by default because they're not applicable
168// for all files and cause too much diff noise when applied.
169var nonDefaultWarnings = map[string]bool{
170	"out-of-order-load":   true, // load statements should be sorted by their labels
171	"unsorted-dict-items": true, // dict items should be sorted
172}
173
174// fileWarningWrapper is a wrapper that converts a file warning function to a generic function.
175// A generic function takes a `pkg string` and a `*ReadFile` arguments which are not used for file warnings,
176// so they are just removed.
177func fileWarningWrapper(fct func(f *build.File) []*LinterFinding) func(*build.File, string, *FileReader) []*LinterFinding {
178	return func(f *build.File, _ string, _ *FileReader) []*LinterFinding {
179		return fct(f)
180	}
181}
182
183// multiFileWarningWrapper is a wrapper that converts a multifile warning function to a generic function.
184// A generic function takes a `pkg string` argument which is not used for file warnings, so it's just removed.
185func multiFileWarningWrapper(fct func(f *build.File, fileReader *FileReader) []*LinterFinding) func(*build.File, string, *FileReader) []*LinterFinding {
186	return func(f *build.File, _ string, fileReader *FileReader) []*LinterFinding {
187		return fct(f, fileReader)
188	}
189}
190
191// ruleWarningWrapper is a wrapper that converts a per-rule function to a per-file function.
192// It also doesn't run on .bzl or default files, only on BUILD and WORKSPACE files.
193func ruleWarningWrapper(ruleWarning func(call *build.CallExpr, pkg string) *LinterFinding) func(*build.File, string, *FileReader) []*LinterFinding {
194	return func(f *build.File, pkg string, _ *FileReader) []*LinterFinding {
195		if f.Type != build.TypeBuild {
196			return nil
197		}
198		var findings []*LinterFinding
199		for _, stmt := range f.Stmt {
200			switch stmt := stmt.(type) {
201			case *build.CallExpr:
202				finding := ruleWarning(stmt, pkg)
203				if finding != nil {
204					findings = append(findings, finding)
205				}
206			case *build.Comprehension:
207				// Rules are often called within list comprehensions, e.g. [my_rule(foo) for foo in bar]
208				if call, ok := stmt.Body.(*build.CallExpr); ok {
209					finding := ruleWarning(call, pkg)
210					if finding != nil {
211						findings = append(findings, finding)
212					}
213				}
214			}
215		}
216		return findings
217	}
218}
219
220// runWarningsFunction runs a linter/fixer function over a file and applies the fixes conditionally
221func runWarningsFunction(category string, f *build.File, fct func(f *build.File, pkg string, fileReader *FileReader) []*LinterFinding, formatted *[]byte, mode LintMode, fileReader *FileReader) []*Finding {
222	findings := []*Finding{}
223	for _, w := range fct(f, f.Pkg, fileReader) {
224		if !DisabledWarning(f, w.Start.Line, category) {
225			finding := makeFinding(f, w.Start, w.End, category, w.URL, w.Message, true, nil)
226			if len(w.Replacement) > 0 {
227				// An automatic fix exists
228				switch mode {
229				case ModeFix:
230					// Apply the fix and discard the finding
231					for _, r := range w.Replacement {
232						*r.Old = r.New
233					}
234					finding = nil
235				case ModeSuggest:
236					// Apply the fix, calculate the diff and roll back the fix
237					newContents := formatWithFix(f, &w.Replacement)
238
239					start, end, replacement := calculateDifference(formatted, &newContents)
240					finding.Replacement = &Replacement{
241						Description: w.Message,
242						Start:       start,
243						End:         end,
244						Content:     replacement,
245					}
246				}
247			}
248			if finding != nil {
249				findings = append(findings, finding)
250			}
251		}
252	}
253	return findings
254}
255
256func hasDisablingComment(expr build.Expr, warning string) bool {
257	return edit.ContainsComments(expr, "buildifier: disable="+warning) ||
258		edit.ContainsComments(expr, "buildozer: disable="+warning)
259}
260
261// DisabledWarning checks if the warning was disabled by a comment.
262// The comment format is buildozer: disable=<warning>
263func DisabledWarning(f *build.File, findingLine int, warning string) bool {
264	disabled := false
265
266	build.Walk(f, func(expr build.Expr, stack []build.Expr) {
267		if expr == nil {
268			return
269		}
270
271		start, end := expr.Span()
272		if findingLine < start.Line || findingLine > end.Line {
273			return
274		}
275
276		if hasDisablingComment(expr, warning) {
277			disabled = true
278			return
279		}
280	})
281
282	return disabled
283}
284
285// FileWarnings returns a list of all warnings found in the file.
286func FileWarnings(f *build.File, enabledWarnings []string, formatted *[]byte, mode LintMode, fileReader *FileReader) []*Finding {
287	findings := []*Finding{}
288
289	// Sort the warnings to make sure they're applied in the same determined order
290	// Make a local copy first to avoid race conditions
291	warnings := append([]string{}, enabledWarnings...)
292	sort.Strings(warnings)
293
294	// If suggestions are requested and formatted file is not provided, format it to compare modified versions with
295	if mode == ModeSuggest && formatted == nil {
296		contents := build.Format(f)
297		formatted = &contents
298	}
299
300	for _, warn := range warnings {
301		if fct, ok := FileWarningMap[warn]; ok {
302			findings = append(findings, runWarningsFunction(warn, f, fileWarningWrapper(fct), formatted, mode, fileReader)...)
303		} else if fct, ok := MultiFileWarningMap[warn]; ok {
304			findings = append(findings, runWarningsFunction(warn, f, multiFileWarningWrapper(fct), formatted, mode, fileReader)...)
305		} else if fct, ok := RuleWarningMap[warn]; ok {
306			findings = append(findings, runWarningsFunction(warn, f, ruleWarningWrapper(fct), formatted, mode, fileReader)...)
307		} else {
308			log.Fatalf("unexpected warning %q", warn)
309		}
310	}
311	sort.Slice(findings, func(i, j int) bool { return findings[i].Start.Line < findings[j].Start.Line })
312	return findings
313}
314
315// formatWithFix applies a fix, formats a file, and rolls back the fix
316func formatWithFix(f *build.File, replacements *[]LinterReplacement) []byte {
317	for i := range *replacements {
318		r := (*replacements)[i]
319		old := *r.Old
320		*r.Old = r.New
321		defer func() { *r.Old = old }()
322	}
323
324	return build.Format(f)
325}
326
327// calculateDifference compares two file contents and returns a replacement in the form of
328// a 3-tuple (byte from, byte to (non inclusive), a string to replace with).
329func calculateDifference(old, new *[]byte) (start, end int, replacement string) {
330	commonPrefix := 0 // length of the common prefix
331	for i, b := range *old {
332		if i >= len(*new) || b != (*new)[i] {
333			break
334		}
335		commonPrefix++
336	}
337
338	commonSuffix := 0 // length of the common suffix
339	for i := range *old {
340		b := (*old)[len(*old)-1-i]
341		if i >= len(*new) || b != (*new)[len(*new)-1-i] {
342			break
343		}
344		commonSuffix++
345	}
346
347	// In some cases common suffix and prefix can overlap. E.g. consider the following case:
348	//   old = "abc"
349	//   new = "abdbc"
350	// In this case the common prefix is "ab" and the common suffix is "bc".
351	// If they overlap, just shorten the suffix so that they don't.
352	// The new suffix will be just "c".
353	if commonPrefix+commonSuffix > len(*old) {
354		commonSuffix = len(*old) - commonPrefix
355	}
356	if commonPrefix+commonSuffix > len(*new) {
357		commonSuffix = len(*new) - commonPrefix
358	}
359	return commonPrefix, len(*old) - commonSuffix, string((*new)[commonPrefix:(len(*new) - commonSuffix)])
360}
361
362// FixWarnings fixes all warnings that can be fixed automatically.
363func FixWarnings(f *build.File, enabledWarnings []string, verbose bool, fileReader *FileReader) {
364	warnings := FileWarnings(f, enabledWarnings, nil, ModeFix, fileReader)
365	if verbose {
366		fmt.Fprintf(os.Stderr, "%s: applied fixes, %d warnings left\n",
367			f.DisplayPath(),
368			len(warnings))
369	}
370}
371
372func collectAllWarnings() []string {
373	var result []string
374	// Collect list of all warnings.
375	for k := range FileWarningMap {
376		result = append(result, k)
377	}
378	for k := range MultiFileWarningMap {
379		result = append(result, k)
380	}
381	for k := range RuleWarningMap {
382		result = append(result, k)
383	}
384	sort.Strings(result)
385	return result
386}
387
388// AllWarnings is the list of all available warnings.
389var AllWarnings = collectAllWarnings()
390
391func collectDefaultWarnings() []string {
392	warnings := []string{}
393	for _, warning := range AllWarnings {
394		if !nonDefaultWarnings[warning] {
395			warnings = append(warnings, warning)
396		}
397	}
398	return warnings
399}
400
401// DefaultWarnings is the list of all warnings that should be used inside google3
402var DefaultWarnings = collectDefaultWarnings()
403