go-ruleguardのルール定義でコンパイルエラーが起きたのはなぜ？―独自Goコンパイラを探る

# TL; DR

- `go-ruleguard` のルールファイル `rules.go` は独自Goコンパイラでコンパイルされる
- そのため、専用のオブジェクト、メソッド呼び出し以外は使えない

:::note info
本記事ではruleguardの実践的な活用方法については触れていないので、予めご了承ください :bow:

（エラーが発生したときの原因調査には少しだけ役立つかもしれません）
:::

# go-ruleguardとは？
（ご存じの方は次の章まで読み飛ばしてください）

go-ruleguardはGoのlinter（およびそのCLI）です。

https://github.com/quasilyte/go-ruleguard

特徴として、**ユーザーが1からルールを作成できる**ことが挙げられます。ASTに対するパターンマッチで記述できるため、**構文レベルでルールを設定可能**です。

(以下 `v0.4.2` 時点の内容です)

```go:rules.go
// READMEより抜粋
//go:build ruleguard
// +build ruleguard

package gorules

import "github.com/quasilyte/go-ruleguard/dsl"

func boolExprSimplify(m dsl.Matcher) {
	m.Match(`!($x != $y)`).Suggest(`$x == $y`)
}
```

```go:マッチする実例
package main

import "fmt"

func main() {
	a := true
	b := true
	if !(a != b) {
		fmt.Println("foo")
	}
}
```

```bash
$ ruleguard -rules rules.go main.go 
/home/syuparn/tmp/ruleguard/main.go:8:5: boolExprSimplify: suggestion: a == b (rules.go:9)
```

変数(`$`始まりの要素)を使わず、直接指定することも可能です[^syntax]。

```go:rules.go
func hogeExpr(m dsl.Matcher) {
	m.Match(`hoge := $y`).Report("do not assign to hoge")
}
```

```go
func main() {
	hoge := 1
	fmt.Println(hoge)
}
```

```
$ ruleguard -rules rules.go main.go 
/home/syuparn/tmp/ruleguard/main.go:6:2: hogeExpr: do not assign to hoge (rules.go:10)
```

さらに、ASTだけでなく変数名の条件で絞り込む[^var]こともできます。

```go
func hogeExpr(m dsl.Matcher) {
	// $xがhogeを含む場合NG
	m.Match(`$x := $y`).Where(m["x"].Text.Matches("hoge")).Report("do not assign to hoge")
}
```

```go
func main() {
	hoge123 := 1
	fmt.Println(hoge123)
}
```

```bash
$ ruleguard -rules rules.go main.go 
/home/syuparn/tmp/ruleguard/main.go:6:2: hogeExpr: do not assign to hoge (rules.go:10)
```

他にもメソッドが用意されているので、詳細は公式リファレンスをご覧ください。

https://go-ruleguard.github.io/by-example/

# 謎のコンパイルエラー
前置きが終わったところで、今回ハマった点についてです。

## やろうとしたこと
文字列定数を定義する際typoを防ぐため「定数名を値と同じにする」というルールを考えます[^naming]。

```go
// OK
const Foo = "Foo"
// NG (typo!)
const Baz = "Bar"
```

上記ルールを実装するため、`rules.go` に以下の規則を設定しました、

```go
func constName(m dsl.Matcher) {
	m.Match(`const $x = $y`).
		Where(string(m["y"].Text) != `"`+string(m["x"].Text)+`"`).
		Suggest("const name must be same as its value")
}
```

## コンパイルエラーが発生
ところが、実行するとルールファイルのコンパイルエラーが発生しました。 `string(m["y"].Text)` がサポートされていないようです。

```bash
$ ruleguard -rules rules.go main.go 
ruleguard: load rules: parse rules file: irconv error: rules.go:13: unsupported expr: string(m["y"].Text) (*ast.CallExpr)
```

組み込み型 `string` への型キャストが「サポートされていない」とは一体どういうことでしょうか？

# ruleguardの独自実装Goコンパイラ
実は、ruleguardは `rules.go` のソースコードを **独自のGoコンパイラでコンパイルしています** 。このruleguard用コンパイラがキャストの構文 `string()` に対応していなかったため、上記のエラーが発生していたというわけです。

コンパイルの様子は、`quasigo` (「疑似Go」？[^quasigo])というパッケージで確認できます。

https://github.com/quasilyte/go-ruleguard/tree/v0.4.2/ruleguard/quasigo

lexer, parserまではGoの標準モジュールを使用しています。

https://github.com/quasilyte/go-ruleguard/blob/v0.4.2/ruleguard/goutil/goutil.go

独自実装なのは評価の部分で、ASTを評価しルール定義の関数オブジェクトへコンパイルしています。

https://github.com/quasilyte/go-ruleguard/blob/v0.4.2/ruleguard/quasigo/compile.go

```go:ruleguard/quasigo/compile.go
func (cl *compiler) compileFunc(fn *ast.FuncDecl) *Func {
	// ... 関数オブジェクト（`Func`）の初期化処理

	// 文を評価
	cl.compileStmt(fn.Body)

	compiled := &Func{
		code:            cl.code,
		constants:       cl.constants,
		intConstants:    cl.intConstants,
		numObjectParams: len(cl.params),
		numIntParams:    len(cl.intParams),
		name:            cl.ctx.Package.Path() + "." + fn.Name.String(),
	}
	if len(cl.locals) != 0 {
		dbg.localNames = make([]string, len(cl.locals))
		for localName, localIndex := range cl.locals {
			dbg.localNames[localIndex] = localName
		}
	}
	cl.ctx.Env.debug.funcs[compiled] = dbg
	cl.linkJumps()
	return compiled
}
```

文の評価関数は「いかにもコンパイラ」という感じがします。

```go:ruleguard/quasigo/compile.go
// 文を評価
func (cl *compiler) compileStmt(stmt ast.Stmt) {
	switch stmt := stmt.(type) {

	case *ast.ReturnStmt:
		cl.compileReturnStmt(stmt)

	case *ast.AssignStmt:
		cl.compileAssignStmt(stmt)

	case *ast.IncDecStmt:
		cl.compileIncDecStmt(stmt)

	case *ast.IfStmt:
		cl.compileIfStmt(stmt)

	case *ast.ForStmt:
		cl.compileForStmt(stmt)

	case *ast.BranchStmt:
		cl.compileBranchStmt(stmt)

	case *ast.ExprStmt:
		cl.compileExprStmt(stmt)

	case *ast.BlockStmt:
		for i := range stmt.List {
			cl.compileStmt(stmt.List[i])
		}

	default:
		panic(cl.errorf(stmt, "can't compile %T yet", stmt))
	}
}
```

コンパイラの本丸は呼び出し式の評価です。（ruleguardの）ネイティブ関数かどうかを判定して、ネイティブ関数であればその呼び出し命令へコンパイルします。

```go:ruleguard/quasigo/compile.go
func (cl *compiler) compileCallExpr(call *ast.CallExpr) {
	// ...

	if cl.compileNativeCall(key, variadic, expr, call.Args) {
		return
	}

	// ...
}


func (cl *compiler) compileNativeCall(key funcKey, variadic int, funcExpr ast.Expr, args []ast.Expr) bool {
	// ネイティブ関数の検索
	funcID, ok := cl.ctx.Env.nameToNativeFuncID[key]
	if !ok {
		return false
	}

	// 実引数の評価
	// ...

	// 関数呼び出し命令のバイトコード出力
	cl.emit16(opCallNative, int(funcID))
	return true
}
```

https://github.com/quasilyte/go-ruleguard/blob/v0.4.2/ruleguard/quasigo/compile.go

ネイティブ関数には、冒頭に出てきた `dsl.Matcher#Match` 等のメソッドが用意されています。

```go:ruleguard/libdsl.go
	nativeTypes := map[string]quasigoNative{
		`*github.com/quasilyte/go-ruleguard/dsl.MatchedText`:      dslMatchedText{},
		`*github.com/quasilyte/go-ruleguard/dsl.DoVar`:            dslDoVar{},
		`*github.com/quasilyte/go-ruleguard/dsl.DoContext`:        dslDoContext{},
		`*github.com/quasilyte/go-ruleguard/dsl.VarFilterContext`: dslVarFilterContext{state: state},
		`github.com/quasilyte/go-ruleguard/dsl/types.Type`:        dslTypesType{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Interface`:  dslTypesInterface{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Pointer`:    dslTypesPointer{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Struct`:     dslTypesStruct{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Array`:      dslTypesArray{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Slice`:      dslTypesSlice{},
		`*github.com/quasilyte/go-ruleguard/dsl/types.Var`:        dslTypesVar{},
	}
```

https://github.com/quasilyte/go-ruleguard/blob/v0.4.2/ruleguard/libdsl.go


余談ですが、`dsl` パッケージ自体には**空の実装しかありません**。quasigoのコンパイラが実装を知っているため、（標準の）Goコンパイラ用のパッケージにはIDE補完用のシグネチャしか定義していないのだと思われます。

```go:dsl/dsl.go
func (m Matcher) Match(pattern string, alternatives ...string) Matcher {
	return m
}
```

コンパイルされたバイトコードはそのまま `eval` で実行されます。バイトコードのVMはスタックベースのようです。

https://github.com/quasilyte/go-ruleguard/blob/v0.4.2/ruleguard/quasigo/eval.go

```go:ruleguard/quasigo/eval.go
func eval(env *EvalEnv, fn *Func, top, intTop int) CallResult {
	pc := 0
	code := fn.code
	stack := &env.Stack
	var locals [maxFuncLocals]interface{}
	var intLocals [maxFuncLocals]int

	for {
		switch op := opcode(code[pc]); op {
		case opPushParam:
			index := int(code[pc+1])
			stack.Push(stack.objects[top+index])
			pc += 2
		case opPushIntParam:
			index := int(code[pc+1])
			stack.PushInt(stack.ints[intTop+index])
			pc += 2

		case opPushLocal:
			index := code[pc+1]
			stack.Push(locals[index])
			pc += 2
		case opPushIntLocal:
			index := code[pc+1]
			stack.PushInt(intLocals[index])
			pc += 2

		case opSetLocal:
			index := code[pc+1]
			locals[index] = stack.Pop()
			pc += 2
		case opSetIntLocal:
			index := code[pc+1]
			intLocals[index] = stack.PopInt()
			pc += 2

		// ....
	}
}
```


# Goコンパイラが作られた背景

（注意：正式にドキュメント化されているわけではないため推測を含みます）

quasigoコンパイラが作られた最初のPRに以下のような記述がありました。

https://github.com/quasilyte/go-ruleguard/pull/173

> Custom filters are simple Go functions defined in ruleguard rule files that can be used as Where clause predicates.
>
> They're compiled to a bytecode that is then interpreted when the matched rule needs to apply its filters. The performance is good enough for now (faster than yaegi) and can be further improved in the future.
> 
> カスタムフィルターはruleguardのルールファイルに定義された単純なGo関数で、Where句の述語として使用できます。
>
> それらはバイトコードへコンパイルされた後、マッチしたルールにフィルタを適用する必要があるときに実行されます。パフォーマンスは現時点でも十分で(yaegiよりも速い)、今後さらに改善の余地があります。


上記の記述から

- 複雑なルールを定義できるよう、`rules.go` でルールを定義できる仕組みを追加した
- 実行時間を速くするため、バイトコードにコンパイルしてから実行するようにした

のではないかと考えられます。

（ちなみに、文中でベンチマークとして挙げられている `yaegi` はpure Go製のGoインタプリタです。最新の構文に追随しておりこちらも活発に開発されています）

https://github.com/traefik/yaegi

# おわりに
以上、go-ruleguardのエラーの原因と内部実装のGoコンパイラの紹介でした。linterの中に独立した処理系があるというのはとてもロマンを感じます。
今後も ~~（自称）~~ 言語処理系ハンターとして、意外な場所で活躍する処理系を探していきたいと思います。

~~作ろうとしていたルールは結局できていませんが、そのうち再挑戦したいと思います（本末転倒）~~

ここまでお読みいただきありがとうございました。

# 関連記事？

https://qiita.com/Syuparn/items/44fe0f6dd3b4c29865b5

https://qiita.com/Syuparn/items/d30323c90c32246d0557

https://qiita.com/Syuparn/items/4157d13c39b95185acfd

https://qiita.com/Syuparn/items/b369be6ebe53ef33b6c5

[^var]: 正確にはノードのトークンリテラルです。
[^syntax]: 詳しい文法は[こちら](https://github.com/quasilyte/go-ruleguard/blob/master/_docs/dsl.md#type-pattern-matching)。
[^naming]: 実際はもう少し複雑な命名規則に従おうとしていました。
[^quasigo]: もしくは作者のquasilyteさんの名前から取っているのかもしれません（由来は見つからず...）。