@ -0,0 +1,128 @@ | |||
// Package bicall locates calls of built-in functions in Go source. | |||
package bicall | |||
import ( | |||
"fmt" | |||
"go/ast" | |||
"go/parser" | |||
"go/token" | |||
"io" | |||
"strings" | |||
) | |||
// See https://golang.org/ref/spec#Built-in_functions | |||
var isBuiltin = map[string]bool{ | |||
"append": true, | |||
"cap": true, | |||
"close": true, | |||
"complex": true, | |||
"copy": true, | |||
"delete": true, | |||
"imag": true, | |||
"len": true, | |||
"make": true, | |||
"new": true, | |||
"panic": true, | |||
"print": true, | |||
"println": true, | |||
"real": true, | |||
"recover": true, | |||
} | |||
// Call represents a call to a built-in function in a source program. | |||
type Call struct { | |||
Name string // the name of the built-in function | |||
Call *ast.CallExpr // the call expression in the AST | |||
Site token.Position // the location of the call | |||
Path []ast.Node // the AST path to the call | |||
Comments []string // comments attributed to the call site by the parser | |||
} | |||
// flattenComments extracts the text of the given comment groups, and removes | |||
// leading and trailing whitespace from them. | |||
func flattenComments(cgs []*ast.CommentGroup) (out []string) { | |||
for _, cg := range cgs { | |||
out = append(out, strings.TrimSuffix(cg.Text(), "\n")) | |||
} | |||
return | |||
} | |||
// Parse parses the contents of r as a Go source file, and calls f for each | |||
// call expression targeting a built-in function that occurs in the resulting | |||
// AST. Location information about each call is attributed to the specified | |||
// filename. | |||
// | |||
// If f reports an error, traversal stops and that error is reported to the | |||
// caller of Parse. | |||
func Parse(r io.Reader, filename string, f func(Call) error) error { | |||
fset := token.NewFileSet() | |||
file, err := parser.ParseFile(fset, filename, r, parser.ParseComments) | |||
if err != nil { | |||
return fmt.Errorf("parsing %q: %w", filename, err) | |||
} | |||
cmap := ast.NewCommentMap(fset, file, file.Comments) | |||
var path []ast.Node | |||
// Find the comments associated with node. If the node does not have its own | |||
// comments, scan upward for a statement containing the node. | |||
commentsFor := func(node ast.Node) []string { | |||
if cgs := cmap[node]; cgs != nil { | |||
return flattenComments(cgs) | |||
} | |||
for i := len(path) - 2; i >= 0; i-- { | |||
if _, ok := path[i].(ast.Stmt); !ok { | |||
continue | |||
} | |||
if cgs := cmap[path[i]]; cgs != nil { | |||
return flattenComments(cgs) | |||
} else { | |||
break | |||
} | |||
} | |||
return nil | |||
} | |||
v := &visitor{ | |||
visit: func(node ast.Node) error { | |||
if node == nil { | |||
path = path[:len(path)-1] | |||
return nil | |||
} | |||
path = append(path, node) | |||
if call, ok := node.(*ast.CallExpr); ok { | |||
id, ok := call.Fun.(*ast.Ident) | |||
if !ok || !isBuiltin[id.Name] { | |||
return nil | |||
} | |||
if err := f(Call{ | |||
Name: id.Name, | |||
Call: call, | |||
Site: fset.Position(call.Pos()), | |||
Path: path, | |||
Comments: commentsFor(node), | |||
}); err != nil { | |||
return err | |||
} | |||
} | |||
return nil | |||
}, | |||
} | |||
ast.Walk(v, file) | |||
return v.err | |||
} | |||
type visitor struct { | |||
err error | |||
visit func(ast.Node) error | |||
} | |||
func (v *visitor) Visit(node ast.Node) ast.Visitor { | |||
if v.err == nil { | |||
v.err = v.visit(node) | |||
} | |||
if v.err != nil { | |||
return nil | |||
} | |||
return v | |||
} |
@ -0,0 +1,101 @@ | |||
package bicall_test | |||
import ( | |||
"log" | |||
"sort" | |||
"strings" | |||
"testing" | |||
"github.com/google/go-cmp/cmp" | |||
"github.com/tendermint/tendermint/tools/panic/bicall" | |||
) | |||
func TestParse(t *testing.T) { | |||
want := mustFindNeedles(testInput) | |||
sortByLocation(want) | |||
var got []needle | |||
err := bicall.Parse(strings.NewReader(testInput), "testinput.go", func(c bicall.Call) error { | |||
got = append(got, needle{ | |||
Name: c.Name, | |||
Line: c.Site.Line, | |||
Col: c.Site.Column - 1, | |||
}) | |||
t.Logf("Found call site for %q at %v", c.Name, c.Site) | |||
// Verify that the indicator comment shows up attributed to the site. | |||
tag := "@" + c.Name | |||
if len(c.Comments) != 1 || c.Comments[0] != tag { | |||
t.Errorf("Wrong comment at %v: got %+q, want [%q]", c.Site, c.Comments, tag) | |||
} | |||
return nil | |||
}) | |||
if err != nil { | |||
t.Fatalf("Parse unexpectedly failed: %v", err) | |||
} | |||
sortByLocation(got) | |||
if diff := cmp.Diff(want, got); diff != "" { | |||
t.Errorf("Call site mismatch: (-want, +got)\n%s", diff) | |||
} | |||
} | |||
// sortByLocation permutes ns in-place to be ordered by line and column. | |||
// The specific ordering rule is not important; we just need a consistent order | |||
// for comparison of test results. | |||
func sortByLocation(ns []needle) { | |||
sort.Slice(ns, func(i, j int) bool { | |||
if ns[i].Line == ns[j].Line { | |||
return ns[i].Col < ns[j].Col | |||
} | |||
return ns[i].Line < ns[j].Line | |||
}) | |||
} | |||
// To add call sites to the test, include a trailing line comment having the | |||
// form "//@name", where "name" is a built-in function name. | |||
// The first offset of that name on the line prior to the comment will become | |||
// an expected call site for that function. | |||
const testInput = ` | |||
package testinput | |||
func main() { | |||
defer func() { | |||
x := recover() //@recover | |||
if x != nil { | |||
println("whoa") //@println | |||
} | |||
}() | |||
ip := new(int) //@new | |||
*ip = 3 + copy([]byte{}, "") //@copy | |||
panic(fmt.Sprintf("ip=%p", ip)) //@panic | |||
} | |||
` | |||
// A needle is a name at a location in the source, that is expected to be | |||
// located in a scan of the input for built-in calls. | |||
// N.B. Fields are exported to allow comparison by the cmp package. | |||
type needle struct { | |||
Name string | |||
Line, Col int | |||
} | |||
func mustFindNeedles(src string) []needle { | |||
var needles []needle | |||
for i, raw := range strings.Split(src, "\n") { | |||
tag := strings.SplitN(raw, "//@", 2) | |||
if len(tag) == 1 { | |||
continue // no needle on this line | |||
} | |||
name := strings.TrimSpace(tag[1]) | |||
col := strings.Index(tag[0], name) | |||
if col < 0 { | |||
log.Panicf("No match for %q on line %d of test input", name, i+1) | |||
} | |||
needles = append(needles, needle{ | |||
Name: name, | |||
Line: i + 1, | |||
Col: col, | |||
}) | |||
} | |||
return needles | |||
} |
@ -0,0 +1,115 @@ | |||
package callgraph | |||
import ( | |||
"fmt" | |||
"go/ast" | |||
"go/types" | |||
"sort" | |||
"golang.org/x/tools/go/loader" | |||
) | |||
type Triple struct { | |||
Caller Entry | |||
Target Entry | |||
Site Location | |||
} | |||
type Entry struct { | |||
Package string // canonical import path | |||
Name string // name relative to the package ("" for calls at file scope) | |||
} | |||
type Location struct { | |||
Path string | |||
Offset int // 0-based | |||
Line, Col int // 1-based line, 0-based byte offset | |||
} | |||
type Graph struct { | |||
cfg *loader.Config | |||
} | |||
func New() *Graph { | |||
cfg := new(loader.Config) | |||
cfg.TypeCheckFuncBodies = func(ip string) bool { | |||
_, ok := cfg.ImportPkgs[ip] | |||
return ok | |||
} | |||
return &Graph{cfg: cfg} | |||
} | |||
func (g *Graph) Import(ipath string) { g.cfg.Import(ipath) } | |||
func (g *Graph) ImportWithTests(ipath string) { g.cfg.ImportWithTests(ipath) } | |||
func (g *Graph) Process(f func(*Triple)) error { | |||
pgm, err := g.cfg.Load() | |||
if err != nil { | |||
return fmt.Errorf("loading program: %v", err) | |||
} | |||
var pkgs []*loader.PackageInfo | |||
for _, pkg := range pgm.Imported { | |||
pkgs = append(pkgs, pkg) | |||
} | |||
sort.Slice(pkgs, func(i, j int) bool { | |||
return pkgs[i].Pkg.Path() < pkgs[j].Pkg.Path() | |||
}) | |||
for _, pkg := range pkgs { | |||
for _, file := range pkg.Files { | |||
fname := g.cfg.Fset.Position(file.Pos()).Filename | |||
var nodes []ast.Node | |||
parent := func() string { | |||
for i := len(nodes) - 1; i >= 0; i-- { | |||
switch t := nodes[i].(type) { | |||
case *ast.FuncDecl: | |||
return t.Name.Name | |||
} | |||
} | |||
return fname | |||
} | |||
ast.Walk(visitFunc(func(node ast.Node) { | |||
if node == nil { | |||
nodes = nodes[:len(nodes)-1] | |||
return | |||
} | |||
nodes = append(nodes, node) | |||
switch t := node.(type) { | |||
case *ast.Ident: | |||
ref := pkg.Info.Uses[t] | |||
if ref == nil { | |||
return // no referent | |||
} | |||
var refPath string | |||
if _, ok := ref.Type().(*types.Signature); ok { | |||
refPath = ref.Pkg().Path() // OK, function | |||
} else if _, ok := ref.(*types.Builtin); ok { | |||
// OK, builtin | |||
} else { | |||
return // not a function call or reference | |||
} | |||
pos := g.cfg.Fset.Position(t.Pos()) | |||
f(&Triple{ | |||
Caller: Entry{Package: pkg.Pkg.Path(), Name: parent()}, | |||
Target: Entry{Package: refPath, Name: ref.Name()}, | |||
Site: Location{ | |||
Path: pos.Filename, | |||
Offset: pos.Offset, | |||
Line: pos.Line, | |||
Col: pos.Column - 1, | |||
}, | |||
}) | |||
} | |||
}), file) | |||
} | |||
} | |||
return nil | |||
} | |||
type visitFunc func(ast.Node) | |||
func (v visitFunc) Visit(n ast.Node) ast.Visitor { v(n); return v } |
@ -0,0 +1,19 @@ | |||
package callgraph_test | |||
import ( | |||
"testing" | |||
"github.com/tendermint/tendermint/tools/panic/callgraph" | |||
) | |||
func TestStub(t *testing.T) { | |||
g := callgraph.New() | |||
g.ImportWithTests("github.com/tendermint/tendermint/internal/consensus") | |||
if err := g.Process(func(cg *callgraph.Triple) { | |||
if cg.Target.Name == "panic" { | |||
t.Logf("Panic call at %v", cg.Site) | |||
} | |||
}); err != nil { | |||
t.Fatal(err) | |||
} | |||
} |
@ -0,0 +1,131 @@ | |||
// Program findbuiltin locates calls to built-in functions in Go source | |||
// code, and writes a machine-readable log of where those calls occur. | |||
package main | |||
import ( | |||
"bufio" | |||
"encoding/json" | |||
"flag" | |||
"fmt" | |||
"io" | |||
"log" | |||
"os" | |||
"path/filepath" | |||
"strings" | |||
"github.com/tendermint/tendermint/tools/panic/bicall" | |||
) | |||
var ( | |||
matchNames []string // function names to match | |||
doPipe bool // read paths from stdin | |||
doSkipMissing bool // do not fail for missing files | |||
) | |||
func init() { | |||
flag.Var(stringList{&matchNames}, "match", `Comma-separated function names to select ("" for all)`) | |||
flag.BoolVar(&doPipe, "pipe", false, "Read paths from stdin, one per line") | |||
flag.BoolVar(&doSkipMissing, "skip-missing", false, "Ignore input paths that are not found") | |||
flag.Usage = func() { | |||
fmt.Fprintf(os.Stderr, `Usage: %[1]s [options] path... : process the named source files | |||
%[1]s [options] -pipe : process paths from stdin | |||
Scan the specified Go source files for calls to built-in functions, and print a | |||
log of those calls to stdout. | |||
With -pipe, the program reads paths from stdin, one path per line. | |||
In this case, paths given on the command-line are consumed first. | |||
Options: | |||
`, filepath.Base(os.Args[0])) | |||
flag.PrintDefaults() | |||
} | |||
} | |||
func main() { | |||
flag.Parse() | |||
// The default input consists of the command-line arguments. | |||
// If -pipe is given, also concatenate the contents of stdin. | |||
var in io.Reader = strings.NewReader(strings.Join(flag.Args(), "\n")) | |||
if doPipe { | |||
in = io.MultiReader(in, os.Stdin) | |||
} | |||
lines := bufio.NewScanner(in) | |||
for lines.Scan() { | |||
mustProcessFile(lines.Text()) | |||
} | |||
if err := lines.Err(); err != nil { | |||
log.Fatalf("Error scanning: %v", err) | |||
} | |||
} | |||
func mustProcessFile(path string) { | |||
f, err := os.Open(path) | |||
if os.IsNotExist(err) && doSkipMissing { | |||
log.Printf("File not found: %q [skipped]", path) | |||
return | |||
} else if err != nil { | |||
log.Fatal(err) | |||
} | |||
defer f.Close() | |||
if err := bicall.Parse(f, path, func(c bicall.Call) error { | |||
if !wantFunction(c.Name) { | |||
return nil | |||
} | |||
bits, err := json.Marshal(struct { | |||
Name string `json:"name"` | |||
Path string `json:"path"` | |||
Line int `json:"line"` | |||
Col int `json:"col"` | |||
Com []string `json:"comments"` | |||
}{ | |||
Name: c.Name, | |||
Path: c.Site.Filename, | |||
Line: c.Site.Line, | |||
Col: c.Site.Column - 1, | |||
Com: c.Comments, | |||
}) | |||
if err != nil { | |||
log.Fatalf("Marshaling output: %v", err) | |||
} | |||
fmt.Println(string(bits)) | |||
return nil | |||
}); err != nil { | |||
log.Fatalf("Parsing %q failed: %v", path, err) | |||
} | |||
} | |||
func wantFunction(name string) bool { | |||
if len(matchNames) == 0 { | |||
return true | |||
} | |||
for _, want := range matchNames { | |||
if want == name { | |||
return true | |||
} | |||
} | |||
return false | |||
} | |||
type stringList struct{ v *[]string } | |||
func (s stringList) Set(v string) error { | |||
ss := strings.Split(v, ",") | |||
if len(ss) == 1 && ss[0] == "" { | |||
*s.v = nil | |||
} else { | |||
*s.v = ss | |||
} | |||
return nil | |||
} | |||
func (s stringList) String() string { | |||
if s.v == nil { | |||
return "" | |||
} | |||
return strings.Join(*s.v, ",") | |||
} |