Skip to content

Commit a50563c

Browse files
Tim Malmströmoreflow
authored andcommitted
Rewriting macro analyzer
- Including found call stack as motivation for decision (macro or not) - Allowing traversal of cached files before loading non cached files - Rewording to indicate whether function or call produces targets or not (by definition rule or macro) - Cleans up API (separating external and internal structures and state)
1 parent 2eb4fcc commit a50563c

File tree

10 files changed

+809
-405
lines changed

10 files changed

+809
-405
lines changed

WARNINGS.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,14 +1216,6 @@ to [Symbolic Macros](https://bazel.build/extending/macros).
12161216
+ my_macro(name = "foo", env = "bar")
12171217
```
12181218

1219-
The linter allows the following functions to be called with positional arguments:
1220-
1221-
* `load()`
1222-
* `vardef()`
1223-
* `export_files()`
1224-
* `licenses()`
1225-
* `print()`
1226-
12271219
--------------------------------------------------------------------------------
12281220

12291221
## <a name="print"></a>`print()` is a debug function and shouldn't be submitted

warn/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
33
go_library(
44
name = "warn",
55
srcs = [
6+
"macro_analyzer.go",
67
"multifile.go",
78
"types.go",
89
"warn.go",
@@ -34,6 +35,7 @@ go_test(
3435
name = "warn_test",
3536
size = "small",
3637
srcs = [
38+
"macro_analyzer_test.go",
3739
"types_test.go",
3840
"warn_bazel_api_test.go",
3941
"warn_bazel_operation_test.go",
@@ -53,6 +55,7 @@ go_test(
5355
"//build",
5456
"//tables",
5557
"//testutils",
58+
"@com_github_google_go_cmp//cmp",
5659
],
5760
)
5861

warn/docs/warnings.textproto

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -884,13 +884,7 @@ warnings: {
884884
"```diff\n"
885885
"- my_macro(\"foo\", \"bar\")\n"
886886
"+ my_macro(name = \"foo\", env = \"bar\")\n"
887-
"```\n\n"
888-
"The linter allows the following functions to be called with positional arguments:\n\n"
889-
" * `load()`\n"
890-
" * `vardef()`\n"
891-
" * `export_files()`\n"
892-
" * `licenses()`\n"
893-
" * `print()`"
887+
"```"
894888
}
895889

896890
warnings: {

warn/macro_analyzer.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)