はじめに
こんにちは、LayerXの id:convto です。
そしてこれは LayerX アドベントカレンダー (概念) の1日目の記事です。
アドベントカレンダー盛り上げていくぞ〜ということで11月から始まるらしいです。だいぶフライングしてるけど枠もかなり埋まっててすごい。
せっかくなのでお祭り参加したいぞ〜ということで一発目です。よろしくお願いします。
静的解析つくろうとしたきっかけ
ちょうどつい最近記事になった下記の輪読会がきっかけでした。
このなかで、mapのrange accesssについて、元mapのcopyを取らないから破壊するとループ挙動が壊れる可能性がある旨が言及されていました。
そのときは雑談で「range accessしてるmapに再代入してたら怒る!みたいな考え方で整理したら静的解析できそうっすよね〜」みたいな話をしたんですが、そういえば Go で静的解析ツール作ったことなかったなと思いちょうどいいので手を動かしてみた次第です。
Go はこのあたりのエコシステムがかなり良くできていて、標準/準標準で以下のようにかなりのパッケージがホストされています。(まだまだほかにもある)
- go/ast
- go/parser
- go/token
- go/types
- x/tools/go/analysis
- x/tools/go/ast
- x/tools/go/packages
- x/tools/go/types
また、go/typesについてはめちゃめちゃよいドキュメントもあります。
イメージ的にはgo/astよりさらに抽象的なAPIというかんじで、実際のASTからより利用しやすい形でいろいろ提供してくれてるやつです。
エコシステムも整っていて、かつちょうどいいモチベーションもあったので、それはやるしかないでしょうということでやってみました。
つくったもの
やりたかったこと
発想としてはmapに対してfor rangeしてるときに、ループ内で自身のmapを書き換えてるところを見つけたら怒る!という感じです。
mapのサイズが変わるようなコードでなければloopの挙動自体には影響出ないかもしれませんが、今回は簡単のために代入時点で警告を出すロジックで考えます。
典型的には以下のようなケースですね。
m := map[string]string{"a": "item a", "b": "item b"} for k, v := range m { m[k] = "reassigned: " + v // want detection }
基本は hoge[key] = val
の形になってるとき hoge がrangeでぶん回してるmap自身だったら危険性のある書き込みということで警告すればよいはずです。
この書き方だけではarray/sliceへの代入か判断できないので、うまく静的解析して型情報を取り出してmapのみに限定した処理としたいです。
また、パッケージを跨いだ以下のようなパターンでも検出も行いたいので、必要があれば依存関係の解決もする必要があるのが面倒なところです。
-- go.mod -- module maptest go 1.21 -- pkg/map.go -- package pkg var M = map[string]string{"a": "item a", "b": "item b"} -- main.go -- package main import ( "maptest/pkg" ) func main() { for k, v := range pkg.M { pkg.M[k] = "reassigned: " + v // want detection } }
x/tools/go/analysis を使って側をつくる
x/tools/go/analysis
は静的解析の結果をモジュール化して依存関係を明言しながら解析処理を作ったり、 go vet
に食わせられるコマンドをシュッとつくれたりするえらいやつです。
過去の事例もめっちゃあるのでここの紹介はほどほどにしますが、以下みたいなノリでAnalizerを作れます。
package mapbreak import ( "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" ) var Analyzer = &analysis.Analyzer{ Name: "mapbreak", Doc: Doc, Run: run, Requires: []*analysis.Analyzer{ inspect.Analyzer, }, } const Doc = "mapbreak detects if there is map reassignment in the range access" func run(pass *analysis.Pass) (interface{}, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.RangeStmt)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { // ここに君だけの最強の静的解析処理を書こう } } }
あいたところに君だけの最強の処理を書けばOKです。
"golang.org/x/tools/go/analysis/passes"
には便利げなAnalizerがいろいろ準備されてて、今回使ってるpasses/inspectはよしなにASTのinspectorを返してくれます。
今回つかってる inspcet.Preorder()
は特定のノード種別を指定して探索してくれるやつで、まあ静的解析やりたい時は何かしら目的があるだろうから大体これ使っとけばじゃないかという感じがあります。
go vet に渡せるコマンドはこういう感じで作れます。
package main import ( "github.com/convto/mapbreak" "golang.org/x/tools/go/analysis/singlechecker" ) func main() { singlechecker.Main(mapbreak.Analyzer) }
main関数に突っ込んで準備されてるやつに食わせるだけです。便利!
これで以下の感じで go vet
に食わせられます。
$ go vet -vettool=$(which mapbreak) ./...
テストの準備をする
よしななテストの機能も提供されています。それが以下です。
testdata
配下によしなに対象となるコードを配置できて、いい感じに探索してくれる優れものです。
ディレクトリ名でpatternもわけられるので、以下のようにテストが書けます。
func Test(t *testing.T) { testdata := analysistest.TestData() patterns := []string{ "target_pattern", } for _, pattern := range patterns { pattern := pattern t.Run(pattern, func(t *testing.T) { t.Parallel() analysistest.Run(t, testdata, mapbreak.Analyzer, pattern) }) } }
また go.modがあるとgo modulesを利用する挙動 なので、パッケージをimportするようなテストも書けます。(めちゃundocumentedでもう少しよくしたい〜というコメントもあるのでそのうちやり方が変わる可能性あり)
testdata
配下にpatternに一致するようなディレクトリをつくりテストで解析対象としたい Go コードを書けばよいです。
そのさい、検出を期待する場合は // want: "message"
のように書くことでテストできます。
package main func main() { m := map[string]string{"a": "item a", "b": "item b"} for k, v := range m { m[k] = "reassigned: " + v // want "detected range access to map and reassigning" } }
これでテストの準備は万端!ではやっていこう!
ASTを眺めて実装のイメージを固める
以下を使うと実際のCLIで実際のコードのASTが見れます。
ためしに以下のコードで確認すると
m := map[string]string{"a": "item a", "b": "item b"} for k, v := range m { m[k] = "reassigned: " + v }
こういう感じの出力が出ます。(長いので畳みます)
gotype -ast
の実行結果
$ gotype -ast singlefile.go 0 *ast.File { 1 . Package: singlefile.go:1:1 2 . Name: *ast.Ident { 3 . . NamePos: singlefile.go:1:9 4 . . Name: "main" 5 . } 6 . Decls: []ast.Decl (len = 1) { 7 . . 0: *ast.FuncDecl { 8 . . . Name: *ast.Ident { 9 . . . . NamePos: singlefile.go:3:6 10 . . . . Name: "main" 11 . . . . Obj: *ast.Object { 12 . . . . . Kind: func 13 . . . . . Name: "main" 14 . . . . . Decl: *(obj @ 7) 15 . . . . } 16 . . . } 17 . . . Type: *ast.FuncType { 18 . . . . Func: singlefile.go:3:1 19 . . . . Params: *ast.FieldList { 20 . . . . . Opening: singlefile.go:3:10 21 . . . . . Closing: singlefile.go:3:11 22 . . . . } 23 . . . } 24 . . . Body: *ast.BlockStmt { 25 . . . . Lbrace: singlefile.go:3:13 26 . . . . List: []ast.Stmt (len = 2) { 27 . . . . . 0: *ast.AssignStmt { 28 . . . . . . Lhs: []ast.Expr (len = 1) { 29 . . . . . . . 0: *ast.Ident { 30 . . . . . . . . NamePos: singlefile.go:4:2 31 . . . . . . . . Name: "m" 32 . . . . . . . . Obj: *ast.Object { 33 . . . . . . . . . Kind: var 34 . . . . . . . . . Name: "m" 35 . . . . . . . . . Decl: *(obj @ 27) 36 . . . . . . . . } 37 . . . . . . . } 38 . . . . . . } 39 . . . . . . TokPos: singlefile.go:4:4 40 . . . . . . Tok: := 41 . . . . . . Rhs: []ast.Expr (len = 1) { 42 . . . . . . . 0: *ast.CompositeLit { 43 . . . . . . . . Type: *ast.MapType { 44 . . . . . . . . . Map: singlefile.go:4:7 45 . . . . . . . . . Key: *ast.Ident { 46 . . . . . . . . . . NamePos: singlefile.go:4:11 47 . . . . . . . . . . Name: "string" 48 . . . . . . . . . } 49 . . . . . . . . . Value: *ast.Ident { 50 . . . . . . . . . . NamePos: singlefile.go:4:18 51 . . . . . . . . . . Name: "string" 52 . . . . . . . . . } 53 . . . . . . . . } 54 . . . . . . . . Lbrace: singlefile.go:4:24 55 . . . . . . . . Elts: []ast.Expr (len = 2) { 56 . . . . . . . . . 0: *ast.KeyValueExpr { 57 . . . . . . . . . . Key: *ast.BasicLit { 58 . . . . . . . . . . . ValuePos: singlefile.go:4:25 59 . . . . . . . . . . . Kind: STRING 60 . . . . . . . . . . . Value: "\"a\"" 61 . . . . . . . . . . } 62 . . . . . . . . . . Colon: singlefile.go:4:28 63 . . . . . . . . . . Value: *ast.BasicLit { 64 . . . . . . . . . . . ValuePos: singlefile.go:4:30 65 . . . . . . . . . . . Kind: STRING 66 . . . . . . . . . . . Value: "\"item a\"" 67 . . . . . . . . . . } 68 . . . . . . . . . } 69 . . . . . . . . . 1: *ast.KeyValueExpr { 70 . . . . . . . . . . Key: *ast.BasicLit { 71 . . . . . . . . . . . ValuePos: singlefile.go:4:40 72 . . . . . . . . . . . Kind: STRING 73 . . . . . . . . . . . Value: "\"b\"" 74 . . . . . . . . . . } 75 . . . . . . . . . . Colon: singlefile.go:4:43 76 . . . . . . . . . . Value: *ast.BasicLit { 77 . . . . . . . . . . . ValuePos: singlefile.go:4:45 78 . . . . . . . . . . . Kind: STRING 79 . . . . . . . . . . . Value: "\"item b\"" 80 . . . . . . . . . . } 81 . . . . . . . . . } 82 . . . . . . . . } 83 . . . . . . . . Rbrace: singlefile.go:4:53 84 . . . . . . . . Incomplete: false 85 . . . . . . . } 86 . . . . . . } 87 . . . . . } 88 . . . . . 1: *ast.RangeStmt { 89 . . . . . . For: singlefile.go:5:2 90 . . . . . . Key: *ast.Ident { 91 . . . . . . . NamePos: singlefile.go:5:6 92 . . . . . . . Name: "k" 93 . . . . . . . Obj: *ast.Object { 94 . . . . . . . . Kind: var 95 . . . . . . . . Name: "k" 96 . . . . . . . . Decl: *ast.AssignStmt { 97 . . . . . . . . . Lhs: []ast.Expr (len = 2) { 98 . . . . . . . . . . 0: *(obj @ 90) 99 . . . . . . . . . . 1: *ast.Ident { 100 . . . . . . . . . . . NamePos: singlefile.go:5:9 101 . . . . . . . . . . . Name: "v" 102 . . . . . . . . . . . Obj: *ast.Object { 103 . . . . . . . . . . . . Kind: var 104 . . . . . . . . . . . . Name: "v" 105 . . . . . . . . . . . . Decl: *(obj @ 96) 106 . . . . . . . . . . . } 107 . . . . . . . . . . } 108 . . . . . . . . . } 109 . . . . . . . . . TokPos: singlefile.go:5:11 110 . . . . . . . . . Tok: := 111 . . . . . . . . . Rhs: []ast.Expr (len = 1) { 112 . . . . . . . . . . 0: *ast.UnaryExpr { 113 . . . . . . . . . . . OpPos: - 114 . . . . . . . . . . . Op: range 115 . . . . . . . . . . . X: *ast.Ident { 116 . . . . . . . . . . . . NamePos: singlefile.go:5:20 117 . . . . . . . . . . . . Name: "m" 118 . . . . . . . . . . . . Obj: *(obj @ 32) 119 . . . . . . . . . . . } 120 . . . . . . . . . . } 121 . . . . . . . . . } 122 . . . . . . . . } 123 . . . . . . . } 124 . . . . . . } 125 . . . . . . Value: *(obj @ 99) 126 . . . . . . TokPos: singlefile.go:5:11 127 . . . . . . Tok: := 128 . . . . . . Range: singlefile.go:5:14 129 . . . . . . X: *(obj @ 115) 130 . . . . . . Body: *ast.BlockStmt { 131 . . . . . . . Lbrace: singlefile.go:5:22 132 . . . . . . . List: []ast.Stmt (len = 1) { 133 . . . . . . . . 0: *ast.AssignStmt { 134 . . . . . . . . . Lhs: []ast.Expr (len = 1) { 135 . . . . . . . . . . 0: *ast.IndexExpr { 136 . . . . . . . . . . . X: *ast.Ident { 137 . . . . . . . . . . . . NamePos: singlefile.go:6:3 138 . . . . . . . . . . . . Name: "m" 139 . . . . . . . . . . . . Obj: *(obj @ 32) 140 . . . . . . . . . . . } 141 . . . . . . . . . . . Lbrack: singlefile.go:6:4 142 . . . . . . . . . . . Index: *ast.Ident { 143 . . . . . . . . . . . . NamePos: singlefile.go:6:5 144 . . . . . . . . . . . . Name: "k" 145 . . . . . . . . . . . . Obj: *(obj @ 93) 146 . . . . . . . . . . . } 147 . . . . . . . . . . . Rbrack: singlefile.go:6:6 148 . . . . . . . . . . } 149 . . . . . . . . . } 150 . . . . . . . . . TokPos: singlefile.go:6:8 151 . . . . . . . . . Tok: = 152 . . . . . . . . . Rhs: []ast.Expr (len = 1) { 153 . . . . . . . . . . 0: *ast.BinaryExpr { 154 . . . . . . . . . . . X: *ast.BasicLit { 155 . . . . . . . . . . . . ValuePos: singlefile.go:6:10 156 . . . . . . . . . . . . Kind: STRING 157 . . . . . . . . . . . . Value: "\"reassigned: \"" 158 . . . . . . . . . . . } 159 . . . . . . . . . . . OpPos: singlefile.go:6:25 160 . . . . . . . . . . . Op: + 161 . . . . . . . . . . . Y: *ast.Ident { 162 . . . . . . . . . . . . NamePos: singlefile.go:6:27 163 . . . . . . . . . . . . Name: "v" 164 . . . . . . . . . . . . Obj: *(obj @ 102) 165 . . . . . . . . . . . } 166 . . . . . . . . . . } 167 . . . . . . . . . } 168 . . . . . . . . } 169 . . . . . . . } 170 . . . . . . . Rbrace: singlefile.go:7:2 171 . . . . . . } 172 . . . . . } 173 . . . . } 174 . . . . Rbrace: singlefile.go:8:1 175 . . . } 176 . . } 177 . } 178 . FileStart: singlefile.go:1:1 179 . FileEnd: singlefile.go:8:3 180 . Scope: *ast.Scope { 181 . . Objects: map[string]*ast.Object (len = 1) { 182 . . . "main": *(obj @ 11) 183 . . } 184 . } 185 . Unresolved: []*ast.Ident (len = 2) { 186 . . 0: *(obj @ 45) 187 . . 1: *(obj @ 49) 188 . } 189 . GoVersion: "" 190 }
このASTを眺めると
*ast.RangeStmt
が range 文ぽい*ast.RangeStmt.X
に range access 対象がはいってそう*ast.RangeStmt.Body.List
にloop内の文がありそう- ループ内で
*ast.AssignStmt
になっているのが代入そう *ast.AssignStmt.Lhs
が左辺ぽいので、そこからよしなに値を眺めて range access 対象と比較すればよさそう- 同一objectだったら怒れば勝ちそう!
みたいなことがわかります。
実装してみる
このあたりのドキュメントをみると値の比較には go/types.Object
が利用できそうです。
example/gotypes at master · golang/example · GitHub
ふつうに ==
で比較するだけでいいらしい。便利〜
実行ターゲットになってるパッケージのtype infoについては analysis.Pass
に突っ込まれており、識別子から go/types/Object
を引っ張り出す ObjectOf()
が生えてるので *ast.Ident
さえあればよしなに取り出せます
https://cs.opensource.google/go/x/tools/+/refs/tags/v0.14.0:go/analysis/analysis.go;l=99
ポインタとかの場合も考慮してよしなに書くと以下のかんじです。
func getObject(pass *analysis.Pass, x ast.Expr) types.Object { switch x.(type) { case *ast.Ident: return pass.TypesInfo.ObjectOf(x.(*ast.Ident)) case *ast.StarExpr: return getObject(pass, x.(*ast.StarExpr).X) case *ast.ParenExpr: return getObject(pass, x.(*ast.ParenExpr).X) } return nil }
実行ターゲットに含まれてないパッケージでExportedなmapをもってる可能性もあるのでそのあたりもよしなに考慮したいので以下も追加します。
*ast.SelectorExpr
は fmt.Println()
みたいなimportしたパッケージへのアクセスなどに使われます。あとメソッドへのアクセスにも使われます。
analysis.Pass
にはまたまた types.Package
とかいう便利なやつが突っ込まれており、importしてるpackageなどが取れます。
https://cs.opensource.google/go/x/tools/+/refs/tags/v0.14.0:go/analysis/analysis.go;l=98
パッケージの名前 と *ast.SelectorExpr.X.Name
( fmt.Println()
でいう fmt
) の値が一致してたら、該当のパッケージのscopeとして types.Object
をさがしにいく感じにすればよいです。
func getObject(pass *analysis.Pass, x ast.Expr) types.Object { ... case *ast.SelectorExpr: sel := x.(*ast.SelectorExpr) id, ok := sel.X.(*ast.Ident) if !ok { return nil } pkg := id.Name call := sel.Sel.Name imports := pass.Pkg.Imports() for i := range imports { if pkg == imports[i].Name() { return imports[i].Scope().Lookup(call) } } } return nil }
ちなここでつかってる Lookup()
は賢くて、中で既にloadずみのやつをなんども読み取らないように sync.Once
をちゃんと使ってくれているので気にせずしばいてOK
https://cs.opensource.google/go/go/+/refs/tags/go1.21.3:src/go/types/scope.go;l=251-274
あと、今回はmapだけ判断したいので以下みたいな感じでとってきたObjectをmapにできると良さげな感じがします。
rangeTarget := getObject(pass, rstmt.X) if rangeTarget == nil { return } _, ok = rangeTarget.Type().Underlying().(*types.Map) if !ok { return }
ここで rangeTarget.Type()
が pointer のことも考慮すると、型をderefする処理もあると良さそうなので書く
func run(pass *analysis.Pass) (interface{}, error) { ... inspect.Preorder(nodeFilter, func(n ast.Node) { ... _, ok = deref(rangeTarget.Type()).Underlying().(*types.Map) if !ok { return } ... } return nil, nil } ... func deref(typ types.Type) types.Type { if ptr, ok := typ.Underlying().(*types.Pointer); ok { return deref(ptr.Elem()) } return typ }
これでよしなに動くやつになった!
package mapbreak import ( "go/ast" "go/types" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" ) var Analyzer = &analysis.Analyzer{ Name: "mapbreak", Doc: Doc, Run: run, Requires: []*analysis.Analyzer{ inspect.Analyzer, }, } const Doc = "mapbreak detects if there is map reassignment in the range access" func run(pass *analysis.Pass) (interface{}, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.RangeStmt)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { rstmt, ok := n.(*ast.RangeStmt) if !ok { return } rangeTarget := getObject(pass, rstmt.X) if rangeTarget == nil { return } _, ok = deref(rangeTarget.Type()).Underlying().(*types.Map) if !ok { return } for _, stmt := range rstmt.Body.List { assign, ok := stmt.(*ast.AssignStmt) if !ok { continue } idx, ok := assign.Lhs[0].(*ast.IndexExpr) if !ok { continue } assignee := getObject(pass, idx.X) if assignee == rangeTarget { pass.Reportf(stmt.Pos(), "detected range access to map and reassigning it") } } }) return nil, nil } func deref(typ types.Type) types.Type { if ptr, ok := typ.Underlying().(*types.Pointer); ok { return deref(ptr.Elem()) } return typ } func getObject(pass *analysis.Pass, x ast.Expr) types.Object { switch x.(type) { case *ast.Ident: return pass.TypesInfo.ObjectOf(x.(*ast.Ident)) case *ast.StarExpr: return getObject(pass, x.(*ast.StarExpr).X) case *ast.ParenExpr: return getObject(pass, x.(*ast.ParenExpr).X) case *ast.SelectorExpr: sel := x.(*ast.SelectorExpr) id, ok := sel.X.(*ast.Ident) if !ok { return nil } pkg := id.Name call := sel.Sel.Name imports := pass.Pkg.Imports() for i := range imports { if pkg == imports[i].Name() { return imports[i].Scope().Lookup(call) } } } return nil }
さいごに
Go の静的解析、エコシステムが成熟してるのは知ってたけど試したことはなかったのでちょうどよく作業できて良かった!
外部パッケージを含むケースでもよしなにできたりするのはめっちゃ便利で、よくできてるなと思いました。
今回みたいな型を知りたいケースは機械的な文字列一致でやるのは難しいので、高度な判定ができるのはかなりありがたかったです。
また、 https://github.com/golang/example/tree/master/gotypes をみて表現力かなりあることがわかったのでまた遊んでみたいですね!
LayerX アドベントカレンダー (概念) は今日から始まるらしいので引き続き他の記事も楽しみにしててください〜概念概念