Skip to content

Commit 5cfc85b

Browse files
committed
Implement a new analysis runner and improve U1000
This commit completely replaces the analysis runner of Staticcheck. It fixes several performance shortcomings, as well as subtle bugs in U1000. To explain the behaviors of the old and new runners, assume that we're processing a package graph that looks like this: A ↙ ↘ B C ↓ ⋮ ↓ X Package A is the package we wish to check. Packages B and C are direct dependencies of A, and X is an indirect dependency of B, with potentially many packages between B and X In the old runner, we would process the graph in a single DFS pass. We would start processing A, see that it needed B and C, start loading B and C, and so forth. This approach would unnecessarily increase memory usage. Package C would be held in memory, ready to be used by A, while the long chain from X to B was being processed. Furthermore, A may not need most of C's data in the first place, if A was already fully cached. Furthermore, processing the graph top to bottom is harder to parallelize efficiently. The new runner, in contrast, first materializes the graph (the planning phase) and then executes it from the bottom up (the execution phase). Whenever a leaf node finishes execution, its data would be cached on disk, then unloaded from memory. The only data that will be kept in memory is the package's hash, so that its dependents can compute their own hashes. Next, all dependents that are ready to run (i.e. that have no more unprocessed leaf nodes) will be executed. If the dependent decides that it needs information of its dependencies, it loads them from disk again. This approach drastically reduces peak memory usage, at a slight increase in CPU usage because of repeated loading of data. However, knowing the full graph allows for more efficient parallelization, offsetting the increased CPU cost. It also favours the common case, where most packages will have up to date cached data. Changes to unused The 'unused' check (U1000 and U1001) has always been the odd one out. It is the only check that propagates information backwards in the import graph – that is, the sum of dependents determines which objects in a package are considered used. Due to tests and test variants, this applies even when not operating in whole-program mode. The way we implemented this was not only expensive – whole-program mode in particular needed to retain type information for all packages – it was also subtly wrong. Because we cached all diagnostics of a package, we cached stale 'unused' diagnostics when a dependent changed. As part of writing the new analysis runner, we make several changes to 'unused' that make sure it behaves well and doesn't negate the performance improvements of the new runner. The most obvious change is the removal of whole-program mode. The combination of correct caching and efficient cache usage means that we no longer have access to the information required to compute a whole-program solution. It never worked quite right, anyway, being unaware of reflection, and having to grossly over-estimate the set of used methods due to interfaces. The normal mode of 'unused' now considers all exported package-level identifiers as used, even if they are declared within tests or package main. Treating exported functions in package main unused has been wrong ever since the addition of the 'plugin' build mode. Doing so in tests may have been mostly correct (ignoring reflection), but continuing to do so would complicate the implementation for little gain. In the new implementation, the per-package information that is cached for U1000 consists of two lists: the list of used objects and the list of unused objects. At the end of analysis, the lists of all packages get merged: if any package uses an object, it is considered used. Otherwise, if any package didn't use an object, it is considered unused. This list-based approach is only correct if the usedness of an exported object in one package doesn't depend on another package. Consider the following package layout: foo.go: package pkg func unexported() {} export_test.go package pkg func Exported() { unexported() } external_test.go package pkg_test import "pkg" var _ = pkg.Exported This layout has three packages: pkg, pkg [test] and pkg_test. Under unused's old logic, pkg_test would be responsible for marking pkg [test]'s Exported as used. This would transitively mark 'unexported' as used, too. However, with our list-based approach, we would get the following lists: pkg: used: unused: unexported pkg [test]: used: unused: unexported, Exported pkg_test: used: Exported unused: Merging these lists, we would never know that 'unexported' was used. Instead of using these lists, we would need to cache and resolve full graphs. This problem does not exist for unexported objects. If a package is able to use an unexported object, it must exist within the same package, which means it can internally resolve the package's graph before generating the lists. For completeness, these are the correct lists: pkg: used: unused: unexported pkg [test]: used: Exported, unexported unused: pkg_test: used: Exported unused: (The inclusion of Exported in pkg_test is superfluous and may be optimized away at some point.) As part of porting unused's tests, we discovered a flaky false negative, caused by an incorrect implementation of our version of types.Identical. We were still using types.Identical under the hood, which wouldn't correctly account for nested types. This has been fixed. Finally, two improvements to U1000 have been made. 1. We no longer hide unused methods of unused types. This would sometimes confuse users who would see an unused type, remove just the type, then be confronted with compile time errors because of lingering methods. 2. //lint:ignore is no longer purely a post-processing step. U1000 is aware of ignore directives and uses them to actively mark objects as used. This means that if an unused function uses an object, and is //lint:ignore'd, the object it uses will transitively be marked used. Closes gh-233 Closes gh-284 Closes gh-476 Closes gh-538 Closes gh-576 Closes gh-671 Closes gh-675 Closes gh-690 Closes gh-691
1 parent 83cac16 commit 5cfc85b

File tree

73 files changed

+3795
-2927
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+3795
-2927
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
strategy:
88
matrix:
99
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
10-
go: ["1.12.x", "1.13.x"]
10+
go: ["1.13.x", "1.14.x"]
1111
runs-on: ${{ matrix.os }}
1212
steps:
1313
- uses: actions/checkout@v1

cmd/staticcheck/staticcheck.go

+4-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77

88
"golang.org/x/tools/go/analysis"
9-
"honnef.co/go/tools/lint"
109
"honnef.co/go/tools/lint/lintutil"
1110
"honnef.co/go/tools/simple"
1211
"honnef.co/go/tools/staticcheck"
@@ -16,7 +15,6 @@ import (
1615

1716
func main() {
1817
fs := lintutil.FlagSet("staticcheck")
19-
wholeProgram := fs.Bool("unused.whole-program", false, "Run unused in whole program mode")
2018
debug := fs.String("debug.unused-graph", "", "Write unused's object graph to `file`")
2119
fs.Parse(os.Args[1:])
2220

@@ -31,14 +29,14 @@ func main() {
3129
cs = append(cs, v)
3230
}
3331

34-
u := unused.NewChecker(*wholeProgram)
32+
cs = append(cs, unused.Analyzer)
3533
if *debug != "" {
3634
f, err := os.OpenFile(*debug, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
3735
if err != nil {
3836
log.Fatal(err)
3937
}
40-
u.Debug = f
38+
unused.Debug = f
4139
}
42-
cums := []lint.CumulativeChecker{u}
43-
lintutil.ProcessFlagSet(cs, cums, fs)
40+
41+
lintutil.ProcessFlagSet(cs, fs)
4442
}

code/code.go

+57-5
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
package code
33

44
import (
5+
"bytes"
56
"flag"
67
"fmt"
78
"go/ast"
89
"go/constant"
910
"go/token"
1011
"go/types"
1112
"strings"
13+
"sync"
1214

1315
"golang.org/x/tools/go/analysis"
1416
"golang.org/x/tools/go/analysis/passes/inspect"
@@ -17,9 +19,55 @@ import (
1719
"honnef.co/go/tools/facts"
1820
"honnef.co/go/tools/go/types/typeutil"
1921
"honnef.co/go/tools/ir"
20-
"honnef.co/go/tools/lint"
2122
)
2223

24+
var bufferPool = &sync.Pool{
25+
New: func() interface{} {
26+
buf := bytes.NewBuffer(nil)
27+
buf.Grow(64)
28+
return buf
29+
},
30+
}
31+
32+
func FuncName(f *types.Func) string {
33+
buf := bufferPool.Get().(*bytes.Buffer)
34+
buf.Reset()
35+
if f.Type() != nil {
36+
sig := f.Type().(*types.Signature)
37+
if recv := sig.Recv(); recv != nil {
38+
buf.WriteByte('(')
39+
if _, ok := recv.Type().(*types.Interface); ok {
40+
// gcimporter creates abstract methods of
41+
// named interfaces using the interface type
42+
// (not the named type) as the receiver.
43+
// Don't print it in full.
44+
buf.WriteString("interface")
45+
} else {
46+
types.WriteType(buf, recv.Type(), nil)
47+
}
48+
buf.WriteByte(')')
49+
buf.WriteByte('.')
50+
} else if f.Pkg() != nil {
51+
writePackage(buf, f.Pkg())
52+
}
53+
}
54+
buf.WriteString(f.Name())
55+
s := buf.String()
56+
bufferPool.Put(buf)
57+
return s
58+
}
59+
60+
func writePackage(buf *bytes.Buffer, pkg *types.Package) {
61+
if pkg == nil {
62+
return
63+
}
64+
s := pkg.Path()
65+
if s != "" {
66+
buf.WriteString(s)
67+
buf.WriteByte('.')
68+
}
69+
}
70+
2371
type Positioner interface {
2472
Pos() token.Pos
2573
}
@@ -34,7 +82,7 @@ func CallName(call *ir.CallCommon) string {
3482
if !ok {
3583
return ""
3684
}
37-
return lint.FuncName(fn)
85+
return FuncName(fn)
3886
case *ir.Builtin:
3987
return v.Name()
4088
}
@@ -244,12 +292,12 @@ func CallNameAST(pass *analysis.Pass, call *ast.CallExpr) string {
244292
if !ok {
245293
return ""
246294
}
247-
return lint.FuncName(fn)
295+
return FuncName(fn)
248296
case *ast.Ident:
249297
obj := pass.TypesInfo.ObjectOf(fun)
250298
switch obj := obj.(type) {
251299
case *types.Func:
252-
return lint.FuncName(obj)
300+
return FuncName(obj)
253301
case *types.Builtin:
254302
return obj.Name()
255303
default:
@@ -472,7 +520,11 @@ func MayHaveSideEffects(pass *analysis.Pass, expr ast.Expr, purity facts.PurityR
472520
}
473521

474522
func IsGoVersion(pass *analysis.Pass, minor int) bool {
475-
version := pass.Analyzer.Flags.Lookup("go").Value.(flag.Getter).Get().(int)
523+
f, ok := pass.Analyzer.Flags.Lookup("go").Value.(flag.Getter)
524+
if !ok {
525+
panic("requested Go version, but analyzer has no version flag")
526+
}
527+
version := f.Get().(int)
476528
return version >= minor
477529
}
478530

facts/directives.go

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package facts
2+
3+
import (
4+
"go/ast"
5+
"go/token"
6+
"path/filepath"
7+
"reflect"
8+
"strings"
9+
10+
"golang.org/x/tools/go/analysis"
11+
)
12+
13+
// A directive is a comment of the form '//lint:<command>
14+
// [arguments...]'. It represents instructions to the static analysis
15+
// tool.
16+
type Directive struct {
17+
Command string
18+
Arguments []string
19+
Directive *ast.Comment
20+
Node ast.Node
21+
}
22+
23+
type SerializedDirective struct {
24+
Command string
25+
Arguments []string
26+
// The position of the comment
27+
DirectivePosition token.Position
28+
// The position of the node that the comment is attached to
29+
NodePosition token.Position
30+
}
31+
32+
func parseDirective(s string) (cmd string, args []string) {
33+
if !strings.HasPrefix(s, "//lint:") {
34+
return "", nil
35+
}
36+
s = strings.TrimPrefix(s, "//lint:")
37+
fields := strings.Split(s, " ")
38+
return fields[0], fields[1:]
39+
}
40+
41+
func directives(pass *analysis.Pass) (interface{}, error) {
42+
return ParseDirectives(pass.Files, pass.Fset), nil
43+
}
44+
45+
func ParseDirectives(files []*ast.File, fset *token.FileSet) []Directive {
46+
var dirs []Directive
47+
for _, f := range files {
48+
// OPT(dh): in our old code, we skip all the commentmap work if we
49+
// couldn't find any directives, benchmark if that's actually
50+
// worth doing
51+
cm := ast.NewCommentMap(fset, f, f.Comments)
52+
for node, cgs := range cm {
53+
for _, cg := range cgs {
54+
for _, c := range cg.List {
55+
if !strings.HasPrefix(c.Text, "//lint:") {
56+
continue
57+
}
58+
cmd, args := parseDirective(c.Text)
59+
d := Directive{
60+
Command: cmd,
61+
Arguments: args,
62+
Directive: c,
63+
Node: node,
64+
}
65+
dirs = append(dirs, d)
66+
}
67+
}
68+
}
69+
}
70+
return dirs
71+
}
72+
73+
// duplicated from report.DisplayPosition to break import cycle
74+
func displayPosition(fset *token.FileSet, p token.Pos) token.Position {
75+
if p == token.NoPos {
76+
return token.Position{}
77+
}
78+
79+
// Only use the adjusted position if it points to another Go file.
80+
// This means we'll point to the original file for cgo files, but
81+
// we won't point to a YACC grammar file.
82+
pos := fset.PositionFor(p, false)
83+
adjPos := fset.PositionFor(p, true)
84+
85+
if filepath.Ext(adjPos.Filename) == ".go" {
86+
return adjPos
87+
}
88+
89+
return pos
90+
}
91+
92+
var Directives = &analysis.Analyzer{
93+
Name: "directives",
94+
Doc: "extracts linter directives",
95+
Run: directives,
96+
RunDespiteErrors: true,
97+
ResultType: reflect.TypeOf([]Directive{}),
98+
}
99+
100+
func SerializeDirective(dir Directive, fset *token.FileSet) SerializedDirective {
101+
return SerializedDirective{
102+
Command: dir.Command,
103+
Arguments: dir.Arguments,
104+
DirectivePosition: displayPosition(fset, dir.Directive.Pos()),
105+
NodePosition: displayPosition(fset, dir.Node.Pos()),
106+
}
107+
}

go/types/typeutil/callee_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func noncalls() {
6363
Uses: make(map[*ast.Ident]types.Object),
6464
Selections: make(map[*ast.SelectorExpr]*types.Selection),
6565
}
66-
cfg := &types.Config{Importer: importer.For("source", nil)}
66+
cfg := &types.Config{Importer: importer.ForCompiler(fset, "source", nil)}
6767
if _, err := cfg.Check("p", fset, []*ast.File{f}, info); err != nil {
6868
t.Fatal(err)
6969
}

0 commit comments

Comments
 (0)