はじめに
皆さんはgorename
というツールをご存知でしょうか?
golang.org/x/tools/cmd/gorename
で公開されている、Goのリファクタリングツールです。
ここでは、このツールを自分のコード上でライブラリとして使う方法と使いやすいリファクタリングツールの形について説明できればと思います。
なお、ここで扱う話はVimConf 2016において、エディタの壁を越えるGoの開発ツールの文化と作成法(ビデオ)というタイトルで話したものと、一部重複していますので、そちらもよかったら見て下さい。
gorenameを使ってみる
まずは、gorename
をインストールして使ってみましょう。すでに使ったことある方はここは飛ばしてもらって構いません。
もしかすると、お使いのIDEやエディタによってすでにインストールされているかもしれません。
vim-go
をお使いの方は、:GoRename
で使うことができます。
さて、インストールしてみましょう。お決まりのgo get
でインストールできます。
$ go get -u golang.org/x/tools/cmd/gorename
ヘルプを見て、使い方を調べてみます。
$ gorename -help
gorename: precise type-safe renaming of identifiers in Go source code.
Usage:
gorename (-from <spec> | -offset <file>:#<byte-offset>) -to <name> [-force]
You must specify the object (named entity) to rename using the -offset
or -from flag. Exactly one must be specified.
Flags:
-offset specifies the filename and byte offset of an identifier to rename.
This form is intended for use by text editors.
-from specifies the object to rename using a query notation;
This form is intended for interactive use at the command line.
A legal -from query has one of the following forms:
"encoding/json".Decoder.Decode method of package-level named type
(*"encoding/json".Decoder).Decode ditto, alternative syntax
"encoding/json".Decoder.buf field of package-level named struct type
"encoding/json".HTMLEscape package member (const, func, var, type)
"encoding/json".Decoder.Decode::x local object x within a method
"encoding/json".HTMLEscape::x local object x within a function
"encoding/json"::x object x anywhere within a package
json.go::x object x within file json.go
Double-quotes must be escaped when writing a shell command.
Quotes may be omitted for single-segment import paths such as "fmt".
For methods, the parens and '*' on the receiver type are both
optional.
It is an error if one of the ::x queries matches multiple
objects.
-to the new name.
-force causes the renaming to proceed even if conflicts were reported.
The resulting program may be ill-formed, or experience a change
in behaviour.
WARNING: this flag may even cause the renaming tool to crash.
(In due course this bug will be fixed by moving certain
analyses into the type-checker.)
-d display diffs instead of rewriting files
-v enables verbose logging.
gorename automatically computes the set of packages that might be
affected. For a local renaming, this is just the package specified by
-from or -offset, but for a potentially exported name, gorename scans
the workspace ($GOROOT and $GOPATH).
gorename rejects renamings of concrete methods that would change the
assignability relation between types and interfaces. If the interface
change was intentional, initiate the renaming at the interface method.
gorename rejects any renaming that would create a conflict at the point
of declaration, or a reference conflict (ambiguity or shadowing), or
anything else that could cause the resulting program not to compile.
Examples:
$ gorename -offset file.go:#123 -to foo
Rename the object whose identifier is at byte offset 123 within file file.go.
$ gorename -from '"bytes".Buffer.Len' -to Size
Rename the "Len" method of the *bytes.Buffer type to "Size".
---- TODO ----
...
長い!-h
をつけるともっと短いヘルプがでます。
$ gorename -h
Usage of gorename:
-d display diffs instead of rewriting files
-diffcmd string
diff command invoked when using -d (default "diff")
-force
proceed, even if conflicts were reported
-from string
identifier to be renamed; see -help for formats
-help
show usage message
-offset string
file and byte offset of identifier to be renamed, e.g. 'file.go:#123'. For use by editors.
-tags build tags
a list of build tags to consider satisfied during the build. For more information about build tags, see the description of build constraints in the documentation for the go/build package (default <tagsFlag>)
-to string
new name for identifier
-v print verbose information
なるほど、-from
で指定した部分を-to
に変更するという感じで使うわけですね。
たとえば、上記のヘルプに出ている例であるように、bytes
パッケージのBuffer
型のLen
メソッドの名前をSize
に変更する場合を考えてみます。
以下のように、-from
で'"bytes".Buffer.Len'
のように変えたい名前を一意に特定できるように指定し、-to
で変えたい名前を指定します。
$ gorename -from '"bytes".Buffer.Len' -to Size
-from
で指定した識別子は、現在のGOPATH
使って解決されます。
-from
の代わりに、-offset
を使っても変更元を指定できます。
例えば、以下のように指定できます。
$ gorename -offset file.go:#123 -to foo
なるほど、file.go
の123
行目を変更するのか、と思ったかもしれませんが、123
は行の指定ではなく、先頭から123
バイト目にある識別子の名前を変更するという意味になります。そのため、-offset
という名前のオプションなんですね。また、行が指定できても、その行のどこなのかわからないので、オフセットになっています。
人間の手で何バイト目なのか指定するの面倒ですよね。しかし、このオプションは主にIDEやエディタのプラグインから利用するために用意されているものです。人間が利用する場合は、-from
で指定してやるのをオススメします。
上記のように、gorename
コマンドを走らせてやると、該当の識別子がGOPATH
上からくまなく探し出され、-to
で指定した文字列にキレイに置換されます。
便利ですね。
当然、IDEに慣れた方にとっては、ごくごく当たり前でプログラミング言語のプラグインとしては当たり前だと思っているでしょう。
gorename
についても特段珍しい機能があるわけではありません。しかし、面白いことにgorename
の本体はライブラリとしても提供されています。
次に、ライブラリとして使う方法について説明したいと思います。
ライブラリとして使う
gorename
自体は、リポジトリを見る限り、main.go
だけで構成されています。そのmain.go
もかなり短いですね。
どうやら、以下の部分が本体のようです。rename
というパッケージを使っています。
rename.Main(&build.Default, *offsetFlag, *fromFlag, *toFlag)
rename
パッケージは、golang.org/x/tools/refactor/renameで提供されているライブラリで、識別子のリネームの機能を提供するライブラリです。
go get
でgorename
をインストールした場合には、こちらのライブラリも一緒にインストールされているはずです。
別途インストールするには、やはりgo get
を使います。
$ go get -u golang.org/x/tools/refactor/rename
rename.Main
関数を見てみましょう。
以下のように定義されています。
func Main(ctxt *build.Context, offsetFlag, fromFlag, to string) error
見たことある名前の引数を取っていますね。
そうです。gorename
の引数と対応していて、offsetFlag
が-offset
、-fromFlag
が-from
、to
が-to
に対応しています。
なお、offsetFlag
とfromFlag
についてはどちらかを指定すればOKで、指定する文字列はgorename
で指定するものと同じです。
引数の1つめは、build.Context
です。この型はビルドする際に使用するコンテキストを表す型で、GOPATH
やGOROOT
などを解決するために用いられます。gorename
では、build.Default
というデフォルトのコンテキストを用いています。
build.Context
は以下のように定義された構造体です。
type Context struct {
GOARCH string // target architecture
GOOS string // target operating system
GOROOT string // Go root
GOPATH string // Go path
CgoEnabled bool // whether cgo can be used
UseAllFiles bool // use files regardless of +build lines, file names
Compiler string // compiler to assume when computing target paths
// The build and release tags specify build constraints
// that should be considered satisfied when processing +build lines.
// Clients creating a new context may customize BuildTags, which
// defaults to empty, but it is usually an error to customize ReleaseTags,
// which defaults to the list of Go releases the current release is compatible with.
// In addition to the BuildTags and ReleaseTags, build constraints
// consider the values of GOARCH and GOOS as satisfied tags.
BuildTags []string
ReleaseTags []string
// The install suffix specifies a suffix to use in the name of the installation
// directory. By default it is empty, but custom builds that need to keep
// their outputs separate can set InstallSuffix to do so. For example, when
// using the race detector, the go command uses InstallSuffix = "race", so
// that on a Linux/386 system, packages are written to a directory named
// "linux_386_race" instead of the usual "linux_386".
InstallSuffix string
// JoinPath joins the sequence of path fragments into a single path.
// If JoinPath is nil, Import uses filepath.Join.
JoinPath func(elem ...string) string
// SplitPathList splits the path list into a slice of individual paths.
// If SplitPathList is nil, Import uses filepath.SplitList.
SplitPathList func(list string) []string
// IsAbsPath reports whether path is an absolute path.
// If IsAbsPath is nil, Import uses filepath.IsAbs.
IsAbsPath func(path string) bool
// IsDir reports whether the path names a directory.
// If IsDir is nil, Import calls os.Stat and uses the result's IsDir method.
IsDir func(path string) bool
// HasSubdir reports whether dir is a subdirectory of
// (perhaps multiple levels below) root.
// If so, HasSubdir sets rel to a slash-separated path that
// can be joined to root to produce a path equivalent to dir.
// If HasSubdir is nil, Import uses an implementation built on
// filepath.EvalSymlinks.
HasSubdir func(root, dir string) (rel string, ok bool)
// ReadDir returns a slice of os.FileInfo, sorted by Name,
// describing the content of the named directory.
// If ReadDir is nil, Import uses ioutil.ReadDir.
ReadDir func(dir string) ([]os.FileInfo, error)
// OpenFile opens a file (not a directory) for reading.
// If OpenFile is nil, Import uses os.Open.
OpenFile func(path string) (io.ReadCloser, error)
}
新しいコンテキストを用意することで、GOPATH
をカスタマイズできたり、ReadDir
やOpenFile
をカスタマイズすればioutil.ReadDir
やos.Open
などのOSの機能を使ってファイルを読み込むのではなく、自前の機構を使ってファイルを読み込んだりもできます。
たとえば、メモリ上に仮想的なファイルシステムを作って、そこから読み出すということもできます。
さて、このままだとあまりおもしろくありません。また、イマイチ使い所もわからないでしょう。
rename
パッケージは、go
パッケージと一緒に使うと効果を発揮します。
go
パッケージは、Goの構文解析や型チェックなどの機能を提供するパッケージです。rename
パッケージでも内部でこれらのパッケージを使って、変数の定義位置や使用位置を調べています。
go/ast
パッケージが提供するAST(抽象構文木)のノードを表すast.Node
を使うと、rename.Main
に渡すオフセットを簡単に指定できます。
ast.Node
のPos
メソッドを使えば、そのノードの位置が手に入り、それをオフセットに渡せます。
たとえば、1つ例を見てみましょう。
少し長いですが、指定したパッケージ内のgoファイルを探して、構造体のフィールドがId
で終わっているものをすべてID
にリネームするコードです。
package main
import (
"fmt"
"go/ast"
"go/build"
"go/parser"
"go/token"
"path/filepath"
"strings"
"golang.org/x/tools/refactor/rename"
)
func main() {
pkg, err := build.Import("fuga", ".", build.IgnoreVendor)
if err != nil {
fmt.Println("Error:", err)
}
for _, filename := range pkg.GoFiles {
if err := renameID(filepath.Join(pkg.Dir, filename)); err != nil {
fmt.Println("Error:", err)
}
}
}
func renameID(filename string) error {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
if err != nil {
return err
}
type renameParam struct {
offset string
to string
}
var renameParams []*renameParam
for _, decl := range f.Decls {
genDecl, isGenDecl := decl.(*ast.GenDecl)
if !isGenDecl {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, isType := spec.(*ast.TypeSpec)
if !isType {
continue
}
structType, isStruct := typeSpec.Type.(*ast.StructType)
if !isStruct {
continue
}
for _, fi := range structType.Fields.List {
for _, fn := range fi.Names {
if strings.HasSuffix(fn.Name, "Id") {
renameParams = append(renameParams, &renameParam{
offset: fmt.Sprintf("%s:#%d", filename, fn.Pos()),
to: fn.Name[:len(fn.Name)-1] + "D", // Id -> ID
})
}
}
}
}
}
fmt.Println(filename)
for _, p := range renameParams {
fmt.Println(p.offset, "->", p.to)
if err := rename.Main(&build.Default, p.offset, "", p.to); err != nil {
return err
}
}
return nil
}
実際に動かしてみましょう。ここでは、以下のコードを変換してみます。
$ tree $GOPATH/src/fuga
$GOPATH/src/fuga
└── fuga.go
$ cat $GOPATH/src/fuga/fuga.go
package fuga
type Hoge struct {
hogeId int
fugaId, piyoId int
}
さて、動かしてみましょう。
$ go run rename.go
$GOPATH/fuga.go
$GOPATH/fuga.go:#35 -> hogeID
Renamed 1 occurrence in 1 file in 1 package.
$GOPATH/fuga.go:#55 -> fugaID
Renamed 1 occurrence in 1 file in 1 package.
$GOPATH/fuga.go:#63 -> piyoID
Renamed 1 occurrence in 1 file in 1 package.
$ cat $GOPATH/src/fuga/fuga.go
package fuga
type Hoge struct {
hogeID int
fugaID, piyoID int
}
3つのフィールドが置換されたのがログで表示されていますね。
なお、$GOPATH
の部分は実際には、フルパスで表示されていますが、分かりやすさのために$GOPATH
として表記してあります。
上記のコードのざっくりとした手順を以下に示します。
-
build.Import
でfuga
パッケージをインポートする -
build.Package
からfuga
パッケージを構成するgo
ファイルを取得する - 各
go
ファイルに対して、renameID
をかける -
renameID
では、ファイルをパースし、ASTを取得している -
ast.File
から宣言部分の解析結果を取得する - 宣言部分の解析結果から、
GenDecl
(generic declaration node)を取得する -
GenDecl
のうち、type
で型定義をしている部分であるast.TypeSpec
を取得する -
ast.TypeSpec
のうち、構造体の型を表すast.StructType
を取得する -
ast.StructType
から構造体のフィールドリストを取得する - フィールドリストの各
ast.Field
から名前にあたるField.Names
を取得する - 1行あたりフィールド名は複数指定できるので、各
File.Names
の要素であるast.Ident
に対して処理をする - 識別子の名前を表す
Ident.Name
がId
で終わっていたら、その場所をIdent.Pos
から取得し、名前と共に記録しておく - 変換するフィールド名のリストが揃ったら、各フィールドを
rename.Main
にかけてリネームする
要するに、リネームしたい対象のast.Node
を探し出し、Pos
から場所を取得し、リネームするという訳です。便利ですね!
リファクタリングツールをライブラリとして提供する利点
上述のように、gorename
のメインの処理は別のライブラリとして切り出されていました。これは非常に賢い方法で、ライブラリに切り出されていることで、第3者が新たなリファクタリングツールを作る際に同じような機能を作る必要なくなります。
また、任意のbuild.Context
を取ることで、GOPATH
をカスタマイズしたり、ファイルを開く処理をカスタマイズしたりと、汎用的に使うことができます。
もし、go
パッケージを駆使して自作のリファクタリングツールや開発ツールを作る際には、メインの処理をライブラリとして切り出すことで、新たなツールを作る際に再利用がしやすくなるのでオススメです。
おわりに
この記事では、gorename
内部で使われているgolang.org/x/tools/refactor/rename
をライブラリとして使う方法について説明しました。
また、ツール類を作る際は、メインの処理をうまく切り出してライブラリにすることで、再利用が可能だという話をしました。
ちなみに、rename
パッケージは私も業務上のコンテキストにべったりなリファクタリングツールを作る際に使いました。go/types
パッケージを使えば、変数の定義位置や参照位置を取得できるので、自分でできないこともありませんが、rename
パッケージを使った方が断然楽ですし、リネーム漏れの心配もありません。
そうそう、go/types
パッケージは、これもまた非常に便利なパッケージですので、また別の機会に説明したいと思います。