Help us understand the problem. What is going on with this article?

アドベントカレンダーに向けてMarkdown に埋め込まれた Go のソースコードに gofmt をかけてくれるツールを作った

こんにちは pon です。みなさんアドベントカレンダーは参加しますか?僕も今年は Go や Rust、Elasticsearch のアドベントカレンダーに参加する予定です。

僕はQiitaの記事を書く際には手元でMarkdownファイルを作ってそこに下書きするのですが、gofmtをかけるのが正直面倒でした。もちろん、既存コードからコピペする分には良いかもしれませんが、記事向けにコードを修正したり補足を入れた時にgofmtがかかっていない状態になることがありました。

そこで来たるアドベントカレンダーに向けて Markdown に埋め込まれた Go のソースコードに gofmt をかけてくれるツールを作ったので、ツールの紹介と作り方を記事にしました。

gofmtmd

作ったツールはこちら

Markdown に埋め込まれた Go のソースコードに gofmt をかけてくれるツールです。例えば下記のようにGoのコードブロックを検知してその中身だけにgofmtがかかります。

もちろんQiitaのようなMarkdownをサポートしているブログサービスだけでなく、README.md を書く時なんかも便利かもしれません。

インストールはこちら

$ go get github.com/po3rin/gofmtmd/cmd/gofmtmd

使い方としてはなるべく gofmt に寄せるようにしました。-rをつけると引数で指定したファイルをそのまま上書きフォーマット。-wを使うと保存するファイルを指定できます。何もつけないとformatした結果が標準出力に流れます。

# replace Go code with formated code
$ gofmtmd testdata/testdata.md -r

# write result to file instead of stdout
$ gofmtmd testdata/testdata.md -w formatted.md

Internal

Markdown のASTを取得する

内部の実装を紹介します。主に使ったのはGo製の Markdown Parser である gopkg.in/russross/blackfriday.v2 を使いました。

これを使ってMarkdownのバイト列をASTに変換しています。

func FmtGoCodeInMarkdown(md []byte) ([]byte, error) {
    // ...
    n := blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)).Parse(md)
    // ...
}

実は fenced block の記法は拡張機能としてのみサポートされているので引数にオプションとして blackfriday.WithExtensions(blackfriday.FencedCode)を渡しています。これで fenced block として Parse してくれるようになります。

ASTをWalkする

MarkdownのASTに対して何かしらの処理を帰納的に処理(トラバース)を実行するには。Walkという *blackfriday.Node から生えているメソッドを使います。Walkにノードごとに実行したい関数を渡してあげます。Walk に渡せる関数は下記の型を持っています。

type NodeVisitor func(node *Node, entering bool) WalkStatus

今回作ったツールではgenFmtWalkerという自作関数をWalkに渡しています。MarkdownのASTをトラバースしていき、Goのコードブロックを見つけたらGoが標準で用意してくれているフォーマット用関数のformat.Sourceに渡しています。

func genFmtWalker(md *[]byte, fmterr *error) blackfriday.NodeVisitor {
    return func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
        if isGoCodeBlock(node) {
            fmted, err := format.Source(node.Literal)
            if err != nil {
                *fmterr = err
                return blackfriday.Terminate
            }
            *md = bytes.ReplaceAll(
                *md,
                bytes.TrimRight(node.Literal, "\n"),
                bytes.TrimRight(fmted, "\n"),
            )
        }
        return blackfriday.GoToNext
    }
}

func isGoCodeBlock(node *blackfriday.Node) bool {
    return node.Type == blackfriday.CodeBlock && string(node.CodeBlockData.Info) == "go"
}

genFmtWalker はクロージャになっており NodeVisitor の型を満たす関数を返しています。なぜこんな複雑な形になったかというと gopkg.in/russross/blackfriday.v2 は Markdown の AST から Markdown に戻す機能がないからです。genFmtWalker で Markdown のバイト列のポインタを受け取って、ポインタの指す先を Walk 内で Replace するようにしています。

Walk はエラーを返さないためエラーも同様に処理しています。error があった場合は blackfriday.Terminate というステータスを返してトラバースを中断します。もっといい実装があるかも。。

ここまで紹介した関数を使って下記のように Markdown内のGoのコードをフォーマットした結果を返す関数が完成しました。主な実装はこれだけです。

// FmtGoCodeInMarkdown formats go code in Markdown.
// returns error if code has syntax error.
func FmtGoCodeInMarkdown(md []byte) ([]byte, error) {
    var err error
    n := blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)).Parse(md)
    // Walk内でformatしていく為、元のMarkdwonのポインタを渡してあげる。
    n.Walk(genFmtWalker(&md, &err))
    if err != nil {
        return nil, err
    }
    return md, nil
}

これを main から読んであげれば Markdown に埋め込まれた Go のソースコードに gofmt をかけてくれる CLI の完成です。

まとめ

Go の AST をトラバースする感覚で Markdown を扱えました。この要領で Markdown 内の Dockerfile のフォーマットなんかも実装できるかもしれません。何かバグがあったら教えてください!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした