blob: 537af1e4471b2120f87340b83abaff083eba71e8 [file] [log] [blame] [edit]
// Package warn implements functions that generate warnings for BUILD files.
package warn
import (
"fmt"
"log"
"os"
"sort"
"github.com/bazelbuild/buildtools/build"
"github.com/bazelbuild/buildtools/edit"
)
// LintMode is an enum representing a linter mode. Can be either "warn", "fix", or "suggest"
type LintMode int
const (
// ModeWarn means only warnings should be returned for each finding.
ModeWarn LintMode = iota
// ModeFix means that all warnings that can be fixed automatically should be fixed and
// no warnings should be returned for them.
ModeFix
// ModeSuggest means that automatic fixes shouldn't be applied, but instead corresponding
// suggestions should be attached to all warnings that can be fixed automatically.
ModeSuggest
)
// LinterFinding is a low-level warning reported by single linter/fixer functions.
type LinterFinding struct {
Start build.Position
End build.Position
Message string
URL string
Replacement []LinterReplacement
}
// LinterReplacement is a low-level object returned by single fixer functions.
type LinterReplacement struct {
Old *build.Expr
New build.Expr
}
// A Finding is a warning reported by the analyzer. It may contain an optional suggested fix.
type Finding struct {
File *build.File
Start build.Position
End build.Position
Category string
Message string
URL string
Actionable bool
Replacement *Replacement
}
// A Replacement is a suggested fix. Text between Start and End should be replaced with Content.
type Replacement struct {
Description string
Start int
End int
Content string
}
func docURL(cat string) string {
return "https://github.com/bazelbuild/buildtools/blob/master/WARNINGS.md#" + cat
}
// makeFinding creates a Finding object
func makeFinding(f *build.File, start, end build.Position, cat, url, msg string, actionable bool, fix *Replacement) *Finding {
if url == "" {
url = docURL(cat)
}
return &Finding{
File: f,
Start: start,
End: end,
Category: cat,
URL: url,
Message: msg,
Actionable: actionable,
Replacement: fix,
}
}
// makeLinterFinding creates a LinterFinding object
func makeLinterFinding(node build.Expr, message string, replacement ...LinterReplacement) *LinterFinding {
start, end := node.Span()
return &LinterFinding{
Start: start,
End: end,
Message: message,
Replacement: replacement,
}
}
// RuleWarningMap lists the warnings that run on a single rule.
// These warnings run only on BUILD files (not bzl files).
var RuleWarningMap = map[string]func(call *build.CallExpr, pkg string) *LinterFinding{
"positional-args": positionalArgumentsWarning,
}
// FileWarningMap lists the warnings that run on the whole file.
var FileWarningMap = map[string]func(f *build.File) []*LinterFinding{
"attr-cfg": attrConfigurationWarning,
"attr-license": attrLicenseWarning,
"attr-non-empty": attrNonEmptyWarning,
"attr-output-default": attrOutputDefaultWarning,
"attr-single-file": attrSingleFileWarning,
"build-args-kwargs": argsKwargsInBuildFilesWarning,
"bzl-visibility": deprecatedBzlLoadWarning,
"confusing-name": confusingNameWarning,
"constant-glob": constantGlobWarning,
"ctx-actions": ctxActionsWarning,
"ctx-args": contextArgsAPIWarning,
"depset-iteration": depsetIterationWarning,
"depset-union": depsetUnionWarning,
"dict-concatenation": dictionaryConcatenationWarning,
"duplicated-name": duplicatedNameWarning,
"filetype": fileTypeWarning,
"function-docstring": functionDocstringWarning,
"function-docstring-header": functionDocstringHeaderWarning,
"function-docstring-args": functionDocstringArgsWarning,
"function-docstring-return": functionDocstringReturnWarning,
"git-repository": nativeGitRepositoryWarning,
"http-archive": nativeHTTPArchiveWarning,
"integer-division": integerDivisionWarning,
"keyword-positional-params": keywordPositionalParametersWarning,
"list-append": listAppendWarning,
"load": unusedLoadWarning,
"load-on-top": loadOnTopWarning,
"module-docstring": moduleDocstringWarning,
"name-conventions": nameConventionsWarning,
"native-android": nativeAndroidRulesWarning,
"native-build": nativeInBuildFilesWarning,
"native-cc": nativeCcRulesWarning,
"native-java": nativeJavaRulesWarning,
"native-package": nativePackageWarning,
"native-proto": nativeProtoRulesWarning,
"native-py": nativePyRulesWarning,
"no-effect": noEffectWarning,
"output-group": outputGroupWarning,
"out-of-order-load": outOfOrderLoadWarning,
"overly-nested-depset": overlyNestedDepsetWarning,
"package-name": packageNameWarning,
"package-on-top": packageOnTopWarning,
"print": printWarning,
"redefined-variable": redefinedVariableWarning,
"repository-name": repositoryNameWarning,
"rule-impl-return": ruleImplReturnWarning,
"return-value": missingReturnValueWarning,
"same-origin-load": sameOriginLoadWarning,
"string-iteration": stringIterationWarning,
"uninitialized": uninitializedVariableWarning,
"unreachable": unreachableStatementWarning,
"unsorted-dict-items": unsortedDictItemsWarning,
"unused-variable": unusedVariableWarning,
}
// nonDefaultWarnings contains warnings that are enabled by default because they're not applicable
// for all files and cause too much diff noise when applied.
var nonDefaultWarnings = map[string]bool{
"out-of-order-load": true, // load statements should be sorted by their labels
"unsorted-dict-items": true, // dict items should be sorted
"bzl-visibility": true, // visibility of .bzl files
}
// fileWarningWrapper is a wrapper that converts a file warning function to a generic function.
// A generic function takes a `pkg string` argument which is not used for file warnings, so it's just removed.
func fileWarningWrapper(fct func(f *build.File) []*LinterFinding) func(f *build.File, pkg string) []*LinterFinding {
return func(f *build.File, pkg string) []*LinterFinding {
return fct(f)
}
}
// ruleWarningWrapper is a wrapper that converts a per-rule function to a per-file function.
// It also doesn't run on .bzl or default files, only on BUILD and WORKSPACE files.
func ruleWarningWrapper(ruleWarning func(call *build.CallExpr, pkg string) *LinterFinding) func(f *build.File, pkg string) []*LinterFinding {
return func(f *build.File, pkg string) []*LinterFinding {
if f.Type != build.TypeBuild {
return nil
}
var findings []*LinterFinding
for _, stmt := range f.Stmt {
switch stmt := stmt.(type) {
case *build.CallExpr:
finding := ruleWarning(stmt, pkg)
if finding != nil {
findings = append(findings, finding)
}
case *build.Comprehension:
// Rules are often called within list comprehensions, e.g. [my_rule(foo) for foo in bar]
if call, ok := stmt.Body.(*build.CallExpr); ok {
finding := ruleWarning(call, pkg)
if finding != nil {
findings = append(findings, finding)
}
}
}
}
return findings
}
}
// runWarningsFunction runs a linter/fixer function over a file and applies the fixes conditionally
func runWarningsFunction(category string, f *build.File, fct func(f *build.File, pkg string) []*LinterFinding, formatted *[]byte, mode LintMode) []*Finding {
findings := []*Finding{}
for _, w := range fct(f, f.Pkg) {
if !DisabledWarning(f, w.Start.Line, category) {
finding := makeFinding(f, w.Start, w.End, category, w.URL, w.Message, true, nil)
if len(w.Replacement) > 0 {
// An automatic fix exists
switch mode {
case ModeFix:
// Apply the fix and discard the finding
for _, r := range w.Replacement {
*r.Old = r.New
}
finding = nil
case ModeSuggest:
// Apply the fix, calculate the diff and roll back the fix
newContents := formatWithFix(f, &w.Replacement)
start, end, replacement := calculateDifference(formatted, &newContents)
finding.Replacement = &Replacement{
Description: w.Message,
Start: start,
End: end,
Content: replacement,
}
}
}
if finding != nil {
findings = append(findings, finding)
}
}
}
return findings
}
func hasDisablingComment(expr build.Expr, warning string) bool {
return edit.ContainsComments(expr, "buildifier: disable="+warning) ||
edit.ContainsComments(expr, "buildozer: disable="+warning)
}
// DisabledWarning checks if the warning was disabled by a comment.
// The comment format is buildozer: disable=<warning>
func DisabledWarning(f *build.File, findingLine int, warning string) bool {
disabled := false
build.Walk(f, func(expr build.Expr, stack []build.Expr) {
if expr == nil {
return
}
start, end := expr.Span()
if findingLine < start.Line || findingLine > end.Line {
return
}
if hasDisablingComment(expr, warning) {
disabled = true
return
}
})
return disabled
}
// FileWarnings returns a list of all warnings found in the file.
func FileWarnings(f *build.File, enabledWarnings []string, formatted *[]byte, mode LintMode) []*Finding {
findings := []*Finding{}
// Sort the warnings to make sure they're applied in the same determined order
// Make a local copy first to avoid race conditions
warnings := append([]string{}, enabledWarnings...)
sort.Strings(warnings)
// If suggestions are requested and formatted file is not provided, format it to compare modified versions with
if mode == ModeSuggest && formatted == nil {
contents := build.Format(f)
formatted = &contents
}
for _, warn := range warnings {
if fct, ok := FileWarningMap[warn]; ok {
findings = append(findings, runWarningsFunction(warn, f, fileWarningWrapper(fct), formatted, mode)...)
} else if fct, ok := RuleWarningMap[warn]; ok {
findings = append(findings, runWarningsFunction(warn, f, ruleWarningWrapper(fct), formatted, mode)...)
} else {
log.Fatalf("unexpected warning %q", warn)
}
}
sort.Slice(findings, func(i, j int) bool { return findings[i].Start.Line < findings[j].Start.Line })
return findings
}
// formatWithFix applies a fix, formats a file, and rolls back the fix
func formatWithFix(f *build.File, replacements *[]LinterReplacement) []byte {
for i := range *replacements {
r := (*replacements)[i]
old := *r.Old
*r.Old = r.New
defer func() { *r.Old = old }()
}
return build.Format(f)
}
// calculateDifference compares two file contents and returns a replacement in the form of
// a 3-tuple (byte from, byte to (non inclusive), a string to replace with).
func calculateDifference(old, new *[]byte) (start, end int, replacement string) {
commonPrefix := 0 // length of the common prefix
for i, b := range *old {
if i >= len(*new) || b != (*new)[i] {
break
}
commonPrefix++
}
commonSuffix := 0 // length of the common suffix
for i := range *old {
b := (*old)[len(*old)-1-i]
if i >= len(*new) || b != (*new)[len(*new)-1-i] {
break
}
commonSuffix++
}
// In some cases common suffix and prefix can overlap. E.g. consider the following case:
// old = "abc"
// new = "abdbc"
// In this case the common prefix is "ab" and the common suffix is "bc".
// If they overlap, just shorten the suffix so that they don't.
// The new suffix will be just "c".
if commonPrefix+commonSuffix > len(*old) {
commonSuffix = len(*old) - commonPrefix
}
if commonPrefix+commonSuffix > len(*new) {
commonSuffix = len(*new) - commonPrefix
}
return commonPrefix, len(*old) - commonSuffix, string((*new)[commonPrefix:(len(*new) - commonSuffix)])
}
// FixWarnings fixes all warnings that can be fixed automatically.
func FixWarnings(f *build.File, enabledWarnings []string, verbose bool) {
warnings := FileWarnings(f, enabledWarnings, nil, ModeFix)
if verbose {
fmt.Fprintf(os.Stderr, "%s: applied fixes, %d warnings left\n",
f.DisplayPath(),
len(warnings))
}
}
func collectAllWarnings() []string {
var result []string
// Collect list of all warnings.
for k := range FileWarningMap {
result = append(result, k)
}
for k := range RuleWarningMap {
result = append(result, k)
}
sort.Strings(result)
return result
}
// AllWarnings is the list of all available warnings.
var AllWarnings = collectAllWarnings()
func collectDefaultWarnings() []string {
warnings := []string{}
for _, warning := range AllWarnings {
if !nonDefaultWarnings[warning] {
warnings = append(warnings, warning)
}
}
return warnings
}
// DefaultWarnings is the list of all warnings that should be used inside google3
var DefaultWarnings = collectDefaultWarnings()