Help us understand the problem. What is going on with this article?

go/typesパッケージを使い変数名をリネームしてみる #golang

More than 3 years have passed since last update.

はじめに

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.Usesinfo.Defsから識別子を使用情報と定義情報が取得できます。
ここでinfo.Usesinfo.Defsmap[*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 ====以下を見ると、きちんとmsgmessageに変更されているのが分かるでしょう。
ちなみに、以下のようなコードに置き換えてもうまくリネームされることが分かるかと思います。

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フィールドをうまく使うことで、今までにない新しいツールが作れそうな気がします。
ぜひ、みなさんも何か作ってみてください。

tenntenn
Go engineer / Gopher artist
mercari
フリマアプリ「メルカリ」を、グローバルで開発しています。
https://tech.mercari.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした