見出し画像

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を新しく実行することになるので、今の実行状況とかは取得できずデバッガとして取りたい状況が取れませんでした、、、

終わりに

あとは誰か頼んだ・・・

いいなと思ったら応援しよう!