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

Goでmarkdown内のhclソースコードをfmtするCLIツール「tffmtmd」を作る過程で学んだことをまとめていく

はじめに

こちらはGo4 Advent Calendar 2019の19日目として書いております。
ギリギリ間に合った!!!

何を作ったか

gofmtmd というcliツールにinspireされて、tffmtmdというmarkdown内のhclソースコードをfmtするツールを作りました。

スクリーンショット 2019-12-19 22.12.15.png

gofmtmd でのblackfridayというmarkdown processerのmoduleの使い方を見て、「ちょっといじれば、これGoだけじゃなくMarkdown内のなんのコードでもfmtできるな??」となり、勢い余ってパクってしまいました。
その際に、Goでのmd内のソースコードの扱い方terraform fmtコマンドのソース(Goで書かれている)の構造OSSツールをリリースする際のお作法について学べたので、そこらへんをまとめていこうかなと思います。

Goでのmd内のソースコードの扱い方

MarkdownのASTを扱う場合、blackfridayを使用して、*blackfriday.NodeのWalkメソッドにNodeVisitor型を満たす関数を渡して再帰的に実行させることで実現するのが一番簡単そうです。
詳しくはアドベントカレンダーに向けてMarkdown に埋め込まれた Go のソースコードに gofmt をかけてくれるツールを作った - Qiitaをご参照ください。

以下のように、本家ではGoのCodeBlockを検知しているところを、HCLを検知してhclのsyntaxcheckをした後にformatをかけています。

func (i impleMdFile) hclFmtWalkerFunc(synerr *error) blackfriday.NodeVisitor {
    return func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
        if i.isHclCodeBlock(node) {
            _, syntaxDiags := hclsyntax.ParseConfig(node.Literal, i.filename, hcl.Pos{Line: 1, Column: 1})
            if syntaxDiags.HasErrors() {
                *synerr = errors.New("[tffmtmd] failed to format hcl source code. Please check syntax")
                return blackfriday.Terminate
            }
            result := hclwrite.Format(node.Literal)
            *i.md = bytes.ReplaceAll(*i.md, bytes.TrimRight(node.Literal, "\n"), bytes.TrimRight(result, "\n"))
        }
        return blackfriday.GoToNext
    }
}

terraform fmtコマンドのソース構造

terraform fmtってどんなソースで構成されているんやろ、みたいな知的好奇心がツール作成のモチベーションの大きな部分だったので読んでみました。

terraform/command/fmt.go
func (c *FmtCommand) fmt(paths []string, stdin io.Reader, stdout io.Writer) tfdiags.Diagnostics {
    var diags tfdiags.Diagnostics

    if len(paths) == 0 { // Assuming stdin, then.
        if c.write {
            diags = diags.Append(fmt.Errorf("Option -write cannot be used when reading from stdin"))
            return diags
        }
        fileDiags := c.processFile("<stdin>", stdin, stdout, true)
        diags = diags.Append(fileDiags)
        return diags
    }

    for _, path := range paths {
        path = c.normalizePath(path)
        info, err := os.Stat(path)
        if err != nil {
            diags = diags.Append(fmt.Errorf("No file or directory at %s", path))
            return diags
        }
        if info.IsDir() {
            dirDiags := c.processDir(path, stdout)
            diags = diags.Append(dirDiags)
        } else {
            switch filepath.Ext(path) {
            case ".tf", ".tfvars":
                f, err := os.Open(path)
                if err != nil {
                    // Open does not produce error messages that are end-user-appropriate,
                    // so we'll need to simplify here.
                    diags = diags.Append(fmt.Errorf("Failed to read file %s", path))
                    continue
                }

                fileDiags := c.processFile(c.normalizePath(path), f, stdout, false) //❶
                diags = diags.Append(fileDiags)
                f.Close()
            default:
                diags = diags.Append(fmt.Errorf("Only .tf and .tfvars files can be processed with terraform fmt"))
                continue
            }
        }
    }

    return diags
}

とってもシンプルな作りですね。
なるほど、.tfと.tfvarsのファイルの時だけ❶でc.processFileメソッドの中でごにょごにょやっている感じやな、c.processFileを読みに行きましょか(dirだったらc.processDirの中で同じようなことをやっている)

terraform/command/fmt.go
func (c *FmtCommand) processFile(path string, r io.Reader, w io.Writer, isStdout bool) tfdiags.Diagnostics {
    var diags tfdiags.Diagnostics

    log.Printf("[TRACE] terraform fmt: Formatting %s", path)

    src, err := ioutil.ReadAll(r)
    if err != nil {
        diags = diags.Append(fmt.Errorf("Failed to read %s", path))
        return diags
    }

    // File must be parseable as HCL native syntax before we'll try to format
    // it. If not, the formatter is likely to make drastic changes that would
    // be hard for the user to undo.
    _, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1})  //❷
    if syntaxDiags.HasErrors() {
        diags = diags.Append(syntaxDiags)
        return diags
    }

    result := hclwrite.Format(src) //❸

    if !bytes.Equal(src, result) {
        // Something was changed
        if c.list {
            fmt.Fprintln(w, path)
        }
        if c.write {
            err := ioutil.WriteFile(path, result, 0644)
            if err != nil {
                diags = diags.Append(fmt.Errorf("Failed to write %s", path))
                return diags
            }
        }
        if c.diff {
            diff, err := bytesDiff(src, result, path)
            if err != nil {
                diags = diags.Append(fmt.Errorf("Failed to generate diff for %s: %s", path, err))
                return diags
            }
            w.Write(diff)
        }
    }

    if !c.list && !c.write && !c.diff {
        _, err = w.Write(result)
        if err != nil {
            diags = diags.Append(fmt.Errorf("Failed to write result"))
        }
    }

    return diags
}

あら、❸のhclwrite.Formatしているだけかと思ったら、まず❷でhclsyntaxのチェックをしているのか。
syntax check前のコメントを読んでみる。

File must be parseable as HCL native syntax before we'll try to format
it. If not, the formatter is likely to make drastic changes that would
be hard for the user to undo.

ファイルはHCLのネイティブシンタックスとしてパースできるように、formatする前になっているべきで、やらないとformatterは元にユーザーが戻しづらい劇的な変化を起こしちゃう。
なるほど I understand.

ということでsyntax checkもformatもtffmtmdの処理に入れ込むことにしました。

GoのOSSツールをリリースする際のお作法

1. TableDrivenTestsを書き、Test Coverageを出しておく

GoはTableDrivenTestsで書いていくのが良いというのは、Gopher道場(卒業レポ: #5 Gopher道場を卒業しました - Qiita)で教わってから現場で実践はしてきましたが、自分の開発したツールに書いていくのは初めてでした。
可読性も上がるし、テストも追加しやすいのでこれはマストでやったほうが良いと思います。
その際、gotestsなんかで雛形をgenerateして、そこから作っていくと楽にかけるかなと思います。(jetbrains社製のIDEならデフォルトでついている)
carbon (5).png

また、ユーザーにとってTest Coverageが一目で見えることで、ツールとして安心して選びやすくなると思います。codecov.ioなんかを使用して、カバレッジをバッジ画像でREADMEにつけておくのが良さそうです。(これもgofmtmdからの受け売り。その他にGolangCIやCircleCIやgodocのバッジをつけておくととても可愛いし、ユーザーが安心して選びやすいと思う。)
設定方法の詳細はこちらを参考にしてください。 -> CircleCIとCodecovでGitHubにカバレッジバッジをつけよう! - VELTRA Engineering - Medium

スクリーンショット 2019-12-19 22.30.53.png

2. goreleaserで各プラットフォームへのバイナリを自動リリースする

goreleaserは、バイナリのクロスコンパイルと Github Releases へのデプロイ自動化ツールです。
直近のtagに対して、goreleaserコマンドを打つだけで.goreleaser.ymlファイルで指定したOSや環境に対応したバイナリをリリースしてくれます。(githubなら、tagではなく最新のコミットに紐付ける--snapshotふらぐもある)
なんて便利なんだ。。。。
スクリーンショット 2019-12-19 22.39.12.png
詳しい設定方法は公式チュートリアルをご参照ください!

最後に

ほぼほぼgofmtmdの受け売りだし簡易なツール作成でしたが、ほぼ初めてのパブリックな自家製ツールだったので、非常に勉強になることが多かったです。
あとはお仕事ではBitbucketを使っていることが多い(たまにGitlab)ので、久々にGithub使うと周辺ツールも相まって快適すぎてやばい。。

今回の記事では、本当はterraformソースからUMLをジェネるツールか、terraform-qiita-providerをGoで書く話を書くつもりでしたが、諸所ゴタゴタしている関係で今日までに完成させることができませんでした。。
それらのツールが完成したら、また諸々まとめてアウトプットしようと思っています。その際にも今回学べたことが大きく活きる気がしています。

最後に再度gofmtmdの作者の@po3rin さんに感謝の意を!ありがとうございました🙇‍♂️

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
ユーザーは見つかりませんでした