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

# はじめに
こちらは[Go4 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/go4)の19日目として書いております。
ギリギリ間に合った！！！

# 何を作ったか
**[gofmtmd](https://github.com/po3rin/gofmtmd) というcliツールにinspireされて、[tffmtmd](https://github.com/fukubaka0825/tffmtmd)というmarkdown内のhclソースコードをfmtするツールを作りました。**

<img width="725" alt="スクリーンショット 2019-12-19 22.12.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321850/bdd12782-403b-9239-03a2-456d20b8c683.png">

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

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


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

```go

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

```go: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`の中で同じようなことをやっている)

```go: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道場](https://gopherdojo.connpass.com/)(卒業レポ: [#5 Gopher道場を卒業しました - Qiita](https://qiita.com/fukubaka0825/items/48658ced3b8b370f66f1))で教わってから現場で実践はしてきましたが、自分の開発したツールに書いていくのは初めてでした。
**可読性も上がるし、テストも追加しやすいのでこれはマストでやったほうが良いと思います。**
その際、[gotests](https://github.com/cweill/gotests)なんかで雛形をgenerateして、そこから作っていくと楽にかけるかなと思います。（jetbrains社製のIDEならデフォルトでついている)
![carbon (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321850/2063cf3c-57a9-65f2-5cac-cc14346e5f9b.png)

また、ユーザーにとって**Test Coverageが一目で見えることで、ツールとして安心して選びやすくなる**と思います。[codecov.io](https://codecov.io/)なんかを使用して、カバレッジをバッジ画像でREADMEにつけておくのが良さそうです。(これもgofmtmdからの受け売り。その他にGolangCIやCircleCIやgodocのバッジをつけておくととても可愛いし、ユーザーが安心して選びやすいと思う。)
設定方法の詳細はこちらを参考にしてください。 -> [CircleCIとCodecovでGitHubにカバレッジバッジをつけよう！ - VELTRA Engineering - Medium](https://medium.com/veltra-engineering/how-to-add-coverage-badge-to-github-repository-using-circleci-and-codecov-663dd412d95f)

<img width="492" alt="スクリーンショット 2019-12-19 22.30.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321850/fb5c36ec-8ebb-29c3-8dc3-a616ffa3995e.png">

## 2. [goreleaser](https://goreleaser.com/)で各プラットフォームへのバイナリを自動リリースする
**goreleaserは、バイナリのクロスコンパイルと Github Releases へのデプロイ自動化ツール**です。
直近のtagに対して、`goreleaser`コマンドを打つだけで.goreleaser.ymlファイルで指定したOSや環境に対応したバイナリをリリースしてくれます。(githubなら、tagではなく最新のコミットに紐付ける--snapshotふらぐもある)
なんて便利なんだ。。。。
<img width="993" alt="スクリーンショット 2019-12-19 22.39.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321850/6a6498f0-266b-4f27-2868-6ccb88112d57.png">
詳しい設定方法は[公式チュートリアル](https://goreleaser.com/)をご参照ください！

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

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

最後に再度[gofmtmd](https://github.com/po3rin/gofmtmd)の作者の@po3rin さんに感謝の意を！ありがとうございました🙇‍♂️