LoginSignup
14
5

Goの構文解析に入門してみる

Last updated at Posted at 2024-04-15

Goのソースコードを処理するツール(コードチェックやデータ生成など)を作ってみたいと思ったので、Goの構文解析に入門してみました。

まえがき

ソースコードを何らかのツールにかけて処理したいことはよくあるかと思います。
例えばコードのルールチェックやフォーマットを行うとか、特定のフォーマットで書かれたコード(やコメント)から何らかのデータ(APIドキュメントなど)を自動生成するなどです。

昨今ではこのような処理を行うためのツールが多数提供されており、多くのプロジェクトで利用されています。
しかし、プロジェクトに固有なルールであるとか、独自のデータファイルを生成したいとなったとき、それら既存のツールでは対応できないこともあります。
そのような場合は「自分たちでツールを作る」ということが考えられますが、その際にはどのようにしてソースコードを処理すればよいのでしょうか?

簡単なツールであればちょっとしたスクリプトと正規表現などの文字列処理で実現することも可能かと思いますが、そういった単純な実装では文法を考慮した厳密な処理が難しかったり、謎の正規表現だらけでツール自身の保守性が悪くなることも少なくありません。

幸いにも私が携わっている案件にはGoを利用しているものも多く、Goには標準で構文解析を行うライブラリが用意されているため、これを利用してツールを作成できるようになりたいと思ったのが、本記事を書くに至った理由です。

どんなツールを作れるようになるか

構文解析ができるようになると、次のような様々なツールが作れそうです。

  • ソースコードの静的解析ツール
    • コーディング規約のチェックや構文エラー、潜在的バグの検出など
    • コードの品質評価ツールなど
    • 依存関係の視覚化ツールなど
  • リファクタリングツール
    • 冗長な部分やより良い記法への自動修正など
  • コード変換ツール
    • コードを一定のルールで整形するフォーマッター
    • 新旧バージョンの記法への変換や、別の言語への変換など
  • コード生成ツール1
    • 新規プロジェクトやファイルのスケルトンを自動生成するなど
    • テストコードの自動生成など
  • データ生成ツール
    • ソースコードから関数やパラメータなどのドキュメントの自動生成
    • SwaggerのAPIドキュメントの生成など
  • IDEの機能拡張
    • シンタックスハイライトや定義位置の表示、リファクタリングなどの機能

本記事の動作環境

本記事は次の環境で動作確認しながら書きました。

  • macOS Ventura
  • Go v1.22

OSについては特に環境依存はないかと思います。

Goのバージョンについては、大きく離れていなければ(ここ1、2年ぐらいのバージョンであれば)、それほど差異はないかと思います。
(ジェネリクスの追加など大きな変更がある場合は、その限りではないです)

今回やってみること

今回扱う内容は次の3つです。

  • AST(抽象構文木)の取得
  • 関数一覧の抽出
  • コードの書き換え(関数名の変更)

私自身、Goの構文解析は初めてなので、まずはソースコードを読み込んで構造を把握したり、特定の部分を選択する方法を学びます。
そして最後にちょっとしたコードの書き換えにチャレンジしてみます。

今回はまだ実用的なツールの実装までは行いません(今後、実用的なツールの開発にも手を出していきたいですね)。

それぞれの実践パート(上記3項目)では、最初にサンプルコードと実行結果を提示し、その後にコードの解説を載せる形としています。
お手元で実行してみなくても動作が見て取れるようになっていますが、是非ともご自身の手で実践してみてください。

Goの構文解析用パッケージの紹介

Go言語には静的解析用のパッケージがgo/*として標準で用意されています。
ここでは、今回直接使用する4つのパッケージを紹介します。

  • go/token: 字句(トークン)についてのパッケージ
  • go/parser: 構文解析を行うパッケージ
  • go/ast: 抽象構文木(AST)についてのパッケージ
  • go/format: ソースコードのフォーマットを行うパッケージ

他にも型についてのパッケージなど色々ありますが割愛します。
興味のある方は公式サイトのパッケージ一覧を覗いてみてください。

go/ directory - go - Go Packages
https://pkg.go.dev/go

余談:Goコンパイラはどうやってできている?

GoのコンパイラはGo自身で書かれています(初期の頃はCで書かれていました)。
よって、私はてっきりGoのコンパイラもこれらのパッケージを使っているのかと思っていたのですが、どうやら違うようです。

Goのコンパイラはgo/src/cmd/compile以下にソースコードがあるのですが、go/parserなどはほぼ利用せず、 cmd/compile/internal以下に専用のパッケージとしてまとめられているようです。

一方、gofmtなどの周辺ツールはgo/*を利用して作られています。

今回対象とするコード

さて、今回の構文解析で対象とするコードとして、次のような簡単なコードを用意してみました。
もっと複雑なコードで試してみたいという方は、是非とも書き換えてチャレンジしてみてください。

_target/hello.go
package main

import "fmt"

func main() {
	// 名前
	name := "Alice"

	hello(name)
}

// helloは挨拶を表示する関数です。
func hello(name string) {
	fmt.Printf("Hello, %s!\n", name)
}

コメントの扱いも見ていきたいので、簡単なコメントも付けてみました。

なお、対象とするソースコードは構文解析ツールで処理するいわばデータであり、解析ツール自身のビルド対象外としたいので、_target/以下に置くこととしました。
._から始まるファイルやディレクトリはGoのビルドなどの対象外となります)

以下のサンプルコードでは対象コードをハードコードしていますが、余裕があれば引数で受け取るようなアレンジをしてもよいでしょう。

AST(抽象構文木)を取得してみる

AST(Abstract Syntax Tree: 抽象構文木)は、プログラムの文法構造を木構造で表したものです。
まずはこれを取得して表示するコードを書いてみます。

package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
)

func main() {
	target := "./_target/hello.go"

	if err := printAst(target); err != nil {
		panic(err)
	}
}

func printAst(filename string) error {
	fmt.Println("-- printAst -------------------")

	fset := token.NewFileSet() // (1)
	f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) // (2)
	if err != nil {
		return err
	}

	ast.Print(fset, f) // (3)

	return nil
}

これを実行すると、次のような出力が得られます。

-- printAst -------------------
     0  *ast.File {
     1  .  Package: ./_target/hello.go:1:1
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: ./_target/hello.go:1:9
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 3) {
     7  .  .  0: *ast.GenDecl {
     8  .  .  .  TokPos: ./_target/hello.go:3:1
     9  .  .  .  Tok: import
    10  .  .  .  Lparen: -
    11  .  .  .  Specs: []ast.Spec (len = 1) {
    12  .  .  .  .  0: *ast.ImportSpec {
    13  .  .  .  .  .  Path: *ast.BasicLit {
    14  .  .  .  .  .  .  ValuePos: ./_target/hello.go:3:8
    15  .  .  .  .  .  .  Kind: STRING
    16  .  .  .  .  .  .  Value: "\"fmt\""
    17  .  .  .  .  .  }
    18  .  .  .  .  .  EndPos: -
    19  .  .  .  .  }
    20  .  .  .  }
    21  .  .  .  Rparen: -
    22  .  .  }
    23  .  .  1: *ast.FuncDecl {
    24  .  .  .  Name: *ast.Ident {
    25  .  .  .  .  NamePos: ./_target/hello.go:5:6
    26  .  .  .  .  Name: "main"
            :
         (以下略)
完全な出力(200行弱)
-- printAst -------------------
     0  *ast.File {
     1  .  Package: ./_target/hello.go:1:1
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: ./_target/hello.go:1:9
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 3) {
     7  .  .  0: *ast.GenDecl {
     8  .  .  .  TokPos: ./_target/hello.go:3:1
     9  .  .  .  Tok: import
    10  .  .  .  Lparen: -
    11  .  .  .  Specs: []ast.Spec (len = 1) {
    12  .  .  .  .  0: *ast.ImportSpec {
    13  .  .  .  .  .  Path: *ast.BasicLit {
    14  .  .  .  .  .  .  ValuePos: ./_target/hello.go:3:8
    15  .  .  .  .  .  .  Kind: STRING
    16  .  .  .  .  .  .  Value: "\"fmt\""
    17  .  .  .  .  .  }
    18  .  .  .  .  .  EndPos: -
    19  .  .  .  .  }
    20  .  .  .  }
    21  .  .  .  Rparen: -
    22  .  .  }
    23  .  .  1: *ast.FuncDecl {
    24  .  .  .  Name: *ast.Ident {
    25  .  .  .  .  NamePos: ./_target/hello.go:5:6
    26  .  .  .  .  Name: "main"
    27  .  .  .  .  Obj: *ast.Object {
    28  .  .  .  .  .  Kind: func
    29  .  .  .  .  .  Name: "main"
    30  .  .  .  .  .  Decl: *(obj @ 23)
    31  .  .  .  .  }
    32  .  .  .  }
    33  .  .  .  Type: *ast.FuncType {
    34  .  .  .  .  Func: ./_target/hello.go:5:1
    35  .  .  .  .  Params: *ast.FieldList {
    36  .  .  .  .  .  Opening: ./_target/hello.go:5:10
    37  .  .  .  .  .  Closing: ./_target/hello.go:5:11
    38  .  .  .  .  }
    39  .  .  .  }
    40  .  .  .  Body: *ast.BlockStmt {
    41  .  .  .  .  Lbrace: ./_target/hello.go:5:13
    42  .  .  .  .  List: []ast.Stmt (len = 2) {
    43  .  .  .  .  .  0: *ast.AssignStmt {
    44  .  .  .  .  .  .  Lhs: []ast.Expr (len = 1) {
    45  .  .  .  .  .  .  .  0: *ast.Ident {
    46  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:7:2
    47  .  .  .  .  .  .  .  .  Name: "name"
    48  .  .  .  .  .  .  .  .  Obj: *ast.Object {
    49  .  .  .  .  .  .  .  .  .  Kind: var
    50  .  .  .  .  .  .  .  .  .  Name: "name"
    51  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 43)
    52  .  .  .  .  .  .  .  .  }
    53  .  .  .  .  .  .  .  }
    54  .  .  .  .  .  .  }
    55  .  .  .  .  .  .  TokPos: ./_target/hello.go:7:7
    56  .  .  .  .  .  .  Tok: :=
    57  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
    58  .  .  .  .  .  .  .  0: *ast.BasicLit {
    59  .  .  .  .  .  .  .  .  ValuePos: ./_target/hello.go:7:10
    60  .  .  .  .  .  .  .  .  Kind: STRING
    61  .  .  .  .  .  .  .  .  Value: "\"Alice\""
    62  .  .  .  .  .  .  .  }
    63  .  .  .  .  .  .  }
    64  .  .  .  .  .  }
    65  .  .  .  .  .  1: *ast.ExprStmt {
    66  .  .  .  .  .  .  X: *ast.CallExpr {
    67  .  .  .  .  .  .  .  Fun: *ast.Ident {
    68  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:9:2
    69  .  .  .  .  .  .  .  .  Name: "hello"
    70  .  .  .  .  .  .  .  .  Obj: *ast.Object {
    71  .  .  .  .  .  .  .  .  .  Kind: func
    72  .  .  .  .  .  .  .  .  .  Name: "hello"
    73  .  .  .  .  .  .  .  .  .  Decl: *ast.FuncDecl {
    74  .  .  .  .  .  .  .  .  .  .  Doc: *ast.CommentGroup {
    75  .  .  .  .  .  .  .  .  .  .  .  List: []*ast.Comment (len = 1) {
    76  .  .  .  .  .  .  .  .  .  .  .  .  0: *ast.Comment {
    77  .  .  .  .  .  .  .  .  .  .  .  .  .  Slash: ./_target/hello.go:12:1
    78  .  .  .  .  .  .  .  .  .  .  .  .  .  Text: "// helloは挨拶を表示する関数です。"
    79  .  .  .  .  .  .  .  .  .  .  .  .  }
    80  .  .  .  .  .  .  .  .  .  .  .  }
    81  .  .  .  .  .  .  .  .  .  .  }
    82  .  .  .  .  .  .  .  .  .  .  Name: *ast.Ident {
    83  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:13:6
    84  .  .  .  .  .  .  .  .  .  .  .  Name: "hello"
    85  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 70)
    86  .  .  .  .  .  .  .  .  .  .  }
    87  .  .  .  .  .  .  .  .  .  .  Type: *ast.FuncType {
    88  .  .  .  .  .  .  .  .  .  .  .  Func: ./_target/hello.go:13:1
    89  .  .  .  .  .  .  .  .  .  .  .  Params: *ast.FieldList {
    90  .  .  .  .  .  .  .  .  .  .  .  .  Opening: ./_target/hello.go:13:11
    91  .  .  .  .  .  .  .  .  .  .  .  .  List: []*ast.Field (len = 1) {
    92  .  .  .  .  .  .  .  .  .  .  .  .  .  0: *ast.Field {
    93  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Names: []*ast.Ident (len = 1) {
    94  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  0: *ast.Ident {
    95  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:13:12
    96  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "name"
    97  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *ast.Object {
    98  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Kind: var
    99  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "name"
   100  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 92)
   101  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   102  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   103  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   104  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Type: *ast.Ident {
   105  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:13:17
   106  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "string"
   107  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   108  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   109  .  .  .  .  .  .  .  .  .  .  .  .  }
   110  .  .  .  .  .  .  .  .  .  .  .  .  Closing: ./_target/hello.go:13:23
   111  .  .  .  .  .  .  .  .  .  .  .  }
   112  .  .  .  .  .  .  .  .  .  .  }
   113  .  .  .  .  .  .  .  .  .  .  Body: *ast.BlockStmt {
   114  .  .  .  .  .  .  .  .  .  .  .  Lbrace: ./_target/hello.go:13:25
   115  .  .  .  .  .  .  .  .  .  .  .  List: []ast.Stmt (len = 1) {
   116  .  .  .  .  .  .  .  .  .  .  .  .  0: *ast.ExprStmt {
   117  .  .  .  .  .  .  .  .  .  .  .  .  .  X: *ast.CallExpr {
   118  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {
   119  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   120  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:14:2
   121  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "fmt"
   122  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   123  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   124  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:14:6
   125  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "Printf"
   126  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   127  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   128  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Lparen: ./_target/hello.go:14:12
   129  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Args: []ast.Expr (len = 2) {
   130  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  0: *ast.BasicLit {
   131  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  ValuePos: ./_target/hello.go:14:13
   132  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
   133  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Value: "\"Hello, %s!\\n\""
   134  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   135  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  1: *ast.Ident {
   136  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:14:29
   137  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "name"
   138  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 97)
   139  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   140  .  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   141  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Ellipsis: -
   142  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Rparen: ./_target/hello.go:14:33
   143  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   144  .  .  .  .  .  .  .  .  .  .  .  .  }
   145  .  .  .  .  .  .  .  .  .  .  .  }
   146  .  .  .  .  .  .  .  .  .  .  .  Rbrace: ./_target/hello.go:15:1
   147  .  .  .  .  .  .  .  .  .  .  }
   148  .  .  .  .  .  .  .  .  .  }
   149  .  .  .  .  .  .  .  .  }
   150  .  .  .  .  .  .  .  }
   151  .  .  .  .  .  .  .  Lparen: ./_target/hello.go:9:7
   152  .  .  .  .  .  .  .  Args: []ast.Expr (len = 1) {
   153  .  .  .  .  .  .  .  .  0: *ast.Ident {
   154  .  .  .  .  .  .  .  .  .  NamePos: ./_target/hello.go:9:8
   155  .  .  .  .  .  .  .  .  .  Name: "name"
   156  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 48)
   157  .  .  .  .  .  .  .  .  }
   158  .  .  .  .  .  .  .  }
   159  .  .  .  .  .  .  .  Ellipsis: -
   160  .  .  .  .  .  .  .  Rparen: ./_target/hello.go:9:12
   161  .  .  .  .  .  .  }
   162  .  .  .  .  .  }
   163  .  .  .  .  }
   164  .  .  .  .  Rbrace: ./_target/hello.go:10:1
   165  .  .  .  }
   166  .  .  }
   167  .  .  2: *(obj @ 73)
   168  .  }
   169  .  FileStart: ./_target/hello.go:1:1
   170  .  FileEnd: ./_target/hello.go:15:3
   171  .  Scope: *ast.Scope {
   172  .  .  Objects: map[string]*ast.Object (len = 2) {
   173  .  .  .  "main": *(obj @ 27)
   174  .  .  .  "hello": *(obj @ 70)
   175  .  .  }
   176  .  }
   177  .  Imports: []*ast.ImportSpec (len = 1) {
   178  .  .  0: *(obj @ 12)
   179  .  }
   180  .  Unresolved: []*ast.Ident (len = 2) {
   181  .  .  0: *(obj @ 104)
   182  .  .  1: *(obj @ 119)
   183  .  }
   184  .  Comments: []*ast.CommentGroup (len = 2) {
   185  .  .  0: *ast.CommentGroup {
   186  .  .  .  List: []*ast.Comment (len = 1) {
   187  .  .  .  .  0: *ast.Comment {
   188  .  .  .  .  .  Slash: ./_target/hello.go:6:2
   189  .  .  .  .  .  Text: "// 名前"
   190  .  .  .  .  }
   191  .  .  .  }
   192  .  .  }
   193  .  .  1: *(obj @ 74)
   194  .  }
   195  .  GoVersion: ""
   196  }

*ast.Fileをルートとして、なんとなくパッケージ宣言(PackageNameの部分)や、import宣言(Declsの0番目)、main関数(Declsの1番目)らしきものが並んでいるのが見て取れるかと思います。
興味がある方は自分で実行したり「完全な出力」を眺めて、hello関数や変数なども探ってみてください。

コードの説明

(1) token.FileSetの作成

	fset := token.NewFileSet() // (1)

まず、(1)のtoken.NewFileSet()token.FileSetを作成します。
これは解析したファイルの行などの情報を記録するための入れ物です。
ファイルを解析するときに併せて指定し、ファイルの情報を蓄積させていく仕組みになっています(複数のファイル情報を格納できます)。

このあとの処理で得られるASTの各ノードは、ファイル上の位置情報を直接は持っておらず、このtoken.FileSet内のインデックスのような整数で持っています。
そのため、「この関数は何というファイルの何行目、何桁目か」のような位置を得るには、token.FileSetを使って変換する必要があります。

(2) ファイルの解析

	f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) // (2)
	if err != nil {
		return err
	}

続いて、(2)ではparser.ParseFile()を使ってファイルを解析しています。
引数は次のようになっています。

  • 第1引数: ファイルの情報を格納するためのtoken.FileSetを指定します
    • (1)で作成したものを渡します
  • 第2引数: 解析対象のファイル名を指定します
  • 第3引数: ソースコードを指定しますが、いくつかの指定方法があります
    • nilを指定すると第2引数で指定したファイルを読み込んで処理してくれます
    • その他にstring[]byteio.Readerを指定することができ、その場合、第2引数のファイル名は位置情報の記録にだけ使用されます
  • 第4引数: 解析のモードを指定します
    • 例えばコメントも解析対象にするにはparser.ParseCommentsを指定します
    • 他にはこのようなモードが定義されています(詳細は割愛します)
      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
          SkipObjectResolution                              // skip deprecated identifier resolution; see ParseFile
          AllErrors            = SpuriousErrors             // report all errors (not just the first 10 on different lines)
      )
      

戻り値は*ast.Fileというファイル情報のノードになります。

解析エラーの場合はもちろんerrorが返ってくるのですが、*ast.Filenilにならずに返ってくることがあります。
例えば途中に構文エラーがあるような場合は、errorと共に該当箇所だけast.BadStmtなどになったツリーが返ってきます。

(3) ASTの表示

	ast.Print(fset, f) // (3)

これは単にASTをダンプ表示しているだけです。

  • 第1引数: token.FileSetを指定します
    • nilも渡せますが、その場合は位置情報がインデックスの整数で表示されます
  • 第2引数: 表示したいノードを指定します(ルートでなくても構いません)

関数一覧を抽出してみる

続いて、特定の種類のノードを処理する方法を見てみたいと思います。
ここでは関数宣言のノードを対象に処理する、という想定で関数一覧を表示してみます。

//       :
// main関数は割愛します
//       :

func printFunctions(filename string) error {
	fmt.Println("-- printFunctions -------------------")

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
	if err != nil {
		return err
	}

	ast.Inspect(f, func(n ast.Node) bool {  // (1)
		if v, ok := n.(*ast.FuncDecl); ok { // (2)
			pos := fset.Position(v.Pos())   // (3)
			fmt.Printf("%s (%s)\n", v.Name, pos)
		}
		return true
	})

	return nil
}

これを実行すると、次のような出力が得られます。

-- printFunctions -------------------
main (./_target/hello.go:5:1)
hello (./_target/hello.go:13:1)

先のASTのダンプ表示にも含まれる情報なので何の面白みもありませんが、関数宣言の関数名(mainhello)だけ列挙されているのが見て取れます。

コードの説明

(1) ast.Inspectでノードのトラバース

ast.Inspect(f, func(n ast.Node) bool {  // (1)
            :
		return true
})

ast.Inspect()を使うと、ノードを順に辿って任意の処理を行うことができます。
引数は次のようになっています。

  • 第1引数: ノードを指定します
  • 第2引数: ノードを処理する関数を指定します
    • ノードを順番に辿りながらこの関数が呼び出されます
      • 子ノードの処理が終わったあとなど、nilが渡されて呼ばれることもあるので注意してください
    • trueを返すと、今のノードの子ノードに進みます
    • falseを返すと、子ノードには行かずに次の兄弟ノードに進みます

今回のように、ASTの中から特定のノードに対して処理を行いたいときに利用できます。

(2) ノードの選択

		if v, ok := n.(*ast.FuncDecl); ok { // (2)
              :
		}

ast.Inspect()に渡した関数は、様々なノードを引数として呼び出されるので、ここで対象としたいノード型に型アサーションして処理するというのがよくあるパターンになるかと思います。
ノードの種類によって処理を分けたい場合はswitchなどでもよいでしょう。

ここでは関数宣言だけを対象としたいので*ast.FuncDeclとしています。
他にどのような型があるか知りたい方は、go/astパッケージのドキュメントを御覧ください。

(3) ノードの処理

(2)で*ast.FuncDeclを得たので、ここから関数名と位置を取得して表示しています。

			pos := fset.Position(v.Pos())   // (3)
			fmt.Printf("%s (%s)\n", v.Name, pos)

関数名はast.FuncDeclNameフィールドで得られました。

ファイル名や位置(行番号など)については、最初のコード解説で出てきたtoken.FileSetを使って変換します。
v.Pos()には整数しか入っていないので、それをtoke.FileSetPosition()関数に渡して変換します。

Position()の戻り値はtoken.Position型で、次のような定義になっています。

type Position struct {
	Filename string // filename, if any
	Offset   int    // offset, starting at 0
	Line     int    // line number, starting at 1
	Column   int    // column number, starting at 1 (byte count)
}

これでファイル名や行番号、桁などの位置情報が得られました。

ソースコードを書き換えてみる

さて、ASTを眺めているだけではつまらないので、今度はコードの書き換えに挑戦してみたいと思います。
ここではhelloという関数名をgreetに変更してみます。
対象は関数宣言、関数呼び出し、関数宣言についているコメント中の関数名です。

//       :
// main関数は割愛します
//       :

func renameFunctions(filename string) error {
	fmt.Println("-- renameFunctions -------------------")

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
	if err != nil {
		return err
	}

	ast.Inspect(f, func(n ast.Node) bool {
		// 関数宣言に対する処理
		if v, ok := n.(*ast.FuncDecl); ok {
			if v.Name != nil && v.Name.Name == "hello" { // (1)
				v.Name.Name = "greet"
			}
			if v.Doc != nil { // (2)
				for _, doc := range v.Doc.List {
					doc.Text = strings.ReplaceAll(doc.Text, "hello", "greet")
				}
			}
		}

		// 関数呼び出しに対する処理
		if v, ok := n.(*ast.CallExpr); ok { // (3)
			if ident, ok := v.Fun.(*ast.Ident); ok {
				if ident.Name == "hello" {
					ident.Name = "greet"
				}
			}
		}
		return true
	})

	return format.Node(os.Stdout, fset, f) // (4)
}

これを実行すると、次のような出力が得られます。

-- renameFunctions -------------------
package main

import "fmt"

func main() {
        // 名前
        name := "Alice"

        greet(name)
}

// greetは挨拶を表示する関数です。
func greet(name string) {
        fmt.Printf("Hello, %s!\n", name)
}

hello関数がgreet関数になっていますね。

コードの説明

ast.Inspect()までの流れは「関数一覧を取得してみる」と同じです。
違いは、ast.Inspect()内での処理内容です。

(1) 関数宣言の関数名を変更

		if v, ok := n.(*ast.FuncDecl); ok {
			if v.Name != nil && v.Name.Name == "hello" { // (1)
				v.Name.Name = "greet"
			}
                   :
		}

関数宣言(*ast.FuncDecl)のNamehelloであれば、greetに更新しています。
v.Name.Nameとしているのは、v.Nameがただのstringではなく、*ast.Ident型だからです。

type Ident struct {
	NamePos token.Pos // identifier position
	Name    string    // identifier name
	Obj     *Object   // denoted object, or nil. Deprecated: see Object.
}

(2) 関数宣言についているコメントを置換

続いて、同関数宣言に付随しているDocフィールドにより、関数宣言についているコメントを処理します。
解析時のモードでparser.ParseCommentsを付けていないと得られないので注意してください。

		if v, ok := n.(*ast.FuncDecl); ok { // (1)
                  :
			if v.Doc != nil { // (2)
				for _, doc := range v.Doc.List {
					doc.Text = strings.ReplaceAll(doc.Text, "hello", "greet")
				}
			}
		}

Docフィールドは*ast.CommentGroupという型で、コメント(ast.Comment)がリストで格納されています。
//で始まる一行コメントの場合は、複数行並べてもひと塊ではなく一行コメントが複数という扱いになります。
/* 〜 */形式のコメントの場合は、これ一つで1コメントとなります。

type CommentGroup struct {
	List []*Comment // len(List) > 0
}

type Comment struct {
	Slash token.Pos // position of "/" starting the comment
	Text  string    // comment text (excluding '\n' for //-style comments)
}

これで関数宣言のコメントにアクセスできたので、今回は雑に文字列置換でhellogreetに置換してみました。

(3) 呼び出し関数名の変更

関数名を変えたら呼び出し側も変えないといけないですね。
というわけで、関数呼び出しを表すast.CallExprを捕捉して、呼び出し関数名を変更します。

		if v, ok := n.(*ast.CallExpr); ok { // (3)
			if ident, ok := v.Fun.(*ast.Ident); ok {
				if ident.Name == "hello" {
					ident.Name = "greet"
				}
			}
		}

ast.CallExprは次のような型です。
関数を指し示すものの他に引数や括弧の位置などが含まれています。

type CallExpr struct {
	Fun      Expr      // function expression
	Lparen   token.Pos // position of "("
	Args     []Expr    // function arguments; or nil
	Ellipsis token.Pos // position of "..." (token.NoPos if there is no "...")
	Rparen   token.Pos // position of ")"
}

このFun*ast.Identに型アサーションして、関数宣言のときと同じようにNameを書き換えています。

今回は例ということで*ast.Identとして処理しましたが、実際のFunには他にも何種類かの型が入っている可能性があります。
例えばパッケージや構造体の関数をfoo.Bar()のように呼び出した場合はast.SelectorExprという型だったり、即時関数(func() { ... }())はast.FuncLitという型が入っていたりします。

また、実際のリファクタリングツールのような処理をするには、別ファイルでの呼び出しや変数に代入した関数など、もっと色々と考慮することがあるでしょう。

(4) 変更したASTを書き戻す

	return format.Node(os.Stdout, fset, f) // (4)

最後に変更した内容(書き換えたAST)をフォーマットして書き出します。
これにはgo/formatパッケージのNode()関数を使います。
引数は次のようになっています。

  • 第1引数: 出力先となるio.Writer
  • 第2引数: 解析時のtoken.FileSet
  • 第3引数: 書き出すノード
    • コードスニペットのように特定の式だけ書き出すということもできます

今回は標準出力に書き出しましたが、ここを元のファイルのio.Writerにしてあげれば、変更した内容をファイルに書き戻すことができますね。

おわりに

Goプログラミングはそこそこ長い間やってきたのですが、Goのソースコード自体を構文解析してみるというのは初めての体験でした。
今回は本当に入門レベルの内容ということもありますが、思いの外簡単に処理できそうだということが分かって良かったです。
今後はもっと踏み込んで実践的なツールを作ってみたいと思っています。

ちなみに、より実践的なツールを作る際はgolang.org/x/tools/go/ast/astutilという便利パッケージを利用することが多いそうです。
こちらについても探っていきたいですね。

  1. 構文解析というよりはASTからコードを生成するほうが近いかも

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5