blob: 65b071c5ee8655b92068433889bdc3435ecec1b4 [file] [log] [blame] [edit]
/*
Copyright 2020 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Warnings for using deprecated functions
package warn
import (
"fmt"
"strings"
"github.com/bazelbuild/buildtools/build"
"github.com/bazelbuild/buildtools/labels"
)
// Internal constant that represents the native module
const nativeModule = "<native>"
// function represents a function identifier, which is a pair (module name, function name).
type function struct {
pkg string // package where the function is defined
filename string // name of a .bzl file relative to the package
name string // original name of the function
}
func (f function) label() string {
return f.pkg + ":" + f.filename
}
// funCall represents a call to another function. It contains information of the function itself as well as some
// information about the environment
type funCall struct {
function
nameAlias string // function name alias (it could be loaded with a different name or assigned to a new variable).
line int // line on which the function is being called
}
// acceptsNameArgument checks whether a function can accept a named argument called "name",
// either directly or via **kwargs.
func acceptsNameArgument(def *build.DefStmt) bool {
for _, param := range def.Params {
if name, op := build.GetParamName(param); name == "name" || op == "**" {
return true
}
}
return false
}
// fileData represents information about rules and functions extracted from a file
type fileData struct {
rules map[string]bool // all rules defined in the file
functions map[string]map[string]funCall // outer map: all functions defined in the file, inner map: all distinct function calls from the given function
aliases map[string]function // all top-level aliases (e.g. `foo = bar`).
}
// resolvesExternal takes a local function definition and replaces it with an external one if it's been defined
// in another file and loaded
func resolveExternal(fn function, externalSymbols map[string]function) function {
if external, ok := externalSymbols[fn.name]; ok {
return external
}
return fn
}
// exprLine returns the start line of an expression
func exprLine(expr build.Expr) int {
start, _ := expr.Span()
return start.Line
}
// getFunCalls extracts information about functions that are being called from the given function
func getFunCalls(def *build.DefStmt, pkg, filename string, externalSymbols map[string]function) map[string]funCall {
funCalls := make(map[string]funCall)
build.Walk(def, func(expr build.Expr, stack []build.Expr) {
call, ok := expr.(*build.CallExpr)
if !ok {
return
}
if ident, ok := call.X.(*build.Ident); ok {
funCalls[ident.Name] = funCall{
function: resolveExternal(function{pkg, filename, ident.Name}, externalSymbols),
nameAlias: ident.Name,
line: exprLine(call),
}
return
}
dot, ok := call.X.(*build.DotExpr)
if !ok {
return
}
if ident, ok := dot.X.(*build.Ident); !ok || ident.Name != "native" {
return
}
name := "native." + dot.Name
funCalls[name] = funCall{
function: function{
name: dot.Name,
filename: nativeModule,
},
nameAlias: name,
line: exprLine(dot),
}
})
return funCalls
}
// analyzeFile extracts the information about rules and functions defined in the file
func analyzeFile(f *build.File) fileData {
if f == nil {
return fileData{}
}
// Collect loaded symbols
externalSymbols := make(map[string]function)
for _, stmt := range f.Stmt {
load, ok := stmt.(*build.LoadStmt)
if !ok {
continue
}
label := labels.ParseRelative(load.Module.Value, f.Pkg)
if label.Repository != "" || label.Target == "" {
continue
}
for i, from := range load.From {
externalSymbols[load.To[i].Name] = function{label.Package, label.Target, from.Name}
}
}
report := fileData{
rules: make(map[string]bool),
functions: make(map[string]map[string]funCall),
aliases: make(map[string]function),
}
for _, stmt := range f.Stmt {
switch stmt := stmt.(type) {
case *build.AssignExpr:
// Analyze aliases (`foo = bar`) or rule declarations (`foo = rule(...)`)
lhsIdent, ok := stmt.LHS.(*build.Ident)
if !ok {
continue
}
if rhsIdent, ok := stmt.RHS.(*build.Ident); ok {
report.aliases[lhsIdent.Name] = resolveExternal(function{f.Pkg, f.Label, rhsIdent.Name}, externalSymbols)
continue
}
call, ok := stmt.RHS.(*build.CallExpr)
if !ok {
continue
}
ident, ok := call.X.(*build.Ident)
if !ok || ident.Name != "rule" {
continue
}
report.rules[lhsIdent.Name] = true
case *build.DefStmt:
report.functions[stmt.Name] = getFunCalls(stmt, f.Pkg, f.Label, externalSymbols)
default:
continue
}
}
return report
}
// functionReport represents the analysis result of a function
type functionReport struct {
isMacro bool // whether the function is a macro (or a rule)
fc *funCall // a call to the rule or another macro
}
// macroAnalyzer is an object that analyzes the directed graph of functions calling each other,
// loading other files lazily if necessary.
type macroAnalyzer struct {
fileReader *FileReader
files map[string]fileData
cache map[function]functionReport
}
// getFileData retrieves a file using the fileReader object and extracts information about functions and rules
// defined in the file.
func (ma macroAnalyzer) getFileData(pkg, label string) fileData {
filename := pkg + ":" + label
if fd, ok := ma.files[filename]; ok {
return fd
}
if ma.fileReader == nil {
fd := fileData{}
ma.files[filename] = fd
return fd
}
f := ma.fileReader.GetFile(pkg, label)
fd := analyzeFile(f)
ma.files[filename] = fd
return fd
}
// IsMacro is a public function that checks whether the given function is a macro
func (ma macroAnalyzer) IsMacro(fn function) (report functionReport) {
// Check the cache first
if cached, ok := ma.cache[fn]; ok {
return cached
}
// Write a negative result to the cache before analyzing. This will prevent stack overflow crashes
// if the input data contains recursion.
ma.cache[fn] = report
defer func() {
// Update the cache with the actual result
ma.cache[fn] = report
}()
// Check for native rules
if fn.filename == nativeModule {
switch fn.name {
case "glob", "existing_rule", "existing_rules", "package_name",
"repository_name", "exports_files":
// Not a rule
default:
report.isMacro = true
}
return
}
fileData := ma.getFileData(fn.pkg, fn.filename)
// Check whether fn.name is an alias for another function
if alias, ok := fileData.aliases[fn.name]; ok {
if ma.IsMacro(alias).isMacro {
report.isMacro = true
}
return
}
// Check whether fn.name is a rule
if fileData.rules[fn.name] {
report.isMacro = true
return
}
// Check whether fn.name is an ordinary function
funCalls, ok := fileData.functions[fn.name]
if !ok {
return
}
// Prioritize function calls from already loaded files. If some of the function calls are from the same file
// (or another file that has been loaded already), check them first.
var knownFunCalls, newFunCalls []funCall
for _, fc := range funCalls {
if _, ok := ma.files[fc.function.pkg+":"+fc.function.filename]; ok || fc.function.filename == nativeModule {
knownFunCalls = append(knownFunCalls, fc)
} else {
newFunCalls = append(newFunCalls, fc)
}
}
for _, fc := range append(knownFunCalls, newFunCalls...) {
if ma.IsMacro(fc.function).isMacro {
report.isMacro = true
report.fc = &fc
return
}
}
return
}
// newMacroAnalyzer creates and initiates an instance of macroAnalyzer.
func newMacroAnalyzer(fileReader *FileReader) macroAnalyzer {
return macroAnalyzer{
fileReader: fileReader,
files: make(map[string]fileData),
cache: make(map[function]functionReport),
}
}
func unnamedMacroWarning(f *build.File, fileReader *FileReader) []*LinterFinding {
if f.Type != build.TypeBzl {
return nil
}
macroAnalyzer := newMacroAnalyzer(fileReader)
macroAnalyzer.files[f.Pkg+":"+f.Label] = analyzeFile(f)
findings := []*LinterFinding{}
for _, stmt := range f.Stmt {
def, ok := stmt.(*build.DefStmt)
if !ok {
continue
}
if strings.HasPrefix(def.Name, "_") || acceptsNameArgument(def) {
continue
}
report := macroAnalyzer.IsMacro(function{f.Pkg, f.Label, def.Name})
if !report.isMacro {
continue
}
msg := fmt.Sprintf(`The macro %q should have a keyword argument called "name".`, def.Name)
if report.fc != nil {
// fc shouldn't be nil because that's the only node that can be found inside a function.
msg += fmt.Sprintf(`
It is considered a macro because it calls a rule or another macro %q on line %d.
By convention, every public macro needs a "name" argument (even if it doesn't use it).
This is important for tooling and automation.
* If this function is a helper function that's not supposed to be used outside of this file,
please make it private (e.g. rename it to "_%s").
* Otherwise, add a "name" argument. If possible, use that name when calling other macros/rules.`, report.fc.nameAlias, report.fc.line, def.Name)
}
finding := makeLinterFinding(def, msg)
finding.End = def.ColonPos
findings = append(findings, finding)
}
return findings
}