|
| 1 | +/* |
| 2 | +Copyright 2025 Google LLC |
| 3 | +
|
| 4 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +you may not use this file except in compliance with the License. |
| 6 | +You may obtain a copy of the License at |
| 7 | +
|
| 8 | + https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | +Unless required by applicable law or agreed to in writing, software |
| 11 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +See the License for the specific language governing permissions and |
| 14 | +limitations under the License. |
| 15 | +*/ |
| 16 | + |
| 17 | +// Analyzer to determine if a function is a macro (produces targets). |
| 18 | + |
| 19 | +package warn |
| 20 | + |
| 21 | +import ( |
| 22 | + "fmt" |
| 23 | + "strings" |
| 24 | + |
| 25 | + "github.com/bazelbuild/buildtools/build" |
| 26 | + "github.com/bazelbuild/buildtools/labels" |
| 27 | +) |
| 28 | + |
| 29 | +// MacroAnalyzer is an object that analyzes the directed graph of functions calling each other, |
| 30 | +// determining whether a function produces targets or not. |
| 31 | +type MacroAnalyzer struct { |
| 32 | + fileReader *FileReader |
| 33 | + cache map[symbolRef]*symbolAnalysisResult |
| 34 | +} |
| 35 | + |
| 36 | +// NewMacroAnalyzer creates and initiates an instance of macroAnalyzer. |
| 37 | +func NewMacroAnalyzer(fileReader *FileReader) MacroAnalyzer { |
| 38 | + if fileReader == nil { |
| 39 | + // If no file reader is provided, a default one is provided which fails on all reads. |
| 40 | + // This can still be used if functions are preloaded via cache. |
| 41 | + fileReader = NewFileReader(func(_ string) ([]byte, error) { |
| 42 | + return nil, fmt.Errorf("tried to read file without file reader") |
| 43 | + }) |
| 44 | + } |
| 45 | + return MacroAnalyzer{ |
| 46 | + fileReader: fileReader, |
| 47 | + cache: make(map[symbolRef]*symbolAnalysisResult), |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +// MacroAnalyzerReport defines the results of analyzing a function using the MacroAnalyzer. |
| 52 | +type MacroAnalyzerReport struct { |
| 53 | + SelfDescription string |
| 54 | + symbolAnalysis *symbolAnalysisResult |
| 55 | +} |
| 56 | + |
| 57 | +// CanProduceTargets returns true if provided function has any call path which produces a target. |
| 58 | +// A function which produces targets is by definition either a rule or a macro. |
| 59 | +func (mar *MacroAnalyzerReport) CanProduceTargets() bool { |
| 60 | + if mar.symbolAnalysis == nil { |
| 61 | + return false |
| 62 | + } |
| 63 | + return mar.symbolAnalysis.canProduceTargets |
| 64 | +} |
| 65 | + |
| 66 | +// PrintableCallStack returns a user-readable call stack, providing a path for how a function may |
| 67 | +// produce targets. |
| 68 | +func (mar *MacroAnalyzerReport) PrintableCallStack() string { |
| 69 | + if mar.symbolAnalysis == nil { |
| 70 | + return "" |
| 71 | + } |
| 72 | + return strings.Join(mar.symbolAnalysis.callStackFrames, "\n") |
| 73 | +} |
| 74 | + |
| 75 | +// AnalyzeFn analyzes the provided def statement, and returns a report containing whether it produces a target (is a macro) or not. |
| 76 | +func (ma *MacroAnalyzer) AnalyzeFn(f *build.File, def *build.DefStmt) (*MacroAnalyzerReport, error) { |
| 77 | + ma.fileReader.AddFileToCache(f) |
| 78 | + call := symbolCall{symbol: &symbolRef{pkg: f.Pkg, label: f.Label, name: def.Name}, line: exprLine(def)} |
| 79 | + report, err := ma.analyzeSymbol(call) |
| 80 | + if err != nil { |
| 81 | + return nil, err |
| 82 | + } |
| 83 | + return &MacroAnalyzerReport{ |
| 84 | + SelfDescription: call.asCallStackFrame(), |
| 85 | + symbolAnalysis: report, |
| 86 | + }, nil |
| 87 | +} |
| 88 | + |
| 89 | +// AnalyzeFnCall analyzes a function call to see if it can produce a targets or not. |
| 90 | +func (ma *MacroAnalyzer) AnalyzeFnCall(f *build.File, call *build.CallExpr) (*MacroAnalyzerReport, error) { |
| 91 | + ma.fileReader.AddFileToCache(f) |
| 92 | + if symbolName := callExprToString(call); symbolName != "" { |
| 93 | + call := symbolCall{symbol: &symbolRef{pkg: f.Pkg, label: f.Label, name: symbolName}, line: exprLine(call)} |
| 94 | + report, err := ma.analyzeSymbol(call) |
| 95 | + if err != nil { |
| 96 | + return nil, err |
| 97 | + } |
| 98 | + return &MacroAnalyzerReport{ |
| 99 | + SelfDescription: call.asCallStackFrame(), |
| 100 | + symbolAnalysis: report, |
| 101 | + }, nil |
| 102 | + } |
| 103 | + return nil, fmt.Errorf("error checking call for being a macro at %s:%d", f.Path, exprLine(call)) |
| 104 | +} |
| 105 | + |
| 106 | +// symbolAnalysisResult stores the result of analyzing a symbolRef. |
| 107 | +type symbolAnalysisResult struct { |
| 108 | + canProduceTargets bool |
| 109 | + callStackFrames []string |
| 110 | +} |
| 111 | + |
| 112 | +// symbolRef represents a symbol in a specific file. |
| 113 | +type symbolRef struct { |
| 114 | + pkg string |
| 115 | + label string |
| 116 | + name string |
| 117 | +} |
| 118 | + |
| 119 | +// symbolCall represents a call (by line number) to a symbolRef. |
| 120 | +type symbolCall struct { |
| 121 | + line int |
| 122 | + symbol *symbolRef |
| 123 | +} |
| 124 | + |
| 125 | +func (sc *symbolCall) asCallStackFrame() string { |
| 126 | + return fmt.Sprintf("%s:%s:%d %s", sc.symbol.pkg, sc.symbol.label, sc.line, sc.symbol.name) |
| 127 | +} |
| 128 | + |
| 129 | +// traversalNode is an internal structure to keep track of symbol call hierarchies while traversing symbols. |
| 130 | +type traversalNode struct { |
| 131 | + parent *traversalNode |
| 132 | + symbolCall *symbolCall |
| 133 | +} |
| 134 | + |
| 135 | +// analyzeSymbol identifies a given symbol, and traverses its call stack to detect if any downstream calls can generate targets. |
| 136 | +func (ma *MacroAnalyzer) analyzeSymbol(sc symbolCall) (*symbolAnalysisResult, error) { |
| 137 | + queue := []*traversalNode{{symbolCall: &sc}} |
| 138 | + visited := make(map[symbolRef]bool) |
| 139 | + |
| 140 | + var current *traversalNode |
| 141 | + var nodeProducedTarget *traversalNode |
| 142 | + |
| 143 | + for len(queue) > 0 && nodeProducedTarget == nil { |
| 144 | + current, queue = queue[0], queue[1:] |
| 145 | + visited[*current.symbolCall.symbol] = true |
| 146 | + |
| 147 | + if producesTarget(current.symbolCall.symbol) { |
| 148 | + nodeProducedTarget = current |
| 149 | + } |
| 150 | + calls, err := ma.expandSymbol(current.symbolCall.symbol) |
| 151 | + if err != nil { |
| 152 | + return nil, err |
| 153 | + } |
| 154 | + for _, call := range calls { |
| 155 | + if _, isVisited := visited[*call.symbol]; isVisited { |
| 156 | + continue |
| 157 | + } |
| 158 | + ref := &traversalNode{parent: current, symbolCall: &call} |
| 159 | + // adding symbol to front/back of queue depending on whether the file is already loaded or not. |
| 160 | + if ma.fileReader.IsCached(call.symbol.pkg, call.symbol.label) { |
| 161 | + queue = append([]*traversalNode{ref}, queue...) |
| 162 | + } else { |
| 163 | + queue = append(queue, ref) |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + if nodeProducedTarget == nil { |
| 168 | + // If no node produced a target, all visited nodes can be cached as non-macros. |
| 169 | + for symbol := range visited { |
| 170 | + ma.cache[symbol] = &symbolAnalysisResult{canProduceTargets: false} |
| 171 | + } |
| 172 | + } else { |
| 173 | + // If a node produced a target, the call stack above the node can be cached as producing targets. |
| 174 | + var callStackFrames []string |
| 175 | + node := nodeProducedTarget |
| 176 | + for node != nil { |
| 177 | + ma.cache[*node.symbolCall.symbol] = &symbolAnalysisResult{canProduceTargets: true, callStackFrames: callStackFrames} |
| 178 | + callStackFrames = append([]string{node.symbolCall.asCallStackFrame()}, callStackFrames...) |
| 179 | + node = node.parent |
| 180 | + } |
| 181 | + } |
| 182 | + return ma.cache[*sc.symbol], nil |
| 183 | +} |
| 184 | + |
| 185 | +// exprLine returns the start line of an expression |
| 186 | +func exprLine(expr build.Expr) int { |
| 187 | + start, _ := expr.Span() |
| 188 | + return start.Line |
| 189 | +} |
| 190 | + |
| 191 | +// expandSymbol expands the provided symbol, returning a list of other symbols that it references. |
| 192 | +// e.g. if the symbol is an alias, the aliased symbol is returned, or if the symbol is a function, the symbols it calls downstream are returned. |
| 193 | +func (ma *MacroAnalyzer) expandSymbol(symbol *symbolRef) ([]symbolCall, error) { |
| 194 | + f := ma.fileReader.GetFile(symbol.pkg, symbol.label) |
| 195 | + if f == nil { |
| 196 | + return nil, fmt.Errorf("unable to find file %s:%s", symbol.pkg, symbol.label) |
| 197 | + } |
| 198 | + |
| 199 | + for _, stmt := range f.Stmt { |
| 200 | + switch stmt := stmt.(type) { |
| 201 | + case *build.AssignExpr: |
| 202 | + if lhsIdent, ok := stmt.LHS.(*build.Ident); ok && lhsIdent.Name == symbol.name { |
| 203 | + if rhsIdent, ok := stmt.RHS.(*build.Ident); ok { |
| 204 | + return []symbolCall{{ |
| 205 | + symbol: &symbolRef{pkg: f.Pkg, label: f.Label, name: rhsIdent.Name}, |
| 206 | + line: exprLine(stmt), |
| 207 | + }}, nil |
| 208 | + } |
| 209 | + if fnName := callExprToString(stmt.RHS); fnName != "" { |
| 210 | + return []symbolCall{{ |
| 211 | + symbol: &symbolRef{pkg: f.Pkg, label: f.Label, name: fnName}, |
| 212 | + line: exprLine(stmt), |
| 213 | + }}, nil |
| 214 | + } |
| 215 | + } |
| 216 | + case *build.DefStmt: |
| 217 | + if stmt.Name == symbol.name { |
| 218 | + var calls []symbolCall |
| 219 | + build.Walk(stmt, func(x build.Expr, _ []build.Expr) { |
| 220 | + if fnName := callExprToString(x); fnName != "" { |
| 221 | + calls = append(calls, symbolCall{ |
| 222 | + symbol: &symbolRef{pkg: f.Pkg, label: f.Label, name: fnName}, |
| 223 | + line: exprLine(x), |
| 224 | + }) |
| 225 | + } |
| 226 | + }) |
| 227 | + return calls, nil |
| 228 | + } |
| 229 | + case *build.LoadStmt: |
| 230 | + label := labels.ParseRelative(stmt.Module.Value, f.Pkg) |
| 231 | + if label.Repository != "" || label.Target == "" { |
| 232 | + continue |
| 233 | + } |
| 234 | + for i, from := range stmt.From { |
| 235 | + if stmt.To[i].Name == symbol.name { |
| 236 | + return []symbolCall{{ |
| 237 | + symbol: &symbolRef{pkg: label.Package, label: label.Target, name: from.Name}, |
| 238 | + line: exprLine(stmt), |
| 239 | + }}, nil |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + return nil, nil |
| 245 | +} |
| 246 | + |
| 247 | +// callExprToString converts a callExpr to its "symbol name" |
| 248 | +func callExprToString(expr build.Expr) string { |
| 249 | + call, ok := expr.(*build.CallExpr) |
| 250 | + if !ok { |
| 251 | + return "" |
| 252 | + } |
| 253 | + |
| 254 | + if fnIdent, ok := call.X.(*build.Ident); ok { |
| 255 | + return fnIdent.Name |
| 256 | + } |
| 257 | + |
| 258 | + // call of the format obj.fn(...), ignores call if anything other than ident.fn(). |
| 259 | + if fn, ok := call.X.(*build.DotExpr); ok { |
| 260 | + if obj, ok := fn.X.(*build.Ident); ok { |
| 261 | + return fmt.Sprintf("%s.%s", obj.Name, fn.Name) |
| 262 | + } |
| 263 | + } |
| 264 | + return "" |
| 265 | +} |
| 266 | + |
| 267 | +// native functions which do not produce targets (https://bazel.build/rules/lib/toplevel/native). |
| 268 | +var nativeRuleExceptions = map[string]bool{ |
| 269 | + "native.existing_rule": true, |
| 270 | + "native.existing_rules": true, |
| 271 | + "native.exports_files": true, |
| 272 | + "native.glob": true, |
| 273 | + "native.module_name": true, |
| 274 | + "native.module_version": true, |
| 275 | + "native.package_default_visibility": true, |
| 276 | + "native.package_group": true, |
| 277 | + "native.package_name": true, |
| 278 | + "native.package_relative_label": true, |
| 279 | + "native.repo_name": true, |
| 280 | + "native.repository_name": true, |
| 281 | + "native.subpackages": true, |
| 282 | +} |
| 283 | + |
| 284 | +// producesTargets returns true if the symbol name is a known generator of a target. |
| 285 | +func producesTarget(s *symbolRef) bool { |
| 286 | + // Calls to the macro() symbol produce a symbolic macro (https://bazel.build/extending/macros). |
| 287 | + if s.name == "macro" { |
| 288 | + return true |
| 289 | + } |
| 290 | + // Calls to the rule() symbol define a rule (https://bazel.build/extending/rules). |
| 291 | + if s.name == "rule" { |
| 292 | + return true |
| 293 | + } |
| 294 | + // Calls to native. invokes native rules (except defined list of native helper functions). |
| 295 | + // https://bazel.build/rules/lib/toplevel/native |
| 296 | + if strings.HasPrefix(s.name, "native.") { |
| 297 | + if _, ok := nativeRuleExceptions[s.name]; !ok { |
| 298 | + return true |
| 299 | + } |
| 300 | + } |
| 301 | + return false |
| 302 | +} |
0 commit comments