Goのトップレベルで定義したエラー変数の命名規則をチェックするリンターを作ってみた

Goのトップレベルで定義したエラー変数の命名規則をチェックするリンターを作ってみた

トップレベルで宣言されたエラーの名前を正規表現でチェックするtomato3713/go-varErrChecker というカスタムリンターを作成しました。

Go言語ではカスタムエラーを定義するときにトップレベルでErrorインターフェイスを満たす変数を定義し、その変数を返すということを行います。 コードで書くと次のようになります。ErrOccuredSomething のような変数の名前が命名規則に従っているかをチェックするリンターです。

var ErrOccuredSomething = fmt.Errorf("failed to occured something")

func somefunc() error {
    return ErrOccuredSomething
}

作ってから気が付きましたが、似たようなことを行うリンターにgolangci-lintのリンターとして組み込まれているAntonboom/errnameやmgechev/reviveのルールの1つのerror-namingがあります。

tomato3713/go-varErrChecker はデフォルトでは命名規則のチェックに ^Err[\d\w]+$ という正規表現を使うのでErrを接頭辞に持つ変数ならパスできます。 オプションで正規表現を変えることができるので例えばErrorを末尾に持つことをチェックしたいときは go vet -vettool=varErrChecker -varErrChecker.pattern="^[\d\w]+Error$" . のように varErrChecker.pattern オプションに^[\d\w]+Error$ を設定すると実現できます。

独自リンターの実装

Goで静的解析を行うツールを作る場合は、golang.org/x/tools/go/analysis を使うと go vet に組み込んで実行するツールが簡単に作れます。 また、他のAnalyzerの結果を利用することもできます。

Analysisパッケージを使って実装した場合、静的解析の起点はfunc run(pass *analysis.Pass) (interface{}, error) です。

varErrCheckerではvarErr.goL35 にあります。

run()の引数として渡される*analysis.Pass には、analysis package - golang.org/x/tools/go/analysis - Go Packages にある通り分析対象のパッケージの情報や静的解析結果を報告するための Reportf() などの関数が含まれています。

varErrCheckerでは[]ast.File型のフィールド pass.Files をforループで各ファイルに対して次の手順を繰り返します。

1: トップレベルの変数宣言を見つける: varErr.go#L61

トップレベルの宣言は、ast.FileDeclsフィールドに格納されています。 https://pkg.go.dev/go/ast#File

Decls []Decl // top-level declarations; or nil

Declの型定義を観ると、NodedeclNode() を持つインターフェイスとして定義されていて良くわからないですね。そのままだと何もわからないですが、*ast.FuncDecl*ast.GenDecl など定義に関するものが入るので型アサーションを使って型を限定してあげるとそれぞれのフィールドにアクセスできるようになります。今回は変数の宣言なので、*ast.GenDecl で型アサーションします。

// All declaration nodes implement the Decl interface.
type Decl interface {
    Node
    declNode()
}

アサーションは、インターフェイス型から実体の型を動的に取り出すために使うx.(T) という表記のことです。

Import、定数、変数、型宣言も全て*ast.GenDecl になるので、varErrCheckerでは Specs フィールドをみてImportや型宣言を除外しています。 const と varが両方とも ast.ValueSpec でまとまっていることが面白いですね。 Tok フィールドを参照して token.Var と一致するかで判定したほうがいいかもしれません。

ast.ValueSpec型のフィールドにいるNamesフィールドが宣言された識別子を表しています。 配列になっているのは、次のように一度に複数の識別子が宣言されることがあるためだと思います。

var (
    a int
    b int
)

2: 宣言された変数がエラーインターフェイスを持っているかを判定する: varErr.go#L71

インターフェイスを満たしているかの判定はfunc Implements(V Type, T *Interface) boolを利用できます。

Tインターフェイスを渡しますが、今回判定したいエラーインターフェイス*type.Interface 型オブジェクトは次のようにすると得られます。

var errorType = types.Universe.Lookup("error").Type()
var errorInterface = errorType.Underlying().(*types.Interface)

判定対象となるVはオブジェクトの型を渡します。*ast.Ident型のオブジェクトであれば、次のようにするとオブジェクトのタイプを得られます。なので、[]*ast.Ident型のast.ValueSpec.Namesの各要素に対して同じことをして型情報を取得しています。

obj := pass.TypesInfo.ObjectOf(ident) // ident は*ast.Ident型
if obj == nil {
    continue
}
// obj.Type() でオブジェクトのTypeを得られる

3: エラーインターフェイスを満たしているなら、名前を事前に与えた正規表現にマッチするかを判定する: varErr.go#L73C41

2で得たobj.Nameが変数名の文字列なので、それと命名規則を表す正規表現のマッチを確認するだけです。

4: マッチしていなければ変数宣言の位置とともに報告する: varErr.go#L76

pass.Reportf()命名規則に違反している識別子が宣言された位置とメッセージを渡して出力します。

文章にして書くと長くなってしまっていますが、コードでは60行程度と短く、簡単なリンターであれば気軽に実装できることがわかりました。 なお、Antonboom/errnameはエラー型の配列などvarErrCheckerでは大雑把にしてしまっている部分についてもケアしているので、もう少し複雑な実装になっていました。

実装は、テスト用として用意したコードのASTを出力して眺めつつ処理を追ってみたり、標準のgo vetで呼ばれているリンターのコードを読んで書き方の参考にしたりして行いました。 説明が良くわからなくてもコードを読むとわかることがあるので、Goの標準パッケージのコードが読みやすかったりサンプルコードが多く含まれているのは助かりました。 特にwalk.go - GoはASTを深さ優先探索しているプログラムで参考になりました。

astパッケージに含まれる構造体については、https://monpoke1.hatenablog.com/entry/2018/12/16/110943 が日本語だとわかりやすかったです。