oinume journal

Scratchpad of what I learned

Go言語でcodemod

大規模なコードベースでリファクタリングを省エネ化するためにcodemodを最近調べていて、軽く試行錯誤したのでそのメモ。

やりたいこと

例えば以下のようなTable Driven TestなコードをBEFOREからAFTERに書き換えたい。コード量が多いため人間がやるのは現実的ではなく、codemodで機械的に書き換えたい。

BEFORE

package main

import (
    "slices"
    "testing"
)

func TestContains(t *testing.T) {
    type args struct {
        ss []string
        s  string
    }
    tests := []struct {
        name string
        args args
        want bool
    }{
        {
            name: "empty: false",
            args: args{[]string{}, ""},
            want: false,
        },
        {
            name: "found: true",
            args: args{[]string{"a", "b", "c"}, "b"},
            want: true,
        },
        {
            name: "not found: false",
            args: args{[]string{"a", "b"}, "c"},
            want: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := contains(tt.args.ss, tt.args.s); got != tt.want {
                t.Errorf("contains(): want=%v, got=%v", got, tt.want)
            }
        })
    }
}

func contains(ss []string, s string) bool {
    return slices.Contains(ss, s)
}

AFTER

func TestContains(t *testing.T) {
    type args struct {
        ss []string
        s  string
    }
    tests := map[string]struct {
        args args
        want bool
    }{
        "empty: false": {
            args: args{[]string{}, ""},
            want: false,
        },
    }
    ...
}

DIFF

--- a/tools_eg/table_driven_test.go
+++ b/tools_eg/table_driven_test.go
@@ -10,29 +10,25 @@ func TestContains(t *testing.T) {
        ss []string
        s  string
    }
-  tests := []struct {
-      name string
+   tests := map[string]struct {
        args args
        want bool
    }{
-      {
-          name: "empty: false",
+       "empty: false": {
            args: args{[]string{}, ""},
            want: false,
        },
-      {
-          name: "found: true",
+       "found: true": {
            args: args{[]string{"a", "b", "c"}, "b"},
            want: true,
        },
-      {
-          name: "not found: false",
+       "not found: false": {
            args: args{[]string{"a", "b"}, "c"},
            want: false,
        },
    }
-  for _, tt := range tests {
-      t.Run(tt.name, func(t *testing.T) {
+   for name, tt := range tests {
+       t.Run(name, func(t *testing.T) {
            if got := contains(tt.args.ss, tt.args.s); got != tt.want {
                t.Errorf("contains(): want=%v, got=%v", got, tt.want)
            }

試したこと

eg

egというのは golang.org/x/tools 配下で提供されているコード書き換えのコマンドのこと(link)。例えば以下のようにdeprecatedな ioutil.ReadAll を呼び出している箇所をtemplateのGoコードを用いて io.ReadAll に書き換えることができる。

http_get.go

package main

import (
    "fmt"
    "io/ioutil" //nolint:staticcheck
    "net/http"
)

func main() {
    resp, err := http.DefaultClient.Get("https://github.com/golang/go")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

http_get.template

package main

import (
    "io"
    "io/ioutil"
)

func before(r io.Reader) ([]byte, error) {
    return ioutil.ReadAll(r)
}

func after(r io.Reader) ([]byte, error) {
    return io.ReadAll(r)
}

egを使って書き換える

$ eg -t http_get.template -w http_get.go

ただ、egでは先ほどのようなTable Driven Testの struct -> map[string]struct にするのは eg: map[string]struct{} is not a safe replacement for struct{name string} のエラーでできなかった(ref)。

なので、次に紹介するast-grepを試してみた。

ast-grep

ast-grepは様々な言語に対応した、ASTベースでコードの検索、Linterの作成、コード書き換えを行えるコマンドラインツールである。YAMLファイルによる独自のルールを定義することによって、かなり複雑なパターンマッチングを行うことができる。

ASTツリーを表示しながらパターンにマッチした部分をハイライトするPlaygroundも用意されているので、AST初心者でも試行錯誤すればルールが定義できそうな雰囲気を感じる。自分はまだ使いこなせてないので、とりあえず紹介だけ。

これを使えばおそらく先ほどのTable Driven Testのコードは書き換えられそうな気がする。

その他のツール

  • TypeScriptだとts-morphというものがあり、これのGo版が欲しい...!!!
  • Codemodというコード書き換えのためのSaaSがある。内部的にはast-grepなどを呼び出すっぽい

最後に

とりあえずast-grepで試行錯誤して上手くいったらまた記事を書きます。