Go言語でデバッガを作ろうとして挫折した話
背景
現在私は仕事でRubyを使っていて、趣味でGoを使っています
Ruby界隈で少し前にRubyKaigiという国際会議があり、その中に「Build a mini Ruby debugger in under 300 lines」( https://rubykaigi.org/2023/presentations/_st0012.html#day2 )というセッションがありました
たった300行でデバッガが作れるというのです
詳しくはセッション動画を見てもらえればと思いますが、実際驚くほど簡単にRubyでデバッガを自作できます
しかしRubyにはbinding.pryなど超便利なデバッガがすでに存在しています
今私が自作したところで圧倒的劣化版しか作れず、あまり有効活用できそうな未来が見えません
そこでGoで作ってみようと思いました
Goでは私の記憶の中にbinding.pryほど便利なデバッガがなかったのです
デバッガの構成
まずは簡単にRubyでのデバッガの構成をお伝えします
Rubyのデバッガはざっくりと以下のような構成です
def pry
display_code
while input = gets
case input.chomp
when "exit"
break
when "step" # step in
step_in
break
when "next" # step over
step_over
break
else
puts "=> " + binding.eval(input)
end
end
end
binding.pryが呼ばれた箇所で呼び出し元のソースコードを表示して標準入力から入力を待ち、特定コマンド(exitとかstepとか)なら特定処理、それ以外ならbinding.evalで入力を処理するという感じです
binding.evalは内部で組み込み関数のKernel.#evalを読んでおり該当箇所でコードを実行するという感じです
なお、display_codeメソッドの中身はこんな感じとなっており、実行されたソースコードの場所を取得し、ファイルを読み込んで表示する感じです
def display_code
file, current_line = binding.source_location
if File.exist?(file)
lines = File.readlines(file)
end_line = [current_line + 5, lines.count].min - 1
start_line = [end_line - 9, 0].max
lines[start_line..end_line].each do |line|
puts line
end
end
end
step_in、step_overはRubyの場合TracePointを使って実装されているのですが、今回ここまで到達できなかったので解説は省略します
(そんなに難しくないので気になる方は是非元セッションを見てみてください!)
Goでデバッガを実装
さてここからGoでの実装について私が到達できた範囲で解説したいと思います
まずは全体コードです
func Pry() {
DisplayCode()
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
input := scanner.Text()
switch input {
case "exit":
return
default:
evalInput(input)
}
}
}
ここは特に解説することはないのでDisplayCodeの中身を見ていきます
func DisplayCode() {
_, fname, currentLine, _ := runtime.Caller(2)
fp, err := os.Open(fname)
if err != nil {
panic(err)
}
defer fp.Close()
lines := []string{}
scanner := bufio.NewScanner(fp)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
endLine := currentLine + 5
if endLine >= len(lines) {
endLine = len(lines) - 1
}
startLine := endLine - 9
if startLine < 0 {
startLine = 0
}
for i := startLine; i < endLine; i++ {
fmt.Println(lines[i])
}
}
Go言語ではruntime.Caller関数によって呼び出された場所のファイル名と行を取得できます
引数に2を指定することでDisplayCodeの呼び出し元のPry関数のさらに呼び出し元、つまりbinding.Pry関数が呼び出された箇所のファイル名と行を得られるわけです
それ以降は特にruby版と変わりなくファイルを読み込んで表示しているだけです
では次はevalInput関数を見ていこうと思います
挫折したEval関数の実装
結論から言うと、Go言語にはrubyのKernel.#evalのような便利メソッドは存在しなかったのです
私が最初Googleでevalを検索した時、types.Eval関数がヒットしたのですが、これは型情報を推定するという関数であり式の評価はしてくれませんでした。(例外的に定数式は評価してくれますが・・・)
↓失敗した時のコード
types.Evalに実行した時の箇所(fileとどの行で実行したか)は渡せましたが、そもそもtypes.Evalが型判定しかしてくれませんでした
// import (
// "go/parser"
// "runtime"
// )
input := "a + b" // 評価したい式
_, fname, line, _ := runtime.Caller(2)
fset := token.NewFileSet()
file, _ := parser.ParseFile(fset, fname, nil, parser.AllErrors)
conf := types.Config{
Importer: &packagesImporter{},
}
pkg, _ := conf.Check(filepath.Dir(fname), fset, []*ast.File{file}, nil)
ff := fset.File(file.Pos())
result, _ := types.Eval(fset, pkg, file.Pos()+ff.LineStart(line), input)
// packagesImporterの中身
type packagesImporter struct {}
func (i packagesImporter) Import(path string) (*types.Package, error) {
return i.ImportFrom(path, "", 0)
}
func (packagesImporter) ImportFrom(path, dir string, mode types.ImportMode) (*types.Package, error) {
conf := packages.Config{
Mode: packages.NeedImports | packages.NeedTypes,
Dir: dir,
}
pkgs, _ := packages.Load(&conf, path)
pkg := pkgs[0]
return pkg.Types, nil
}
こうなればやれることは一つで、自分でEval関数を実装してやることです
私はこれに挫折したわけですが、もし誰かの助けになるかもしれないと思い途中まで挑戦した結果を記しておきます
// import (
// "go/format"
// )
input := "a + b" // 評価したい式
node, _ := parseString(input)
_, fname, line, _ := runtime.Caller(2)
fset := token.NewFileSet()
file, _ := parser.ParseFile(fset, fname, nil, parser.AllErrors)
file.Decls=append(file.Decls, node) // WIP: 追加場所は調整する必要あり
format.Node(os.Stdout, fset, file)
// parseStringの中身
func parseString(exprStr string) (ast.Node, error) {
exprStr = strings.Trim(exprStr, " \n\t")
wrappedExpr := "func(){" + exprStr + "}()"
expr, err := parser.ParseExpr(wrappedExpr)
if err != nil {
panic(err)
}
callExpr, ok := expr.(*ast.CallExpr)
if !ok {
panic(callExpr)
}
return callExpr.Fun.(*ast.FuncLit).Body, nil
}
アプローチとしては、binding.Pry関数を呼び出した箇所のAST(抽象構文木)を取得し、そこに評価したい式を追加した新たなASTを作成し実行させるというものです
ただこの方法だと作成したASTを新しく実行することになるので、今の実行状況とかは取得できずデバッガとして取りたい状況が取れませんでした、、、
終わりに
あとは誰か頼んだ・・・