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

ASTを取得する方法を調べる #golang

More than 3 years have passed since last update.

はじめに

みなさん、メリークリスマス!
22日の枠に欠員が出たので、go/parserパッケージを使ってAST(抽象構文木)を取得する方法についてまとめたいと思います。

goパッケージについては、簡単な式の評価機を作ってみるという記事も書いているので、そちらもぜひ読んで下さい。

なお、この記事を執筆時点のGoの最新バージョンは1.7.4です。

ノード

go/parserパッケージでは、いくつかのParseと名前のついた関数でAST(抽象構文木)を取得することができます。ASTの各ノードを表す型は、go/astパッケージで提供されています。そしてそれらの型は、以下のast.Nodeインタフェースを実装するように定義されています。

type Node interface {
        Pos() token.Pos // position of first character belonging to the node
        End() token.Pos // position of first character immediately after the node
}

ast.Nodeインタフェースは、PosメソッドとEndメソッドを持ち、共にtoken.Pos型の値を返します。token.Posはノードのコード上の位置を表します。

token.Posが具体的にどんな値を表すかは後述しますので、ここでは、ノードのコード上の位置という理解でよいでしょう。

go/astパッケージのドキュメントを見ると分かるように、ast.Nodeインタフェースを実装した型が多数存在することが分かります。

ドキュメントを見るだけでは、一体どの型が何を表し、どうやったら取得できるのか謎だと思いますが、それはgo/parseパッケージが提供するParse系の関数から取得できるノードから順にみていくとよいでしょう。

そこで、この記事ではgo/parserパッケージの提供するParse系の関数について詳しくみていきたいと思います。

パースする

ASTを取得するには、いくつか方法があります。
簡単な式の評価機を作ってみるで紹介した、ParseExprは、式を表すASTのノードである、ast.Exprを返します。

この他にも、Parse系のメソッドは以下のように、いくつかあります。

上記の何が違うのかというと、パースの対象となるソースコードの範囲です。
ParseDirはディレクトリ以下にあるファイルが対象であるのに対して、ParseFileはファイルを対象としています。また、ParseExprParseExprFromは式を対象にしています。

そして、それぞれの関数によって、返されるノードの種類が異なります。
各関数のシグニチャを並べて、比べてみましょう。

func ParseDir(fset *token.FileSet, path string, filter func(os.FileInfo) bool, mode Mode) (pkgs map[string]*ast.Package, first error)
func ParseFile(fset *token.FileSet, filename string, src interface{}, mode Mode) (f *ast.File, err error)
func ParseExpr(x string) (ast.Expr, error)
func ParseExprFrom(fset *token.FileSet, filename string, src interface{}, mode Mode) (ast.Expr, error)

ast.ParseExpr以外は、token.FileSet型のポインタを引数に取るようです。まずは、このtoken.FileSetが何者なのかみてみましょう。

ちなみに、ast.ParseExprは内部で以下のようにast.ParseExprFromを呼んでいるようです。

func ParseExpr(x string) (ast.Expr, error) {
    return ParseExprFrom(token.NewFileSet(), "", []byte(x), 0)
}

token.FileSet

サンプルをみる限り、Parse系に渡すtoken.Fileは、どうやらtoken.NewFileSet関数で生成するようです。

つまり、初期化したものをそのまま渡すということは、Parse系の関数によって、渡したtoken.FileSetに何かしらの変更が加えられることが予想できます。

具体的に、go/parserパッケージのソースコードを読むと以下のように、*token.FileSet型のAddFileメソッドが呼ばれていることが分かります。

func (p *parser) init(fset *token.FileSet, filename string, src []byte, mode Mode) {
    p.file = fset.AddFile(filename, -1, len(src))
    ...
}

*parser型のinitメソッドは、ast.ParseFileからast.ParseExprFromから呼ばれています。なお、ast.ParseDirはファイルごとの処理を、ast.ParseFileにまかせているので、実質ast.ParseDirを読んでも*token.FileSet型のAddFileメソッドが呼ばれます。

さて、AddFileメソッドは何をしているのでしょうか?
ソースコードをみてみると、以下のようになっています。

func (s *FileSet) AddFile(filename string, base, size int) *File {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    if base < 0 {
        base = s.base
    }
    if base < s.base || size < 0 {
        panic("illegal base or size")
    }
    // base >= s.base && size >= 0
    f := &File{s, filename, base, size, []int{0}, nil}
    base += size + 1 // +1 because EOF also has a position
    if base < 0 {
        panic("token.Pos offset overflow (> 2G of source code in file set)")
    }
    // add the file to the file set
    s.base = base
    s.files = append(s.files, f)
    s.last = f
    return f
}

*.parser型のinitメソッドでは、baseとして-1を渡しています。そのため、上記のコードを読む限り、それまで追加されたファイルのサイズの合計+新しく追加されるファイルのサイズをそのファイルのbaseとして計算しています。そして、そのbaseを元にtoken.File型の値を生成し、内部に記録していきます。

どうやら、このbaseは、*token.FileSet型のPositionメソッドで使用され、ast.NodeインタフェースのPosメソッドやEndメソッドから取得できるtoken.Posからファイル名や行数などを取得するために用いられるようです。
つまり、*token.FileSet型のAddFileメソッドで追加された順に、baseをどんどんファイルサイズで加算していき、その値を元にノードの位置をtoken.Posという値で表すことで、1つ数値でどのファイルのどの行なのかという情報を表せるようにしているようです。

さて、token.FileSetについてなんとなく分かったところで、各Parser系の関数についてみていきましょう。

parser.ParseDir

parser.ParseDir関数のシグニチャは以下の通りです。

func ParseDir(fset *token.FileSet, path string, filter func(os.FileInfo) bool, mode Mode) (pkgs map[string]*ast.Package, first error)

第2引数にパス、第3引数にフィルター関数、最後にモードを返しています。
モードについては後述するとして、第2引数と第3引数について説明したいと思います。

第2引数のパスは、パース対象の.goファイルが入ったディレクトリです。このパスは、os.Openによって開かれ、内部のファイル一覧が取得されます。

そして、.goファイルかつ第3引数のフィルターがtrueを返すファイルのみを対象にパースが行われます。なお、フィルターがnilの場合には、.goファイルが対象になります。具体的には、ドキュメントかソースコードを見るとわかるでしょう。

ast.ParseDir関数の戻り値は、map[string]*ast.Packageとエラーです。
第1戻り値は、キーがパッケージ名で値が*ast.Package型の値のマップです。
上述のとおり、ast.ParserDir関数は内部でast.ParseFile関数を呼び出しているため、その結果がast.Packageとしてまとめられ、このマップに格納されています。

なお、第2戻り値の名前がfirstになっていることから分かるように、ast.ParseFile関数を呼んだ結果のうち最初のエラーのみが返却されます。また、エラーが置きてもすべてのファイルについてast.ParseFileが実行されるようです。

戻り値のast.Packageは以下のように定義されている構造体です。もちろん、ast.Nodeインタフェースを実装しています。

type Package struct {
        Name    string             // package name
        Scope   *Scope             // package scope across all files
        Imports map[string]*Object // map of package id -> package object
        Files   map[string]*File   // Go source files by filename
}

どうやらソースコードを見る限り、parser.ParseDir関数で取得した、ast.Packageには、ImportsScopeは設定されていないようです。

parser.ParseFile

続いて、parser.ParseFile関数についてみてましょう。
シグニチャは以下のようになっていました。

func ParseFile(fset *token.FileSet, filename string, src interface{}, mode Mode) (f *ast.File, err error)

第2引数と第3引数が謎ですね。
ドキュメントとソースコードを読むと、第3引数のsrcnilの場合、第2引数のファイル名を使ってソースコードがos.ReadFileされるようです。

そして、srcは、以下の4種類を受け付けるようです。

  • string
  • []byte
  • *bytes.Buffer
  • io.Reader

いずれも最終的には[]byte型としてソースコードを扱っているようです。

また、ast.ParseFile関数の戻り値は、*ast.File型の値とエラーです。
ast.Filetoken.Fileとは違うので注意)は、以下のように定義されています。

type File struct {
        Doc        *CommentGroup   // associated documentation; or nil
        Package    token.Pos       // position of "package" keyword
        Name       *Ident          // package name
        Decls      []Decl          // top-level declarations; or nil
        Scope      *Scope          // package scope (this file only)
        Imports    []*ImportSpec   // imports in this file
        Unresolved []*Ident        // unresolved identifiers in this file
        Comments   []*CommentGroup // list of all comments in the source file
}

Declsフィールドから定義された関数や変数や型が取得できそうです。
また、Commentsからはファイル中のコメントの一覧が取得できるようです。色々使えそうですね。

parser.ParseExpr

parser.ParseExprparser.ParseExprFromについてはここでは詳しく述べません。
簡単な式の評価機を作ってみるにも挙動を書いているので、ぜひそちらをご覧ください。

parser.Mode

さて、parser.ParseExpr以外のParse系の関数の最後の引数がparser.Modeです。
これは、パースする際にどうパースするのかを表したフラグで以下のように定義されています。

type Mode uint
const (
        PackageClauseOnly Mode             = 1 << iota // stop parsing after package clause
        ImportsOnly                                    // stop parsing after import declarations
        ParseComments                                  // parse comments and add them to AST
        Trace                                          // print a trace of parsed productions
        DeclarationErrors                              // report declaration errors
        SpuriousErrors                                 // same as AllErrors, for backward-compatibility
        AllErrors         = SpuriousErrors             // report all errors (not just the first 10 on different lines)
)

それぞれの意味を説明していきましょう。

  • PackageClauseOnly: packageの部分までパースする
  • ImportsOnly: importの部分までパースする
  • ParseComments: コメントをパースして、ASTに追加する
  • Trace: パースの過程をトレースする
  • DeclarationErrors: 定義エラーを見つける(多重定義とか)
  • SpuriousErrors: 互換のためにある。AllErrorsと同じ
  • AllErrors: すべてのエラーを出す。そうじゃないと10個まで。

ast.Modeはフラグのため、上記の機能を追加しない場合は、基本的に0を指定しておけば問題ありません。
なお、エラーについては実際には、scanner.ErrorListが返却されるため、parser.AllErrorsを指定した場合には、以下のように取得すると良いでしょう。

fset := token.NewFileSet()
_, err := parser.ParseFile(fset, "sample.go", []byte(src), parser.DeclarationErrors|parser.AllErrors)
if err != nil {
    switch err := err.(type) {
    case scanner.ErrorList:
        for _, e := range err {
            fmt.Printf("%s:%d:%d %s\n", e.Pos.Filename, e.Pos.Line, e.Pos.Column, e.Msg)
        }
    default:
        fmt.Println(err)
    }
    return
}

ちなみに、parser.AllErrorsを指定しない場合には、10個目のエラーまでしか、scanner.ErrorListに含まれません。その10個目までしか返却されないようにするのに、panicを使って大域脱出(ここここ)をしていたのが面白かったです。

まとめ

この記事では、主にgo/parserが提供するParse系関数をベースにその周辺の型や関数について説明しました。
Parse系の関数で得られた、ast.Nodeインタフェースを実装したASTのノードをいじることで色々楽しいことができるので、ぜひやってみてください。

goパッケージの他のサブパッケージについてもまた記事を書きたいと思います。

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
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