/* 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 = "" // 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 }