Go言語では同じディレクトリで同じパッケージ(package main等)であれば、別ファイルに分割することができる。

AtCoderやCodinGameでテキストとして提出するときに一つのファイルにまとめる必要があるため、結合する方法を考える。

TL;DR

てっとり早く使いたい方は以下を参照

go-combine-multi-source を以下コマンドでインストール

$ go install github.com/kajikentaro/playground/go-combine-multi-source@latest

実行するとPlease enter target directory: と聞かれるので、mainパッケージが存在するディレクトリを指定する

$ go-combine-multi-source
Please enter target directory: ./

コマンドを実行した場所にresult.go.tmpが生成される

ソース

https://github.com/kajikentaro/playground/blob/master/go-combine-multi-source/main.go

解説

まず標準入力でディレクトリ名を受け取る。

その後はAST木に分解するため、parserライブラリを用いる。このとき不要なテストコードを読み込まないよう、filter関数(isNotTestFile)を使ってはじいている。

func isNotTestFile(fi fs.FileInfo) bool {
	filename := fi.Name()

	r := regexp.MustCompile(`_test.go$`)
	isTestFile := r.MatchString(filename)
	return !isTestFile
}

func main() {
	// 名前を標準入力から受け取って開く
	var dirname string
	fmt.Print("Please enter target directory: ")
	fmt.Scan(&dirname)
	fset := token.NewFileSet()
	f, err := parser.ParseDir(fset, dirname, isNotTestFile, parser.Mode(0))
	if err != nil {
		log.Fatal(err)
	}

次に各ノードを全て探索していく。

Goのソースコードを結合するときに障害になるのは、import文。なぜならimport先が重複してしまうとコンパイルエラーになるから。

ast.Inspectは第一引数にNodeインターフェースを受け取り、そこから生えている全てのノードを検索する。f["main"]となっているがここを好きなパッケージ名にすれば、main以外でも動くようになるはず。

import文が書かれているNodeの型は*ast.ImportSpec型であるのでそこで条件分岐。
stringを一意にするためにmap構造を用いて保存する。

	// importの重複しない一覧を作成する
	importMap := map[string]bool{}
	ast.Inspect(f["main"], func(n ast.Node) bool {
		if v, isOk := n.(*ast.ImportSpec); isOk {
			importMap[v.Path.Value] = true
		}
		return true
	})

ここで先に最終目標であるast.File型を確認しておく。
ast.FileのDeclsメンバにはソースコード上で一番上の宣言(top-level declarations)を配列で格納する。
つまりここにimport文のDeclとimport文以外のDeclを配置すれば良い

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
}

先にimport文のDeclを作成する
ネスト表現がややこしいのでドキュメントを確認しながら行った。

	// importのDeclを作成
	importDecl := ast.GenDecl{
		Tok:   token.IMPORT,
		Specs: []ast.Spec{},
	}
	for k := range importMap {
		importDecl.Specs = append(importDecl.Specs, &ast.ImportSpec{
			Path: &ast.BasicLit{
				Kind:  token.STRING,
				Value: k,
			},
		})
	}

次にimport文以外のDeclを作成する

	// import以外のDeclのリストを作成
	theOtherDecls := []ast.Decl{}
	for _, file := range f["main"].Files {
		for _, decl := range file.Decls {
			if v, isOk := decl.(*ast.GenDecl); isOk {
				if v.Tok == token.IMPORT {
					continue
				}
			}
			theOtherDecls = append(theOtherDecls, decl)
		}
	}

これらをくっつけてFile型を生成

	// 両方をくっつけて新しいfileにする
	newFile := &ast.File{
		Name:  ast.NewIdent("main"),
		Decls: append([]ast.Decl{&importDecl}, theOtherDecls...),
	}

あとはnewFileからテキストデータに戻すだけ

	// result.go.tmp を作成
	file, err := os.OpenFile("result.go.tmp", os.O_WRONLY|os.O_CREATE, 0666)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()
	format.Node(file, token.NewFileSet(), newFile)
}