go/typesパッケージを使い変数名をリネームしてみる #golang
January 5, 2017
この記事はQiitaの記事をエクスポートしたものです。内容が古くなっている可能性があります。
はじめに
Goには、gorename
という識別子をリネームするリファクタリングツールがあります。
以下のようにgo get
することで使用することができます。
$ golang.org/x/tools/cmd/gorename
また、「gorenameをライブラリとして使う」とう記事にも書いたとおり、gorename
はライブラリとしても利用することができます。
この記事では、gorename
ほどは高機能では無いものの、ローカル変数のリネームをgo/types
パッケージの提供する機能で実装してみます。
なお、この記事を書いた時点のGoの最新バージョンは1.7.4です。
指定した位置にある識別子を取得する
gorename
もそうですが、多くのリファクタリングツールでは、「何」を対象にリファクタリングを行うか、以下の2種類の方法で指定することが多いでしょう。
- ずばり対象とするものを指定(この場合だと対象となる識別子)
- 対象となるもののソースコード上の位置
ソースコード上の位置は、エディタから利用することを考えると、何行何列目という指定より、ファイルの先頭からのオフセット(何バイト目)を指定することが多いでしょう。
さて、ソースコードから抽象構文木(AST)を取得し、指定した位置(ファイルの先頭からのオフセット)のノードを取得することを考えてみましょう。
まずは、ASTを取得します。
いつも通り、「ASTを取得する方法を調べる」という記事で解説した方法で、以下のように*ast.File
を取得することができます。
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "sample.go", src, 0)
if err != nil {
log.Fatalln("Error:", err)
}
なお、今回対象としているソースコードは以下のようなコードです。
package main
func main() {
msg := "Hello,"
msg += " World"
println(msg)
}
ASTのノードの位置は、token.Pos
型で表されます。
これは、対象とするファイルすべてで一意にノードの位置を決める値となっており、token.FileSet
構造体によって管理されます。
そのため、token.FileSet
構造体から対象とするファイル上の先頭からのオフセットを指定することで、token.Pos
型の値を取得する必要があります。
今回は、sample.go
という名前でソースコードをパースしているので、このファイル上での位置をtoken.Pos
型として取得することにします。
// 47バイト目 = msg
const p = 47
var pos token.Pos
fset.Iterate(func(f *token.File) bool {
if f.Name() == "sample.go" {
pos = f.Pos(p)
return false
}
return true
})
ここでは、sample.go
の先頭から47
バイト目、つまり、msg += " World"
のm
の後ろを指定しています。
まず、*token.FileSet
型のIterate
メソッドを使うことにより、すべてのファイルをイテレーションして、目的のsample.go
を見つけ出します。
そして、sample.go
を表す*token.File
型の値から、Pos
メソッドを使用することにより、ファイル上のオフセットからtoken.FileSet
上のtoken.Pos
型の値へと変換を行っています。
さて、位置が取得できたので、次は対象となる識別子を取り出してみましょう。
上述のとおり、今回はmsg += " World"
のm
の後ろを指定しているので、main
関数内のmsg
が対象となります。
AST上の中から指定した位置のノードを探し行きます。
ここでは、「抽象構文木(AST)をトラバースする」という記事で紹介した、ast.Inspect
関数を用いて、対象となるノードを探します。
token.Pos
型の値は、token.FileSet
上で一意にノードの位置を指定する値です。
これは、ファイルごとのベースとなる値に、ファイルの先頭からのオフセットを足し合わせたものなので、ファイル内であれば、単純に<
や>
で比較することができます。
つまり、指定した位置のノードを探すには、以下のようにすれば見つけることができます。
var ident *ast.Ident
ast.Inspect(f, func(n ast.Node) bool {
if n == nil || pos < n.Pos() || pos > n.End() {
return true
}
if n, ok := n.(*ast.Ident); ok {
ident = n
return false
}
return true
})
なお、この記事では、識別子のリネームを対象としているので、識別子を表す*ast.Ident
型の値としてキャストしています。
さぁ、リネーム対象の識別子が取得できたところで、どうリネームしていけばいいか考えましょう。
識別子のリネーム
変数名や関数名などの識別子のリネームで問題になるのは、どの識別子がリネームの対象になるかです。
同じスコープで同じ名前の識別子を定義または使用している箇所を探していく必要があります。
ここではローカル変数のリネームだけを対象にしているため、単純に同じ名前かつ同じスコープの*ast.Ident
型のノードをAST上から探してやれば良いでしょう。
gorename
では、ローカル変数のリネーム以外にも、エクスポートされた識別子やフィールドやメソッドのリネームなども対象としているので、同じ機能を実装するには今回紹介する方法だけでは不十分です。
さて、ローカル変数のリネームを行っていきましょう。
AST上に登場する*ast.Ident
型のノードが指す識別子がどのスコープで定義されたものかを解析するには、go/types
パッケージを使用する必要があります。
「Goのスコープについて考えてみよう」という記事で少しだけ触れましたが、*types.Config
型のCheck
メソッドを使用すると、識別子の定義情報や使用情報を取得することができます。
定義情報や使用情報を取得するには、Check
メソッドの引数に渡す*types.Info
型の値のUses
フィールドとDefs
フィールドを初期化する必要があります。
Check
メソッドは、types.Info
構造体のフィールドのうち、マップが初期化されている情報だけを解析して、結果を設定してくれます。
つまり、上記の処理は以下のように書くことができます。
conf := &types.Config{
Importer: importer.Default(),
}
info := &types.Info{
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
}
_, err = conf.Check("main", fset, []*ast.File{f}, info)
if err != nil {
log.Fatalln("Error:", err)
}
type.Config
構造体のImporter
フィールドを指定していますが、対象とするコードでfmt
パッケージなど他のパッケージをインポートするコードを書くと、The Go Playgroundで動作しないので注意してください。
さて、これでinfo.Uses
とinfo.Defs
から識別子を使用情報と定義情報が取得できます。
ここでinfo.Uses
とinfo.Defs
はmap[*ast.Ident]types.Object
という型のマップです。
キーは、識別子である*ast.Ident
型の値で、値はtypes.Object
型の値です。
types.Object
型はインタフェースで、以下のように定義されています。
type Object interface {
Parent() *Scope // scope in which this object is declared
Pos() token.Pos // position of object identifier in declaration
Pkg() *Package // nil for objects in the Universe scope and labels
Name() string // package local object name
Type() Type // object type
Exported() bool // reports whether the name starts with a capital letter
Id() string // object id (see Id below)
// String returns a human-readable string of the object.
String() string
// contains filtered or unexported methods
}
Parent
メソッドを使うことで、識別子が定義されたスコープを取得することができ、対象となるノードと同じスコープを持つ*ast.Ident
型のノードの名前をリネームしていけば良さそうです。
上記の処理をコードにまとめると以下のようになります。
from := ident.Name
const to = "message"
fmt.Println(ident, "->", to)
obj := info.Defs[ident]
if obj == nil {
obj = info.Uses[ident]
}
// Defs
fmt.Println("== Defs ==")
for i, o := range info.Defs {
if i.Name == from && o.Parent() == obj.Parent() {
fmt.Println(fset.Position(i.Pos()))
i.Name = to
}
}
// Uses
fmt.Println("== Uses ==")
for i, o := range info.Uses {
if i.Name == from && o.Parent() == obj.Parent() {
fmt.Println(fset.Position(i.Pos()))
i.Name = to
}
}
変更前の変数名は、ident.Name
から別の変数(ここではfrom
)に入れておかないと、リネーム中に元のノード(ここではident
)の識別子名も変わってしまうので、注意が必要です。
さて、これでリネームするコードは完成しました。
最後にリネーム結果を「抽象構文木(AST)をいじってフォーマットをかける 」という記事でも紹介した、format.Node
関数を使って出力してみましょう。
では、ここまでのコードをすべて載せます。
package main
import (
"fmt"
"go/ast"
"go/format"
"go/importer"
"go/parser"
"go/token"
"go/types"
"log"
"os"
)
// sample.go
const src = `package main
func main() {
msg := "Hello,"
msg += " World"
println(msg)
}`
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "sample.go", src, 0)
if err != nil {
log.Fatalln("Error:", err)
}
conf := &types.Config{
Importer: importer.Default(),
}
info := &types.Info{
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
}
_, err = conf.Check("main", fset, []*ast.File{f}, info)
if err != nil {
log.Fatalln("Error:", err)
}
// 47バイト目 = msg
const p = 47
var pos token.Pos
fset.Iterate(func(f *token.File) bool {
if f.Name() == "sample.go" {
pos = f.Pos(p)
return false
}
return true
})
var ident *ast.Ident
ast.Inspect(f, func(n ast.Node) bool {
if n == nil || pos < n.Pos() || pos > n.End() {
return true
}
if n, ok := n.(*ast.Ident); ok {
ident = n
return false
}
return true
})
from := ident.Name
const to = "message"
fmt.Println(ident, "->", to)
obj := info.Defs[ident]
if obj == nil {
obj = info.Uses[ident]
}
// Defs
fmt.Println("== Defs ==")
for i, o := range info.Defs {
if i.Name == from && o.Parent() == obj.Parent() {
fmt.Println(fset.Position(i.Pos()))
i.Name = to
}
}
// Uses
fmt.Println("== Uses ==")
for i, o := range info.Uses {
if i.Name == from && o.Parent() == obj.Parent() {
fmt.Println(fset.Position(i.Pos()))
i.Name = to
}
}
fmt.Println()
fmt.Println("==== Before ====")
fmt.Println(src)
fmt.Println()
fmt.Println("==== After ====")
format.Node(os.Stdout, fset, f)
}
このコードはThe Go Playground上でも実行可能です。 実行すると以下のような結果が得られるでしょう。
msg -> message
== Defs ==
sample.go:4:2
== Uses ==
sample.go:5:2
sample.go:6:10
==== Before ====
package main
func main() {
msg := "Hello,"
msg += " World"
println(msg)
}
==== After ====
package main
func main() {
message := "Hello,"
message += " World"
println(message)
}
==== After ====
以下を見ると、きちんとmsg
がmessage
に変更されているのが分かるでしょう。
ちなみに、以下のようなコードに置き換えてもうまくリネームされることが分かるかと思います。
package main
var msg = "hoge"
func main() {
msg := "Hello,"
msg += " World"
println(msg)
}
msg -> message
== Defs ==
sample.go:6:2
== Uses ==
sample.go:7:2
sample.go:8:10
==== Before ====
package main
var msg = "hoge"
func main() {
msg := "Hello,"
msg += " World"
println(msg)
}
==== After ====
package main
var msg = "hoge"
func main() {
message := "Hello,"
message += " World"
println(message)
}
なお、上記のコードもThe Go Playground上で実行できます。
ちなみに、リネームする識別子の位置はファイルの先頭から65
バイト目に変更してあります。
おわりに
この記事では、go/types
パッケージを使って、ローカル変数をリネームするコードを実装してみました。
今回紹介したリネームは完全ではないため、自作のリファクタリングツールでリネームする場合には、gorename
をライブラリとして使う方が良いとは思います。
しかしながら、types.Info
構造体のDefs
フィールドやUses
フィールドをうまく使うことで、今までにない新しいツールが作れそうな気がします。
ぜひ、みなさんも何か作ってみてください。