LoginSignup
0
0

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

Posted at

TL; DR

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

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

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

go-ruleguardとは?

(ご存じの方は次の章まで読み飛ばしてください)

go-ruleguardはGoのlinter(およびそのCLI)です。

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

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

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`)
}
マッチする実例
package main

import "fmt"

func main() {
	a := true
	b := true
	if !(a != b) {
		fmt.Println("foo")
	}
}
$ ruleguard -rules rules.go main.go 
/home/syuparn/tmp/ruleguard/main.go:8:5: boolExprSimplify: suggestion: a == b (rules.go:9)

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

rules.go
func hogeExpr(m dsl.Matcher) {
	m.Match(`hoge := $y`).Report("do not assign to hoge")
}
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だけでなく変数名の条件で絞り込む2こともできます。

func hogeExpr(m dsl.Matcher) {
	// $xがhogeを含む場合NG
	m.Match(`$x := $y`).Where(m["x"].Text.Matches("hoge")).Report("do not assign to hoge")
}
func main() {
	hoge123 := 1
	fmt.Println(hoge123)
}
$ ruleguard -rules rules.go main.go 
/home/syuparn/tmp/ruleguard/main.go:6:2: hogeExpr: do not assign to hoge (rules.go:10)

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

謎のコンパイルエラー

前置きが終わったところで、今回ハマった点についてです。

やろうとしたこと

文字列定数を定義する際typoを防ぐため「定数名を値と同じにする」というルールを考えます3

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

上記ルールを実装するため、rules.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) がサポートされていないようです。

$ 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」?4)というパッケージで確認できます。

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

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

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
}

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

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の)ネイティブ関数かどうかを判定して、ネイティブ関数であればその呼び出し命令へコンパイルします。

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
}

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

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{},
	}

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

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

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

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に以下のような記述がありました。

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インタプリタです。最新の構文に追随しておりこちらも活発に開発されています)

おわりに

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

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

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

関連記事?

  1. 詳しい文法はこちら

  2. 正確にはノードのトークンリテラルです。

  3. 実際はもう少し複雑な命名規則に従おうとしていました。

  4. もしくは作者のquasilyteさんの名前から取っているのかもしれません(由来は見つからず...)。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0