Repository: goplus/gop
Pull request #2147 (2025-03-01 12:14:57)

v1.3.2


Patch:
diff --git a/.github/codecov.yml b/.github/codecov.yml
index 0752ec08..043ffcbd 100644
--- a/.github/codecov.yml
+++ b/.github/codecov.yml
@@ -28,4 +28,5 @@ coverage:
     - "x/gocmd"
     - "x/gopenv"
     - "tool"
+    - "tpl"
   
\ No newline at end of file
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index fda1cbbb..62d9d0ad 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -191,6 +191,8 @@ nfpms:
         dst: "/usr/lib/{{ .ProjectName }}/all.bat"
       - src: "ast"
         dst: "/usr/lib/{{ .ProjectName }}/ast"
+      - src: "tpl"
+        dst: "/usr/lib/{{ .ProjectName }}/tpl"
       - src: "tool"
         dst: "/usr/lib/{{ .ProjectName }}/tool"
       - src: "builtin"
@@ -265,6 +267,8 @@ snapcrafts:
         destination: "all.bat"
       - source: "ast"
         destination: "ast"
+      - source: "tpl"
+        destination: "tpl"
       - source: "tool"
         destination: "tool"
       - source: "builtin"
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 75740798..00000000
--- a/VERSION
+++ /dev/null
@@ -1,1 +0,0 @@
-v1.3.1
diff --git a/ast/ast.go b/ast/ast.go
index 5496a164..e3ee3c9e 100644
--- a/ast/ast.go
+++ b/ast/ast.go
@@ -356,9 +356,6 @@ func (x *Ident) Pos() token.Pos { return x.NamePos }
 // Pos returns position of first character belonging to the node.
 func (x *Ellipsis) Pos() token.Pos { return x.Ellipsis }
 
-// Pos returns position of first character belonging to the node.
-func (x *BasicLit) Pos() token.Pos { return x.ValuePos }
-
 // Pos returns position of first character belonging to the node.
 func (x *FuncLit) Pos() token.Pos { return x.Type.Pos() }
 
@@ -440,9 +437,6 @@ func (x *Ellipsis) End() token.Pos {
 	return x.Ellipsis + 3 // len("...")
 }
 
-// End returns position of first character immediately after the node.
-func (x *BasicLit) End() token.Pos { return token.Pos(int(x.ValuePos) + len(x.Value)) }
-
 // End returns position of first character immediately after the node.
 func (x *FuncLit) End() token.Pos { return x.Body.End() }
 
@@ -520,7 +514,6 @@ func (x *ChanType) End() token.Pos { return x.Value.End() }
 func (*BadExpr) exprNode()        {}
 func (*Ident) exprNode()          {}
 func (*Ellipsis) exprNode()       {}
-func (*BasicLit) exprNode()       {}
 func (*FuncLit) exprNode()        {}
 func (*CompositeLit) exprNode()   {}
 func (*ParenExpr) exprNode()      {}
diff --git a/ast/ast_gop.go b/ast/ast_gop.go
index 64fe4a85..f464b5a1 100644
--- a/ast/ast_gop.go
+++ b/ast/ast_gop.go
@@ -59,6 +59,26 @@ func (*OverloadFuncDecl) declNode() {}
 
 // -----------------------------------------------------------------------------
 
+// A DomainTextLit node represents a domain text literal.
+//
+//	tpl`...`
+type DomainTextLit struct {
+	Domain   *Ident       // domain name
+	ValuePos token.Pos    // literal position
+	Value    string       // literal string; e.g. `\m\n\o`
+	Extra    *StringLitEx // optional
+}
+
+// Pos returns position of first character belonging to the node.
+func (x *DomainTextLit) Pos() token.Pos { return x.Domain.NamePos }
+
+// End returns position of first character immediately after the node.
+func (x *DomainTextLit) End() token.Pos { return token.Pos(int(x.ValuePos) + len(x.Value)) }
+
+func (*DomainTextLit) exprNode() {}
+
+// -----------------------------------------------------------------------------
+
 // A BasicLit node represents a literal of basic type.
 type BasicLit struct {
 	ValuePos token.Pos    // literal position
@@ -83,6 +103,14 @@ func NextPartPos(pos token.Pos, part any) (nextPos token.Pos) {
 	panic("NextPartPos: unexpected parameters")
 }
 
+// Pos returns position of first character belonging to the node.
+func (x *BasicLit) Pos() token.Pos { return x.ValuePos }
+
+// End returns position of first character immediately after the node.
+func (x *BasicLit) End() token.Pos { return token.Pos(int(x.ValuePos) + len(x.Value)) }
+
+func (*BasicLit) exprNode() {}
+
 // -----------------------------------------------------------------------------
 
 // A NumberUnitLit node represents a number with unit.
diff --git a/ast/gopq/gop_dom.go b/ast/gopq/dom.go
similarity index 73%
rename from ast/gopq/gop_dom.go
rename to ast/gopq/dom.go
index 19ba2add..440ebdf4 100644
--- a/ast/gopq/gop_dom.go
+++ b/ast/gopq/dom.go
@@ -120,19 +120,57 @@ func (p *astSpec) Obj() interface{} {
 
 // -----------------------------------------------------------------------------
 
-// NameOf returns name of an ast node.
-func NameOf(node Node) string {
-	switch v := node.Obj().(type) {
-	case *ast.FuncDecl:
-		return v.Name.Name
-	case *ast.ImportSpec:
-		if v.Name == nil {
-			return ""
+func visitStmt(stmt ast.Stmt, filter func(node Node) error) error {
+	if stmt != nil {
+		return filter(&astStmt{stmt})
+	}
+	return nil
+}
+
+type astStmt struct {
+	ast.Stmt
+}
+
+func (p *astStmt) ForEach(filter func(node Node) error) error {
+	switch stmt := p.Stmt.(type) {
+	case *ast.IfStmt:
+		if err := visitStmt(stmt.Init, filter); err == ErrBreak {
+			return err
+		}
+		if err := filter(&astStmt{stmt.Body}); err == ErrBreak {
+			return err
+		}
+		if err := visitStmt(stmt.Else, filter); err == ErrBreak {
+			return err
+		}
+	case *ast.BlockStmt:
+		for _, stmt := range stmt.List {
+			node := &astStmt{stmt}
+			if err := filter(node); err == ErrBreak {
+				return err
+			}
 		}
-		return v.Name.Name
-	default:
-		panic("node doesn't contain the `name` property")
 	}
+	// TODO(xsw): visit other stmts
+	return nil
+}
+
+func (p *astStmt) Obj() interface{} {
+	return p.Stmt
+}
+
+// -----------------------------------------------------------------------------
+
+type astExpr struct {
+	ast.Expr
+}
+
+func (p *astExpr) ForEach(filter func(node Node) error) error {
+	return nil
+}
+
+func (p *astExpr) Obj() interface{} {
+	return p.Expr
 }
 
 // -----------------------------------------------------------------------------
diff --git a/ast/gopq/gop_query.go b/ast/gopq/gop_query.go
deleted file mode 100644
index 281ed6e0..00000000
--- a/ast/gopq/gop_query.go
+++ /dev/null
@@ -1,350 +0,0 @@
-/*
- * Copyright (c) 2021 The GoPlus Authors (goplus.org). All rights reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package gopq
-
-import (
-	"errors"
-	"io/fs"
-	"syscall"
-
-	"github.com/goplus/gop/ast"
-	"github.com/goplus/gop/parser"
-	"github.com/goplus/gop/token"
-)
-
-// -----------------------------------------------------------------------------
-
-var (
-	// ErrNotFound - not found
-	ErrNotFound = syscall.ENOENT
-	// ErrBreak - break
-	ErrBreak = syscall.ELOOP
-	// ErrTooManyNodes - too may nodes
-	ErrTooManyNodes = errors.New("too many nodes")
-)
-
-// Node - node interface
-type Node interface {
-	ast.Node
-	ForEach(filter func(node Node) error) error
-	Obj() interface{}
-}
-
-// NodeEnum - node enumerator
-type NodeEnum interface {
-	ForEach(filter func(node Node) error) error
-}
-
-// NodeSet - node set
-type NodeSet struct {
-	Data NodeEnum
-	Err  error
-}
-
-// NewSource calls ParseFile for all files with names ending in ".gop" in the
-// directory specified by path and returns a map of package name -> package
-// AST with all the packages found.
-//
-// If filter != nil, only the files with fs.FileInfo entries passing through
-// the filter (and ending in ".gop") are considered. The mode bits are passed
-// to ParseFile unchanged. Position information is recorded in fset, which
-// must not be nil.
-//
-// If the directory couldn't be read, a nil map and the respective error are
-// returned. If a parse error occurred, a non-nil but incomplete map and the
-// first error encountered are returned.
-func NewSource(
-	fset *token.FileSet, path string,
-	filter func(fs.FileInfo) bool, mode parser.Mode) (doc NodeSet, err error) {
-
-	pkgs, err := parser.ParseDir(fset, path, filter, mode)
-	if err != nil {
-		return
-	}
-	return NodeSet{Data: &oneNode{astPackages(pkgs)}}, nil
-}
-
-// NewSourceFrom calls ParseFile for all files with names ending in ".gop" in the
-// directory specified by path and returns a map of package name -> package
-// AST with all the packages found.
-//
-// If filter != nil, only the files with fs.FileInfo entries passing through
-// the filter (and ending in ".gop") are considered. The mode bits are passed
-// to ParseFile unchanged. Position information is recorded in fset, which
-// must not be nil.
-//
-// If the directory couldn't be read, a nil map and the respective error are
-// returned. If a parse error occurred, a non-nil but incomplete map and the
-// first error encountered are returned.
-func NewSourceFrom(
-	fset *token.FileSet, fs parser.FileSystem, path string,
-	filter func(fs.FileInfo) bool, mode parser.Mode) (doc NodeSet, err error) {
-
-	pkgs, err := parser.ParseFSDir(fset, fs, path, parser.Config{Filter: filter, Mode: mode})
-	if err != nil {
-		return
-	}
-	return NodeSet{Data: &oneNode{astPackages(pkgs)}}, nil
-}
-
-// Ok returns if node set is valid or not.
-func (p NodeSet) Ok() bool {
-	return p.Err == nil
-}
-
-// FuncDecl returns *ast.FuncDecl node set.
-func (p NodeSet) FuncDecl() NodeSet {
-	return p.Match(func(node Node) bool {
-		_, ok := node.Obj().(*ast.FuncDecl)
-		return ok
-	})
-}
-
-// GenDecl returns *ast.GenDecl node set.
-func (p NodeSet) GenDecl(tok token.Token) NodeSet {
-	return p.Match(func(node Node) bool {
-		decl, ok := node.Obj().(*ast.GenDecl)
-		return ok && decl.Tok == tok
-	})
-}
-
-// TypeSpec returns *ast.TypeSpec node set.
-func (p NodeSet) TypeSpec() NodeSet {
-	return p.GenDecl(token.TYPE).Child()
-}
-
-// VarSpec returns variables *ast.ValueSpec node set.
-func (p NodeSet) VarSpec() NodeSet {
-	return p.GenDecl(token.VAR).Child()
-}
-
-// ConstSpec returns constants *ast.ValueSpec node set.
-func (p NodeSet) ConstSpec() NodeSet {
-	return p.GenDecl(token.CONST).Child()
-}
-
-// ImportSpec returns *ast.ImportSpec node set.
-func (p NodeSet) ImportSpec() NodeSet {
-	return p.GenDecl(token.IMPORT).Child()
-}
-
-// -----------------------------------------------------------------------------
-
-type oneNode struct {
-	Node
-}
-
-func (p *oneNode) ForEach(filter func(node Node) error) error {
-	return filter(p.Node)
-}
-
-func (p *oneNode) Cached() int {
-	return 1
-}
-
-// One returns the first node as a node set.
-func (p NodeSet) One() NodeSet {
-	if _, ok := p.Data.(*oneNode); ok {
-		return p
-	}
-	node, err := p.CollectOne()
-	if err != nil {
-		return NodeSet{Err: err}
-	}
-	return NodeSet{Data: &oneNode{node}}
-}
-
-// One creates a node set that only contains a signle node.
-func One(node Node) NodeSet {
-	return NodeSet{Data: &oneNode{node}}
-}
-
-// -----------------------------------------------------------------------------
-
-type fixNodes struct {
-	nodes []Node
-}
-
-func (p *fixNodes) ForEach(filter func(node Node) error) error {
-	for _, node := range p.nodes {
-		if filter(node) == ErrBreak {
-			return ErrBreak
-		}
-	}
-	return nil
-}
-
-func (p *fixNodes) Cached() int {
-	return len(p.nodes)
-}
-
-// Nodes creates a fixed node set.
-func Nodes(nodes ...Node) NodeSet {
-	return NodeSet{Data: &fixNodes{nodes}}
-}
-
-// -----------------------------------------------------------------------------
-
-type cached interface {
-	Cached() int
-}
-
-// Cache caches node set.
-func (p NodeSet) Cache() NodeSet {
-	if _, ok := p.Data.(cached); ok {
-		return p
-	}
-	nodes, err := p.Collect()
-	if err != nil {
-		return NodeSet{Err: err}
-	}
-	return NodeSet{Data: &fixNodes{nodes}}
-}
-
-// -----------------------------------------------------------------------------
-
-type anyNodes struct {
-	data NodeEnum
-}
-
-func (p *anyNodes) ForEach(filter func(node Node) error) error {
-	return p.data.ForEach(func(node Node) error {
-		return anyForEach(node, filter)
-	})
-}
-
-func anyForEach(p Node, filter func(node Node) error) error {
-	if err := filter(p); err == ErrBreak {
-		return err
-	}
-	return p.ForEach(func(node Node) error {
-		return anyForEach(node, filter)
-	})
-}
-
-// Any returns deeply visiting node set.
-func (p NodeSet) Any() (ret NodeSet) {
-	if p.Err != nil {
-		return p
-	}
-	return NodeSet{Data: &anyNodes{p.Data}}
-}
-
-// -----------------------------------------------------------------------------
-
-type childNodes struct {
-	data NodeEnum
-}
-
-func (p *childNodes) ForEach(filter func(node Node) error) error {
-	return p.data.ForEach(func(node Node) error {
-		return node.ForEach(filter)
-	})
-}
-
-// Child returns child node set.
-func (p NodeSet) Child() NodeSet {
-	if p.Err != nil {
-		return p
-	}
-	return NodeSet{Data: &childNodes{p.Data}}
-}
-
-// -----------------------------------------------------------------------------
-
-type matchedNodes struct {
-	data  NodeEnum
-	match func(node Node) bool
-}
-
-func (p *matchedNodes) ForEach(filter func(node Node) error) error {
-	return p.data.ForEach(func(node Node) error {
-		if p.match(node) {
-			return filter(node)
-		}
-		return ErrNotFound
-	})
-}
-
-// Match filters the node set.
-func (p NodeSet) Match(match func(node Node) bool) (ret NodeSet) {
-	if p.Err != nil {
-		return p
-	}
-	return NodeSet{Data: &matchedNodes{p.Data, match}}
-}
-
-// -----------------------------------------------------------------------------
-
-// Name returns names of the node set.
-func (p NodeSet) Name() []string {
-	return p.ToString(NameOf)
-}
-
-// ToString returns string values of the node set.
-func (p NodeSet) ToString(str func(node Node) string) (items []string) {
-	if p.Err != nil {
-		return nil
-	}
-	p.Data.ForEach(func(node Node) error {
-		items = append(items, str(node))
-		return nil
-	})
-	return
-}
-
-// Collect collects all nodes of the node set.
-func (p NodeSet) Collect() (items []Node, err error) {
-	if p.Err != nil {
-		return nil, p.Err
-	}
-	p.Data.ForEach(func(node Node) error {
-		items = append(items, node)
-		return nil
-	})
-	return
-}
-
-// CollectOne collects one node of a node set.
-// If exactly is true, it returns ErrTooManyNodes when node set is more than one.
-func (p NodeSet) CollectOne(exactly ...bool) (item Node, err error) {
-	if p.Err != nil {
-		return nil, p.Err
-	}
-	err = ErrNotFound
-	if exactly != nil {
-		if !exactly[0] {
-			panic("please call `CollectOne()` instead of `CollectOne(false)`")
-		}
-		p.Data.ForEach(func(node Node) error {
-			if err == ErrNotFound {
-				item, err = node, nil
-				return nil
-			}
-			err = ErrTooManyNodes
-			return ErrBreak
-		})
-	} else {
-		p.Data.ForEach(func(node Node) error {
-			item, err = node, nil
-			return ErrBreak
-		})
-	}
-	return
-}
-
-// -----------------------------------------------------------------------------
diff --git a/ast/gopq/gopq.go b/ast/gopq/gopq.go
new file mode 100644
index 00000000..8e86c96c
--- /dev/null
+++ b/ast/gopq/gopq.go
@@ -0,0 +1,746 @@
+/*
+ * Copyright (c) 2021 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package gopq
+
+import (
+	"errors"
+	"io/fs"
+	"syscall"
+
+	"github.com/goplus/gop/ast"
+	"github.com/goplus/gop/parser"
+	"github.com/goplus/gop/parser/fsx"
+	"github.com/goplus/gop/token"
+)
+
+// -----------------------------------------------------------------------------
+
+const (
+	GopPackage = true
+)
+
+var (
+	// ErrBreak - break
+	ErrBreak = syscall.ELOOP
+
+	// ErrNotFound - not found
+	ErrNotFound = errors.New("not found")
+
+	// ErrTooManyNodes - too may nodes
+	ErrTooManyNodes = errors.New("too many nodes")
+
+	// ErrUnexpectedNode - unexpected node
+	ErrUnexpectedNode = errors.New("unexpected node")
+)
+
+// Node - node interface
+type Node interface {
+	ast.Node
+	ForEach(filter func(node Node) error) error
+	Obj() interface{}
+}
+
+// NodeEnum - node enumerator
+type NodeEnum interface {
+	ForEach(filter func(node Node) error) error
+}
+
+// NodeSet - node set
+type NodeSet struct {
+	Data NodeEnum
+	Err  error
+}
+
+// FromFile calls ParseFile for a single file and returns *ast.File node set.
+func FromFile(fset *token.FileSet, filename string, src interface{}, mode parser.Mode) (doc NodeSet, err error) {
+	file, err := parser.ParseFile(fset, filename, src, mode)
+	if err != nil {
+		return
+	}
+	return NodeSet{Data: &oneNode{astFile{file}}}, nil
+}
+
+// FromFSFile calls ParseFSFile for a single file and returns *ast.File node set.
+func FromFSFile(
+	fset *token.FileSet, fs fsx.FileSystem,
+	filename string, src interface{}, mode parser.Mode) (doc NodeSet, err error) {
+	file, err := parser.ParseFSFile(fset, fs, filename, src, mode)
+	if err != nil {
+		return
+	}
+	return NodeSet{Data: &oneNode{astFile{file}}}, nil
+}
+
+// FromDir calls ParseFile for all files with names ending in ".gop" in the
+// directory specified by path and returns a map of package name -> package
+// AST with all the packages found.
+//
+// If filter != nil, only the files with fs.FileInfo entries passing through
+// the filter (and ending in ".gop") are considered. The mode bits are passed
+// to ParseFile unchanged. Position information is recorded in fset, which
+// must not be nil.
+//
+// If the directory couldn't be read, a nil map and the respective error are
+// returned. If a parse error occurred, a non-nil but incomplete map and the
+// first error encountered are returned.
+func FromDir(
+	fset *token.FileSet, path string,
+	filter func(fs.FileInfo) bool, mode parser.Mode) (doc NodeSet, err error) {
+
+	pkgs, err := parser.ParseDir(fset, path, filter, mode)
+	if err != nil {
+		return
+	}
+	return NodeSet{Data: &oneNode{astPackages(pkgs)}}, nil
+}
+
+// FromFSDir calls ParseFile for all files with names ending in ".gop" in the
+// directory specified by path and returns a map of package name -> package
+// AST with all the packages found.
+//
+// If filter != nil, only the files with fs.FileInfo entries passing through
+// the filter (and ending in ".gop") are considered. The mode bits are passed
+// to ParseFile unchanged. Position information is recorded in fset, which
+// must not be nil.
+//
+// If the directory couldn't be read, a nil map and the respective error are
+// returned. If a parse error occurred, a non-nil but incomplete map and the
+// first error encountered are returned.
+func FromFSDir(
+	fset *token.FileSet, fs parser.FileSystem, path string,
+	filter func(fs.FileInfo) bool, mode parser.Mode) (doc NodeSet, err error) {
+
+	pkgs, err := parser.ParseFSDir(fset, fs, path, parser.Config{Filter: filter, Mode: mode})
+	if err != nil {
+		return
+	}
+	return NodeSet{Data: &oneNode{astPackages(pkgs)}}, nil
+}
+
+// Ok returns if node set is valid or not.
+func (p NodeSet) Ok() bool {
+	return p.Err == nil
+}
+
+// -----------------------------------------------------------------------------
+
+// FuncDecl returns *ast.FuncDecl node set.
+func (p NodeSet) FuncDecl__0() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astDecl); ok {
+			_, ok = node.Decl.(*ast.FuncDecl)
+			return ok
+		}
+		return false
+	})
+}
+
+// FuncDecl returns *ast.FuncDecl node set.
+func (p NodeSet) FuncDecl__1(name string) NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astDecl); ok {
+			if fn, ok := node.Decl.(*ast.FuncDecl); ok {
+				return fn.Name.Name == name
+			}
+		}
+		return false
+	})
+}
+
+// GenDecl returns *ast.GenDecl node set.
+func (p NodeSet) GenDecl__0(tok token.Token) NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astDecl); ok {
+			if decl, ok := node.Decl.(*ast.GenDecl); ok {
+				return decl.Tok == tok
+			}
+		}
+		return false
+	})
+}
+
+// TypeSpec returns *ast.TypeSpec node set.
+func (p NodeSet) TypeSpec() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astSpec); ok {
+			_, ok = node.Spec.(*ast.TypeSpec)
+			return ok
+		}
+		return false
+	})
+}
+
+// ValueSpec returns *ast.ValueSpec node set.
+func (p NodeSet) ValueSpec() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astSpec); ok {
+			_, ok = node.Spec.(*ast.ValueSpec)
+			return ok
+		}
+		return false
+	})
+}
+
+// ImportSpec returns *ast.ImportSpec node set.
+func (p NodeSet) ImportSpec() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astSpec); ok {
+			_, ok = node.Spec.(*ast.ImportSpec)
+			return ok
+		}
+		return false
+	})
+}
+
+// ExprStmt returns *ast.ExprStmt node set.
+func (p NodeSet) ExprStmt() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astStmt); ok {
+			_, ok = node.Stmt.(*ast.ExprStmt)
+			return ok
+		}
+		return false
+	})
+}
+
+// AssignStmt returns *ast.AssignStmt node set.
+func (p NodeSet) AssignStmt() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astStmt); ok {
+			_, ok = node.Stmt.(*ast.AssignStmt)
+			return ok
+		}
+		return false
+	})
+}
+
+// CallExpr returns *ast.CallExpr node set.
+func (p NodeSet) CallExpr__0() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astExpr); ok {
+			_, ok = node.Expr.(*ast.CallExpr)
+			return ok
+		}
+		return false
+	})
+}
+
+// CallExpr returns *ast.CallExpr node set.
+func (p NodeSet) CallExpr__1(name string) NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astExpr); ok {
+			if expr, ok := node.Expr.(*ast.CallExpr); ok {
+				return getName(expr.Fun, true) == name
+			}
+		}
+		return false
+	})
+}
+
+// CompositeLit returns *ast.CompositeLit node set.
+func (p NodeSet) CompositeLit__0() NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astExpr); ok {
+			_, ok = node.Expr.(*ast.CompositeLit)
+			return ok
+		}
+		return false
+	})
+}
+
+// CompositeLit returns *ast.CompositeLit node set.
+func (p NodeSet) CompositeLit__1(name string) NodeSet {
+	return p.Match(func(node Node) bool {
+		if node, ok := node.(*astExpr); ok {
+			if lit, ok := node.Expr.(*ast.CompositeLit); ok && lit.Type != nil {
+				return getName(lit.Type, true) == name
+			}
+		}
+		return false
+	})
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) Gop_Enum(callback func(node NodeSet)) {
+	if p.Err == nil {
+		p.Data.ForEach(func(node Node) error {
+			t := NodeSet{Data: &oneNode{node}}
+			callback(t)
+			return nil
+		})
+	}
+}
+
+func (p NodeSet) ForEach(callback func(node NodeSet)) {
+	p.Gop_Enum(callback)
+}
+
+// -----------------------------------------------------------------------------
+
+type oneNode struct {
+	Node
+}
+
+func (p *oneNode) ForEach(filter func(node Node) error) error {
+	return filter(p.Node)
+}
+
+func (p *oneNode) Cached() int {
+	return 1
+}
+
+// One returns the first node as a node set.
+func (p NodeSet) One() NodeSet {
+	if _, ok := p.Data.(*oneNode); ok {
+		return p
+	}
+	node, err := p.CollectOne__0()
+	if err != nil {
+		return NodeSet{Err: err}
+	}
+	return NodeSet{Data: &oneNode{node}}
+}
+
+// One creates a node set that only contains a signle node.
+func One__0(node Node) NodeSet {
+	return NodeSet{Data: &oneNode{node}}
+}
+
+// One creates a node set that only contains a signle node.
+func One__1(f *ast.File) NodeSet {
+	return NodeSet{Data: &oneNode{astFile{f}}}
+}
+
+// One creates a node set that only contains a signle node.
+func One__2(pkg *ast.Package) NodeSet {
+	return NodeSet{Data: &oneNode{astPackage{pkg}}}
+}
+
+// -----------------------------------------------------------------------------
+
+type fixNodes struct {
+	nodes []Node
+}
+
+func (p *fixNodes) ForEach(filter func(node Node) error) error {
+	for _, node := range p.nodes {
+		if filter(node) == ErrBreak {
+			return ErrBreak
+		}
+	}
+	return nil
+}
+
+func (p *fixNodes) Cached() int {
+	return len(p.nodes)
+}
+
+// Nodes creates a fixed node set.
+func Nodes(nodes ...Node) NodeSet {
+	return NodeSet{Data: &fixNodes{nodes}}
+}
+
+// -----------------------------------------------------------------------------
+
+type cached interface {
+	Cached() int
+}
+
+// Cache caches node set.
+func (p NodeSet) Cache() NodeSet {
+	if _, ok := p.Data.(cached); ok {
+		return p
+	}
+	nodes, err := p.Collect()
+	if err != nil {
+		return NodeSet{Err: err}
+	}
+	return NodeSet{Data: &fixNodes{nodes}}
+}
+
+// -----------------------------------------------------------------------------
+
+type anyNodes struct {
+	data NodeEnum
+}
+
+func (p *anyNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		return anyForEach(node, filter)
+	})
+}
+
+func anyForEach(p Node, filter func(node Node) error) error {
+	if err := filter(p); err == ErrBreak {
+		return err
+	}
+	return p.ForEach(func(node Node) error {
+		return anyForEach(node, filter)
+	})
+}
+
+// Any returns deeply visiting node set.
+func (p NodeSet) Any() (ret NodeSet) {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &anyNodes{p.Data}}
+}
+
+// -----------------------------------------------------------------------------
+
+type childNodes struct {
+	data NodeEnum
+}
+
+func (p *childNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		return node.ForEach(filter)
+	})
+}
+
+// Child returns child node set.
+func (p NodeSet) Child() NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &childNodes{p.Data}}
+}
+
+// -----------------------------------------------------------------------------
+
+type bodyNodes struct {
+	data NodeEnum
+}
+
+func (p *bodyNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astDecl:
+			if fn, ok := node.Decl.(*ast.FuncDecl); ok && fn.Body != nil {
+				return filter(&astStmt{fn.Body})
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Body returns body node set.
+func (p NodeSet) Body() NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &bodyNodes{p.Data}}
+}
+
+// -----------------------------------------------------------------------------
+
+type xNodes struct {
+	data NodeEnum
+}
+
+func (p *xNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astStmt:
+			switch stmt := node.Stmt.(type) {
+			case *ast.ExprStmt:
+				return filter(&astExpr{stmt.X})
+			}
+		case *astExpr:
+			switch expr := node.Expr.(type) {
+			case *ast.SelectorExpr:
+				return filter(&astExpr{expr.X})
+			case *ast.UnaryExpr:
+				return filter(&astExpr{expr.X})
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// X returns x node set.
+func (p NodeSet) X() NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &xNodes{p.Data}}
+}
+
+// -----------------------------------------------------------------------------
+
+type funNodes struct {
+	data NodeEnum
+}
+
+func (p *funNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astExpr:
+			switch expr := node.Expr.(type) {
+			case *ast.CallExpr:
+				return filter(&astExpr{expr.Fun})
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Fun returns fun node set.
+func (p NodeSet) Fun() NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &funNodes{p.Data}}
+}
+
+// -----------------------------------------------------------------------------
+
+type argNodes struct {
+	data NodeEnum
+	i    int
+	varg bool
+}
+
+func (p *argNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astExpr:
+			switch expr := node.Expr.(type) {
+			case *ast.CallExpr:
+				args := expr.Args
+				if p.varg {
+					for i, n := p.i, len(args); i < n; i++ {
+						if err := filter(&astExpr{args[i]}); err == ErrBreak {
+							return err
+						}
+					}
+					return nil
+				} else {
+					return filter(&astExpr{args[p.i]})
+				}
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Arg returns args[i] node set.
+func (p NodeSet) Arg(i int) NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &argNodes{p.Data, i, false}}
+}
+
+// Varg returns args[i:] node set.
+func (p NodeSet) Varg(i int) NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &argNodes{p.Data, i, true}}
+}
+
+// -----------------------------------------------------------------------------
+
+type ieltNodes struct {
+	data NodeEnum
+	i    int
+}
+
+func (p *ieltNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astExpr:
+			switch expr := node.Expr.(type) {
+			case *ast.CompositeLit:
+				if i := p.i; i == -1 {
+					for _, elt := range expr.Elts {
+						if err := filter(&astExpr{elt}); err == ErrBreak {
+							return err
+						}
+					}
+				} else {
+					return filter(&astExpr{expr.Elts[i]})
+				}
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Elt returns elts[i] node set.
+func (p NodeSet) Elt__0(i int) NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &ieltNodes{p.Data, i}}
+}
+
+// Elt returns elts[:] node set.
+func (p NodeSet) Elt__1() NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &ieltNodes{p.Data, -1}}
+}
+
+// -----------------------------------------------------------------------------
+
+type eltNodes struct {
+	data NodeEnum
+	name string
+}
+
+func (p *eltNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astExpr:
+			switch expr := node.Expr.(type) {
+			case *ast.CompositeLit:
+				if elt, ok := getElt(expr.Elts, p.name); ok {
+					return filter(&astExpr{elt})
+				}
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Elt returns elts[name] node set.
+func (p NodeSet) Elt__2(name string) NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &eltNodes{p.Data, name}}
+}
+
+// -----------------------------------------------------------------------------
+
+type rhsNodes struct {
+	data NodeEnum
+	i    int
+}
+
+func (p *rhsNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		switch node := node.(type) {
+		case *astStmt:
+			switch stmt := node.Stmt.(type) {
+			case *ast.AssignStmt:
+				return filter(&astExpr{stmt.Rhs[p.i]})
+			}
+		}
+		return ErrNotFound
+	})
+}
+
+// Rhs returns rhs[i] node set.
+func (p NodeSet) Rhs(i int) NodeSet {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &rhsNodes{p.Data, i}}
+}
+
+// -----------------------------------------------------------------------------
+
+type matchedNodes struct {
+	data  NodeEnum
+	match func(node Node) bool
+}
+
+func (p *matchedNodes) ForEach(filter func(node Node) error) error {
+	return p.data.ForEach(func(node Node) error {
+		if p.match(node) {
+			return filter(node)
+		}
+		return ErrNotFound
+	})
+}
+
+// Match filters the node set.
+func (p NodeSet) Match(match func(node Node) bool) (ret NodeSet) {
+	if p.Err != nil {
+		return p
+	}
+	return NodeSet{Data: &matchedNodes{p.Data, match}}
+}
+
+// -----------------------------------------------------------------------------
+
+// Name returns names of the node set.
+func (p NodeSet) Name() []string {
+	return p.ToString(NameOf)
+}
+
+// ToString returns string values of the node set.
+func (p NodeSet) ToString(str func(node Node) string) (items []string) {
+	if p.Err != nil {
+		return nil
+	}
+	p.Data.ForEach(func(node Node) error {
+		items = append(items, str(node))
+		return nil
+	})
+	return
+}
+
+// Collect collects all nodes of the node set.
+func (p NodeSet) Collect() (items []Node, err error) {
+	if p.Err != nil {
+		return nil, p.Err
+	}
+	p.Data.ForEach(func(node Node) error {
+		items = append(items, node)
+		return nil
+	})
+	return
+}
+
+// CollectOne collects one node of a node set.
+// If exactly is true, it returns ErrTooManyNodes when node set is more than one.
+func (p NodeSet) CollectOne__1(exactly bool) (item Node, err error) {
+	if p.Err != nil {
+		return nil, p.Err
+	}
+	err = ErrNotFound
+	if exactly {
+		p.Data.ForEach(func(node Node) error {
+			if err == ErrNotFound {
+				item, err = node, nil
+				return nil
+			}
+			err = ErrTooManyNodes
+			return ErrBreak
+		})
+	} else {
+		p.Data.ForEach(func(node Node) error {
+			item, err = node, nil
+			return ErrBreak
+		})
+	}
+	return
+}
+
+// CollectOne returns the first node.
+func (p NodeSet) CollectOne__0() (item Node, err error) {
+	return p.CollectOne__1(false)
+}
+
+// -----------------------------------------------------------------------------
diff --git a/ast/gopq/helper.go b/ast/gopq/helper.go
new file mode 100644
index 00000000..ffeda30a
--- /dev/null
+++ b/ast/gopq/helper.go
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package gopq
+
+import (
+	"strconv"
+
+	"github.com/goplus/gop/ast"
+	"github.com/goplus/gop/token"
+)
+
+// -----------------------------------------------------------------------------
+
+// Funcs returns all `*ast.FuncDecl` nodes.
+func (p NodeSet) Funcs() NodeSet {
+	return p.Any().FuncDecl__0().Cache()
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) UnquotedString__1(exactly bool) (ret string, err error) {
+	item, err := p.CollectOne__1(exactly)
+	if err != nil {
+		return
+	}
+	if lit, ok := item.Obj().(*ast.BasicLit); ok && lit.Kind == token.STRING {
+		return strconv.Unquote(lit.Value)
+	}
+	return "", ErrUnexpectedNode
+}
+
+func (p NodeSet) UnquotedString__0() (ret string, err error) {
+	return p.UnquotedString__1(false)
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) UnquotedStringElts__1(exactly bool) (ret []string, err error) {
+	item, err := p.CollectOne__1(exactly)
+	if err != nil {
+		return
+	}
+	if lit, ok := item.Obj().(*ast.CompositeLit); ok {
+		ret = make([]string, len(lit.Elts))
+		for i, elt := range lit.Elts {
+			if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
+				if ret[i], err = strconv.Unquote(lit.Value); err != nil {
+					return
+				}
+			} else {
+				return nil, ErrUnexpectedNode
+			}
+		}
+		return
+	}
+	return nil, ErrUnexpectedNode
+}
+
+func (p NodeSet) UnquotedStringElts__0() (ret []string, err error) {
+	return p.UnquotedStringElts__1(false)
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) Positions__1(exactly bool) (ret []token.Pos, err error) {
+	item, err := p.CollectOne__1(exactly)
+	if err != nil {
+		return
+	}
+	switch o := item.Obj().(type) {
+	case *ast.CompositeLit:
+		return []token.Pos{o.Pos(), o.End(), o.Lbrace, o.Rbrace}, nil
+	case ast.Node:
+		return []token.Pos{o.Pos(), o.End()}, nil
+	}
+	return nil, ErrUnexpectedNode
+}
+
+func (p NodeSet) Positions__0() (ret []token.Pos, err error) {
+	return p.Positions__1(false)
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) EltLen__1(exactly bool) (ret int, err error) {
+	item, err := p.CollectOne__1(exactly)
+	if err != nil {
+		return
+	}
+	if lit, ok := item.Obj().(*ast.CompositeLit); ok {
+		return len(lit.Elts), nil
+	}
+	return 0, ErrUnexpectedNode
+}
+
+func (p NodeSet) EltLen__0() (ret int, err error) {
+	return p.EltLen__1(false)
+}
+
+// -----------------------------------------------------------------------------
+
+func (p NodeSet) Ident__1(exactly bool) (ret string, err error) {
+	item, err := p.CollectOne__1(exactly)
+	if err != nil {
+		return
+	}
+	if ident, ok := item.Obj().(*ast.Ident); ok {
+		return ident.Name, nil
+	}
+	return "", ErrUnexpectedNode
+}
+
+func (p NodeSet) Ident__0() (ret string, err error) {
+	return p.Ident__1(false)
+}
+
+// -----------------------------------------------------------------------------
+
+func getElt(elts []ast.Expr, name string) (ast.Expr, bool) {
+	for _, elt := range elts {
+		if kv, ok := elt.(*ast.KeyValueExpr); ok {
+			if ident, ok := kv.Key.(*ast.Ident); ok && ident.Name == name {
+				return kv.Value, true
+			}
+		}
+	}
+	return nil, false
+}
+
+// -----------------------------------------------------------------------------
+
+// NameOf returns name of an ast node.
+func NameOf(node Node) string {
+	return getName(node.Obj(), false)
+}
+
+func getName(v interface{}, useEmpty bool) string {
+	switch v := v.(type) {
+	case *ast.FuncDecl:
+		return v.Name.Name
+	case *ast.ImportSpec:
+		n := v.Name
+		if n == nil {
+			return ""
+		}
+		return n.Name
+	case *ast.Ident:
+		return v.Name
+	case *ast.SelectorExpr:
+		return getName(v.X, useEmpty) + "." + v.Sel.Name
+	}
+	if useEmpty {
+		return ""
+	}
+	panic("node doesn't contain the `name` property")
+}
+
+// -----------------------------------------------------------------------------
+
+func CodeOf(fset *token.FileSet, f *ast.File, start, end token.Pos) string {
+	pos := fset.Position(start)
+	n := int(end - start)
+	return string(f.Code[pos.Offset : pos.Offset+n])
+}
+
+// -----------------------------------------------------------------------------
diff --git a/ast/walk.go b/ast/walk.go
index 88a21fd5..38cfc3fc 100644
--- a/ast/walk.go
+++ b/ast/walk.go
@@ -98,7 +98,7 @@ func Walk(v Visitor, node Node) {
 		}
 
 	// Expressions
-	case *BadExpr, *Ident:
+	case *BadExpr, *Ident, *NumberUnitLit:
 		// nothing to do
 
 	case *BasicLit:
@@ -110,6 +110,16 @@ func Walk(v Visitor, node Node) {
 			}
 		}
 
+	case *DomainTextLit:
+		Walk(v, n.Domain)
+		if n.Extra != nil { // Go+ extended
+			for _, part := range n.Extra.Parts {
+				if e, ok := part.(Expr); ok {
+					Walk(v, e)
+				}
+			}
+		}
+
 	case *Ellipsis:
 		if n.Elt != nil {
 			Walk(v, n.Elt)
diff --git a/builtin/doc/builtin.gop b/builtin/doc/builtin.gop
new file mode 100755
index 00000000..373db69b
--- /dev/null
+++ b/builtin/doc/builtin.gop
@@ -0,0 +1,476 @@
+package builtin
+
+import (
+	"gop/builtin/iox"
+	"io"
+	"os"
+	"reflect"
+)
+
+// Echo formats using the default formats for its operands and writes to standard output.
+// Spaces are always added between operands and a newline is appended.
+// It returns the number of bytes written and any write error encountered.
+func Echo(a ...any) (n int, err error)
+func Blines(r io.Reader) iox.BLineReader
+
+// Type returns the reflection [Type] that represents the dynamic type of i.
+// If i is a nil interface value, Type returns nil.
+func Type(i any) reflect.Type
+
+// Print formats using the default formats for its operands and writes to standard output.
+// Spaces are added between operands when neither is a string.
+// It returns the number of bytes written and any write error encountered.
+func Print(a ...any) (n int, err error)
+
+// Println formats using the default formats for its operands and writes to standard output.
+// Spaces are always added between operands and a newline is appended.
+// It returns the number of bytes written and any write error encountered.
+func Println(a ...any) (n int, err error)
+
+// Printf formats according to a format specifier and writes to standard output.
+// It returns the number of bytes written and any write error encountered.
+func Printf(format string, a ...any) (n int, err error)
+
+// Errorf formats according to a format specifier and returns the string as a
+// value that satisfies error.
+//
+// If the format specifier includes a %w verb with an error operand,
+// the returned error will implement an Unwrap method returning the operand.
+// If there is more than one %w verb, the returned error will implement an
+// Unwrap method returning a []error containing all the %w operands in the
+// order they appear in the arguments.
+// It is invalid to supply the %w verb with an operand that does not implement
+// the error interface. The %w verb is otherwise a synonym for %v.
+func Errorf(format string, a ...any) error
+
+// Fprint formats using the default formats for its operands and writes to w.
+// Spaces are added between operands when neither is a string.
+// It returns the number of bytes written and any write error encountered.
+func Fprint(w io.Writer, a ...any) (n int, err error)
+
+// Fprintln formats using the default formats for its operands and writes to w.
+// Spaces are always added between operands and a newline is appended.
+// It returns the number of bytes written and any write error encountered.
+func Fprintln(w io.Writer, a ...any) (n int, err error)
+
+// Fprintf formats according to a format specifier and writes to w.
+// It returns the number of bytes written and any write error encountered.
+func Fprintf(w io.Writer, format string, a ...any) (n int, err error)
+
+// Sprint formats using the default formats for its operands and returns the resulting string.
+// Spaces are added between operands when neither is a string.
+func Sprint(a ...any) string
+
+// Sprintln formats using the default formats for its operands and returns the resulting string.
+// Spaces are always added between operands and a newline is appended.
+func Sprintln(a ...any) string
+
+// Sprintf formats according to a format specifier and returns the resulting string.
+func Sprintf(format string, a ...any) string
+
+// Open opens the named file for reading. If successful, methods on
+// the returned file can be used for reading; the associated file
+// descriptor has mode O_RDONLY.
+// If there is an error, it will be of type *PathError.
+func Open(name string) (*os.File, error)
+
+// Create creates or truncates the named file. If the file already exists,
+// it is truncated. If the file does not exist, it is created with mode 0666
+// (before umask). If successful, methods on the returned File can
+// be used for I/O; the associated file descriptor has mode O_RDWR.
+// If there is an error, it will be of type *PathError.
+func Create(name string) (*os.File, error)
+func Lines(r io.Reader) iox.LineReader
+func (r io.Reader) Gop_Enum() iox.LineIter
+
+// String is equivalent to strconv.FormatFloat(f, 'g', -1, 64)
+//
+// FormatFloat converts the floating-point number f to a string,
+// according to the format fmt and precision prec. It rounds the
+// result assuming that the original was obtained from a floating-point
+// value of bitSize bits (32 for float32, 64 for float64).
+//
+// The format fmt is one of
+// 'b' (-ddddp±ddd, a binary exponent),
+// 'e' (-d.dddde±dd, a decimal exponent),
+// 'E' (-d.ddddE±dd, a decimal exponent),
+// 'f' (-ddd.dddd, no exponent),
+// 'g' ('e' for large exponents, 'f' otherwise),
+// 'G' ('E' for large exponents, 'f' otherwise),
+// 'x' (-0xd.ddddp±ddd, a hexadecimal fraction and binary exponent), or
+// 'X' (-0Xd.ddddP±ddd, a hexadecimal fraction and binary exponent).
+//
+// The precision prec controls the number of digits (excluding the exponent)
+// printed by the 'e', 'E', 'f', 'g', 'G', 'x', and 'X' formats.
+// For 'e', 'E', 'f', 'x', and 'X', it is the number of digits after the decimal point.
+// For 'g' and 'G' it is the maximum number of significant digits (trailing
+// zeros are removed).
+// The special precision -1 uses the smallest number of digits
+// necessary such that ParseFloat will return f exactly.
+func (f float64) String() string
+
+// String is equivalent to FormatInt(int64(i), 10).
+func (i int) String() string
+
+// String is equivalent to strconv.FormatInt(i, 10)
+//
+// FormatInt returns the string representation of i in the given base,
+// for 2 <= base <= 36. The result uses the lower-case letters 'a' to 'z'
+// for digit values >= 10.
+func (i int64) String() string
+
+// String is equivalent to strconv.FormatUint(u, 10)
+//
+// FormatUint returns the string representation of i in the given base,
+// for 2 <= base <= 36. The result uses the lower-case letters 'a' to 'z'
+// for digit values >= 10.
+func (u uint64) String() string
+
+// The len built-in function returns the length of v, according to its type:
+//
+//	Array: the number of elements in v.
+//	Pointer to array: the number of elements in *v (even if v is nil).
+//	Slice, or map: the number of elements in v; if v is nil, len(v) is zero.
+//	String: the number of bytes in v.
+//	Channel: the number of elements queued (unread) in the channel buffer;
+//	         if v is nil, len(v) is zero.
+//
+// For some arguments, such as a string literal or a simple array expression, the
+// result can be a constant. See the Go language specification's "Length and
+// capacity" section for details.
+func (s string) Len() int
+
+// Count counts the number of non-overlapping instances of substr in s.
+// If substr is an empty string, Count returns 1 + the number of Unicode code points in s.
+func (s string) Count(substr string) int
+
+// Int is equivalent to ParseInt(s, 10, 0), converted to type int.
+func (s string) Int() (int, error)
+
+// Int64 is equivalent to strconv.ParseInt(s, 10, 64)
+//
+// ParseInt interprets a string s in the given base (0, 2 to 36) and
+// bit size (0 to 64) and returns the corresponding value i.
+//
+// The string may begin with a leading sign: "+" or "-".
+//
+// If the base argument is 0, the true base is implied by the string's
+// prefix following the sign (if present): 2 for "0b", 8 for "0" or "0o",
+// 16 for "0x", and 10 otherwise. Also, for argument base 0 only,
+// underscore characters are permitted as defined by the Go syntax for
+// [integer literals].
+//
+// The bitSize argument specifies the integer type
+// that the result must fit into. Bit sizes 0, 8, 16, 32, and 64
+// correspond to int, int8, int16, int32, and int64.
+// If bitSize is below 0 or above 64, an error is returned.
+//
+// The errors that ParseInt returns have concrete type *NumError
+// and include err.Num = s. If s is empty or contains invalid
+// digits, err.Err = ErrSyntax and the returned value is 0;
+// if the value corresponding to s cannot be represented by a
+// signed integer of the given size, err.Err = ErrRange and the
+// returned value is the maximum magnitude integer of the
+// appropriate bitSize and sign.
+//
+// [integer literals]: https://go.dev/ref/spec#Integer_literals
+func (s string) Int64() (i int64, err error)
+
+// Uint64 is equivalent to strconv.ParseUint(s, 10, 64)
+//
+// ParseUint is like ParseInt but for unsigned numbers.
+//
+// A sign prefix is not permitted.
+func (s string) Uint64() (uint64, error)
+
+// Float is equivalent to strconv.ParseFloat(s, 64)
+//
+// ParseFloat converts the string s to a floating-point number
+// with the precision specified by bitSize: 32 for float32, or 64 for float64.
+// When bitSize=32, the result still has type float64, but it will be
+// convertible to float32 without changing its value.
+//
+// ParseFloat accepts decimal and hexadecimal floating-point numbers
+// as defined by the Go syntax for [floating-point literals].
+// If s is well-formed and near a valid floating-point number,
+// ParseFloat returns the nearest floating-point number rounded
+// using IEEE754 unbiased rounding.
+// (Parsing a hexadecimal floating-point value only rounds when
+// there are more bits in the hexadecimal representation than
+// will fit in the mantissa.)
+//
+// The errors that ParseFloat returns have concrete type *NumError
+// and include err.Num = s.
+//
+// If s is not syntactically well-formed, ParseFloat returns err.Err = ErrSyntax.
+//
+// If s is syntactically well-formed but is more than 1/2 ULP
+// away from the largest floating point number of the given size,
+// ParseFloat returns f = ±Inf, err.Err = ErrRange.
+//
+// ParseFloat recognizes the string "NaN", and the (possibly signed) strings "Inf" and "Infinity"
+// as their respective special floating point values. It ignores case when matching.
+//
+// [floating-point literals]: https://go.dev/ref/spec#Floating-point_literals
+func (s string) Float() (float64, error)
+
+// Index returns the index of the first instance of substr in s, or -1 if substr is not present in s.
+func (s string) Index(substr string) int
+
+// IndexAny returns the index of the first instance of any Unicode code point
+// from chars in s, or -1 if no Unicode code point from chars is present in s.
+func (s string) IndexAny(chars string) int
+
+// IndexByte returns the index of the first instance of c in s, or -1 if c is not present in s.
+func (s string) IndexByte(c byte) int
+
+// IndexRune returns the index of the first instance of the Unicode code point
+// r, or -1 if rune is not present in s.
+// If r is utf8.RuneError, it returns the first instance of any
+// invalid UTF-8 byte sequence.
+func (s string) IndexRune(r rune) int
+
+// LastIndex returns the index of the last instance of substr in s, or -1 if substr is not present in s.
+func (s string) LastIndex(substr string) int
+
+// LastIndexAny returns the index of the last instance of any Unicode code
+// point from chars in s, or -1 if no Unicode code point from chars is
+// present in s.
+func (s string) LastIndexAny(chars string) int
+
+// LastIndexByte returns the index of the last instance of c in s, or -1 if c is not present in s.
+func (s string) LastIndexByte(c byte) int
+
+// Contains reports whether substr is within s.
+func (s string) Contains(substr string) bool
+
+// ContainsAny reports whether any Unicode code points in chars are within s.
+func (s string) ContainsAny(chars string) bool
+
+// ContainsRune reports whether the Unicode code point r is within s.
+func (s string) ContainsRune(r rune) bool
+
+// Compare returns an integer comparing two strings lexicographically.
+// The result will be 0 if a == b, -1 if a < b, and +1 if a > b.
+//
+// Compare is included only for symmetry with package bytes.
+// It is usually clearer and always faster to use the built-in
+// string comparison operators ==, <, >, and so on.
+func (s string) Compare(b string) int
+
+// EqualFold reports whether s and t, interpreted as UTF-8 strings,
+// are equal under simple Unicode case-folding, which is a more general
+// form of case-insensitivity.
+func (s string) EqualFold(t string) bool
+
+// HasPrefix reports whether the string s begins with prefix.
+func (s string) HasPrefix(prefix string) bool
+
+// HasSuffix reports whether the string s ends with suffix.
+func (s string) HasSuffix(suffix string) bool
+
+// Quote returns a double-quoted Go string literal representing s. The
+// returned string uses Go escape sequences (\t, \n, \xFF, \u0100) for
+// control characters and non-printable characters as defined by
+// IsPrint.
+func (s string) Quote() string
+
+// Unquote interprets s as a single-quoted, double-quoted,
+// or backquoted Go string literal, returning the string value
+// that s quotes.  (If s is single-quoted, it would be a Go
+// character literal; Unquote returns the corresponding
+// one-character string.)
+func (s string) Unquote() (string, error)
+
+// ToTitle returns a copy of the string s with all Unicode letters mapped to
+// their Unicode title case.
+func (s string) ToTitle() string
+
+// ToUpper returns s with all Unicode letters mapped to their upper case.
+func (s string) ToUpper() string
+
+// ToLower returns s with all Unicode letters mapped to their lower case.
+func (s string) ToLower() string
+
+// Fields splits the string s around each instance of one or more consecutive white space
+// characters, as defined by unicode.IsSpace, returning a slice of substrings of s or an
+// empty slice if s contains only white space.
+func (s string) Fields() []string
+
+// Repeat returns a new string consisting of count copies of the string s.
+//
+// It panics if count is negative or if the result of (len(s) * count)
+// overflows.
+func (s string) Repeat(count int) string
+
+// Split slices s into all substrings separated by sep and returns a slice of
+// the substrings between those separators.
+//
+// If s does not contain sep and sep is not empty, Split returns a
+// slice of length 1 whose only element is s.
+//
+// If sep is empty, Split splits after each UTF-8 sequence. If both s
+// and sep are empty, Split returns an empty slice.
+//
+// It is equivalent to [SplitN] with a count of -1.
+//
+// To split around the first instance of a separator, see Cut.
+func (s string) Split(sep string) []string
+
+// SplitAfter slices s into all substrings after each instance of sep and
+// returns a slice of those substrings.
+//
+// If s does not contain sep and sep is not empty, SplitAfter returns
+// a slice of length 1 whose only element is s.
+//
+// If sep is empty, SplitAfter splits after each UTF-8 sequence. If
+// both s and sep are empty, SplitAfter returns an empty slice.
+//
+// It is equivalent to [SplitAfterN] with a count of -1.
+func (s string) SplitAfter(sep string) []string
+
+// SplitN slices s into substrings separated by sep and returns a slice of
+// the substrings between those separators.
+//
+// The count determines the number of substrings to return:
+//
+//	n > 0: at most n substrings; the last substring will be the unsplit remainder.
+//	n == 0: the result is nil (zero substrings)
+//	n < 0: all substrings
+//
+// Edge cases for s and sep (for example, empty strings) are handled
+// as described in the documentation for [Split].
+//
+// To split around the first instance of a separator, see Cut.
+func (s string) SplitN(sep string, n int) []string
+
+// SplitAfterN slices s into substrings after each instance of sep and
+// returns a slice of those substrings.
+//
+// The count determines the number of substrings to return:
+//
+//	n > 0: at most n substrings; the last substring will be the unsplit remainder.
+//	n == 0: the result is nil (zero substrings)
+//	n < 0: all substrings
+//
+// Edge cases for s and sep (for example, empty strings) are handled
+// as described in the documentation for SplitAfter.
+func (s string) SplitAfterN(sep string, n int) []string
+
+// Replace returns a copy of the string s with the first n
+// non-overlapping instances of old replaced by new.
+// If old is empty, it matches at the beginning of the string
+// and after each UTF-8 sequence, yielding up to k+1 replacements
+// for a k-rune string.
+// If n < 0, there is no limit on the number of replacements.
+func (s string) Replace(old, new string, n int) string
+
+// ReplaceAll returns a copy of the string s with all
+// non-overlapping instances of old replaced by new.
+// If old is empty, it matches at the beginning of the string
+// and after each UTF-8 sequence, yielding up to k+1 replacements
+// for a k-rune string.
+func (s string) ReplaceAll(old, new string) string
+
+// Trim returns a slice of the string s with all leading and
+// trailing Unicode code points contained in cutset removed.
+func (s string) Trim(cutset string) string
+
+// TrimSpace returns a slice of the string s, with all leading
+// and trailing white space removed, as defined by Unicode.
+func (s string) TrimSpace() string
+
+// TrimLeft returns a slice of the string s with all leading
+// Unicode code points contained in cutset removed.
+//
+// To remove a prefix, use [TrimPrefix] instead.
+func (s string) TrimLeft(cutset string) string
+
+// TrimRight returns a slice of the string s, with all trailing
+// Unicode code points contained in cutset removed.
+//
+// To remove a suffix, use [TrimSuffix] instead.
+func (s string) TrimRight(cutset string) string
+
+// TrimPrefix returns s without the provided leading prefix string.
+// If s doesn't start with prefix, s is returned unchanged.
+func (s string) TrimPrefix(prefix string) string
+
+// TrimSuffix returns s without the provided trailing suffix string.
+// If s doesn't end with suffix, s is returned unchanged.
+func (s string) TrimSuffix(suffix string) string
+
+// Capitalize returns a copy of the string str with the first letter mapped to
+// its upper case.
+func (s string) Capitalize() string
+
+// The len built-in function returns the length of v, according to its type:
+//
+//	Array: the number of elements in v.
+//	Pointer to array: the number of elements in *v (even if v is nil).
+//	Slice, or map: the number of elements in v; if v is nil, len(v) is zero.
+//	String: the number of bytes in v.
+//	Channel: the number of elements queued (unread) in the channel buffer;
+//	         if v is nil, len(v) is zero.
+//
+// For some arguments, such as a string literal or a simple array expression, the
+// result can be a constant. See the Go language specification's "Length and
+// capacity" section for details.
+func (v []string) Len() int
+
+// The cap built-in function returns the capacity of v, according to its type:
+//
+//	Array: the number of elements in v (same as len(v)).
+//	Pointer to array: the number of elements in *v (same as len(v)).
+//	Slice: the maximum length the slice can reach when resliced;
+//	if v is nil, cap(v) is zero.
+//	Channel: the channel buffer capacity, in units of elements;
+//	if v is nil, cap(v) is zero.
+//
+// For some arguments, such as a simple array expression, the result can be a
+// constant. See the Go language specification's "Length and capacity" section for
+// details.
+func (v []string) Cap() int
+
+// Join concatenates the elements of its first argument to create a single string. The separator
+// string sep is placed between elements in the resulting string.
+func (v []string) Join(sep string) string
+
+// Capitalize capitalizes the first letter of each string in the slice.
+func (v []string) Capitalize() []string
+
+// ToTitle title-cases all strings in the slice.
+func (v []string) ToTitle() []string
+
+// ToUpper upper-cases all strings in the slice.
+func (v []string) ToUpper() []string
+
+// ToLower lower-cases all strings in the slice.
+func (v []string) ToLower() []string
+
+// Repeat repeats each string in the slice count times.
+func (v []string) Repeat(count int) []string
+
+// Replace replaces all occurrences of old in each string in the slice with new.
+func (v []string) Replace(old, new string, n int) []string
+
+// ReplaceAll replaces all occurrences of old in each string in the slice with new.
+func (v []string) ReplaceAll(old, new string) []string
+
+// Trim removes leading and trailing white space from each string in the slice.
+func (v []string) Trim(cutset string) []string
+
+// TrimSpace removes leading and trailing white space from each string in the slice.
+func (v []string) TrimSpace() []string
+
+// TrimLeft removes leading white space from each string in the slice.
+func (v []string) TrimLeft(cutset string) []string
+
+// TrimRight removes trailing white space from each string in the slice.
+func (v []string) TrimRight(cutset string) []string
+
+// TrimPrefix removes leading prefix from each string in the slice.
+func (v []string) TrimPrefix(prefix string) []string
+
+// TrimSuffix removes trailing suffix from each string in the slice.
+func (v []string) TrimSuffix(suffix string) []string
diff --git a/cl/builtin.go b/cl/builtin.go
index c61a6c5c..93445a39 100644
--- a/cl/builtin.go
+++ b/cl/builtin.go
@@ -38,7 +38,7 @@ func initBuiltinFns(builtin *types.Package, scope *types.Scope, pkg gogen.PkgRef
 	}
 }
 
-func initBuiltin(_ *gogen.Package, builtin *types.Package, os, fmt, ng, iox, buil gogen.PkgRef) {
+func initBuiltin(_ *gogen.Package, builtin *types.Package, os, fmt, ng, iox, buil, reflect gogen.PkgRef) {
 	scope := builtin.Scope()
 	if ng.Types != nil {
 		typs := []string{"bigint", "bigrat", "bigfloat"}
@@ -68,6 +68,9 @@ func initBuiltin(_ *gogen.Package, builtin *types.Package, os, fmt, ng, iox, bui
 		})
 		scope.Insert(gogen.NewOverloadFunc(token.NoPos, builtin, "blines", iox.Ref("BLines")))
 	}
+	if reflect.Types != nil {
+		scope.Insert(gogen.NewOverloadFunc(token.NoPos, builtin, "type", reflect.Ref("TypeOf")))
+	}
 	if buil.Types != nil {
 		scope.Insert(gogen.NewOverloadFunc(token.NoPos, builtin, "newRange", buil.Ref("NewRange__0")))
 	}
@@ -78,6 +81,7 @@ func newBuiltinDefault(pkg *gogen.Package, conf *gogen.Config) *types.Package {
 	builtin := types.NewPackage("", "")
 	fmt := pkg.TryImport("fmt")
 	os := pkg.TryImport("os")
+	reflect := pkg.TryImport("reflect")
 	buil := pkg.TryImport("github.com/goplus/gop/builtin")
 	ng := pkg.TryImport("github.com/goplus/gop/builtin/ng")
 	iox := pkg.TryImport("github.com/goplus/gop/builtin/iox")
@@ -88,7 +92,7 @@ func newBuiltinDefault(pkg *gogen.Package, conf *gogen.Config) *types.Package {
 	if ng.Types != nil {
 		initMathBig(pkg, conf, ng)
 	}
-	initBuiltin(pkg, builtin, os, fmt, ng, iox, buil)
+	initBuiltin(pkg, builtin, os, fmt, ng, iox, buil, reflect)
 	gogen.InitBuiltin(pkg, builtin, conf)
 	if strx.Types != nil {
 		ti := pkg.BuiltinTI(types.Typ[types.String])
diff --git a/cl/classfile.go b/cl/classfile.go
index 073f059c..badebbc2 100644
--- a/cl/classfile.go
+++ b/cl/classfile.go
@@ -394,14 +394,12 @@ func gmxProjMain(pkg *gogen.Package, parent *pkgCtx, proj *gmxProject) {
 	})
 }
 
-func gmxMainFunc(pkg *gogen.Package, proj *gmxProject) func() {
-	return func() {
-		if o := pkg.TryRef(proj.gameClass); o != nil {
-			// new(gameClass).Main()
-			new := pkg.Builtin().Ref("new")
-			pkg.NewFunc(nil, "main", nil, nil, false).
-				BodyStart(pkg).Val(new).Val(o).Call(1).MemberVal("Main").Call(0).EndStmt().End()
-		}
+func genMainFunc(pkg *gogen.Package, gameClass string) {
+	if o := pkg.TryRef(gameClass); o != nil {
+		// new(gameClass).Main()
+		new := pkg.Builtin().Ref("new")
+		pkg.NewFunc(nil, "main", nil, nil, false).
+			BodyStart(pkg).Val(new).Val(o).Call(1).MemberVal("Main").Call(0).EndStmt().End()
 	}
 }
 
diff --git a/cl/compile.go b/cl/compile.go
index afe723cc..24e22e87 100644
--- a/cl/compile.go
+++ b/cl/compile.go
@@ -342,6 +342,9 @@ type pkgCtx struct {
 	generics map[string]bool // generic type record
 	idents   []*ast.Ident    // toType ident recored
 	inInst   int             // toType in generic instance
+
+	goxMain      int // normal gox files with main func
+	goxMainClass string
 }
 
 type pkgImp struct {
@@ -595,7 +598,7 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 
 	// genMain = true if it is main package and no main func
 	var genMain bool
-	var gen func()
+	var mainClass string
 	if pkg.Name == "main" {
 		_, hasMain := ctx.syms["main"]
 		genMain = !hasMain
@@ -608,8 +611,12 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 	}
 
 	if genMain { // make classfile main func if need
-		if proj != nil && !multi { // only one project file
-			gen = gmxMainFunc(p, proj)
+		if proj != nil {
+			if !multi { // only one project file
+				mainClass = proj.gameClass
+			}
+		} else if ctx.goxMain == 1 {
+			mainClass = ctx.goxMainClass // main func in normal gox file
 		}
 	}
 
@@ -631,8 +638,8 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 	}
 	err = ctx.complete()
 
-	if gen != nil { // generate classfile main func
-		gen()
+	if mainClass != "" { // generate classfile main func
+		genMainFunc(p, mainClass)
 	} else if genMain && !conf.NoAutoGenMain { // generate empty main func
 		old, _ := p.SetCurFile(defaultGoFile, false)
 		p.NewFunc(nil, "main", nil, nil, false).BodyStart(p).End()
@@ -726,6 +733,10 @@ func preloadGopFile(p *gogen.Package, ctx *blockCtx, file string, f *ast.File, c
 			if classType == "main" {
 				classType = "_main"
 			}
+			if f.ShadowEntry != nil {
+				parent.goxMainClass = classType
+				parent.goxMain++
+			}
 		} else {
 			c = parent.classes[f]
 			classType = c.tname
diff --git a/cl/expr.go b/cl/expr.go
index dc2241e2..da4db1b8 100644
--- a/cl/expr.go
+++ b/cl/expr.go
@@ -387,6 +387,8 @@ func compileExpr(ctx *blockCtx, expr ast.Expr, inFlags ...int) {
 		compileEnvExpr(ctx, v)
 	/* case *ast.MatrixLit:
 	compileMatrixLit(ctx, v) */
+	case *ast.DomainTextLit:
+		compileDomainTextLit(ctx, v)
 	default:
 		panic(ctx.newCodeErrorf(v.Pos(), "compileExpr failed: unknown - %T", v))
 	}
@@ -1071,6 +1073,19 @@ func compileStringLitEx(ctx *blockCtx, cb *gogen.CodeBuilder, lit *ast.BasicLit)
 	}
 }
 
+const (
+	tplPkgPath = "github.com/goplus/gop/tpl"
+)
+
+func compileDomainTextLit(ctx *blockCtx, v *ast.DomainTextLit) {
+	switch v.Domain.Name {
+	case "tpl": // tpl`...` => tpl.new(`...`)
+		ctx.cb.Val(ctx.pkg.Import(tplPkgPath).Ref("New")).
+			Val(&goast.BasicLit{Kind: gotoken.STRING, Value: v.Value}, v).
+			Call(1)
+	}
+}
+
 const (
 	compositeLitVal    = 0
 	compositeLitKeyVal = 1
diff --git a/cmd/chore/gopbuiltingen/builtin.gox b/cmd/chore/gopbuiltingen/builtin.gox
new file mode 100644
index 00000000..8a1177a0
--- /dev/null
+++ b/cmd/chore/gopbuiltingen/builtin.gox
@@ -0,0 +1,40 @@
+import (
+	"go/ast"
+)
+
+var (
+	Name string
+	Fn   reference
+)
+
+func genAST() *ast.FuncDecl {
+	f := Fn
+	ref := f.getAST()
+	newName := Name.capitalize
+	return {
+		Doc:  docForFunc(ref.Doc, f.Name, newName),
+		Name: ast.newIdent(newName),
+		Type: reference.toFuncType(ref.Type, f.Pkg),
+	}
+}
+
+func genMethodAST(methods []builtin) *ast.FuncDecl {
+	f := Fn
+	at := f.Pkg
+	ref := f.getAST()
+	ex := f.Exargs
+	mt, recvType := reference.toMethodType(ref.Type, ex, at)
+	if at == "" { // builtin
+		recvType = methods[methods.len-1].genAST().Type.Params.List[0].Type
+	}
+	recvName := recvNameOf(recvType)
+	return {
+		Doc:  docForMethod(ref.Doc, at, f.Name, Name, recvName, ex),
+		Name: ast.newIdent(Name),
+		Type: mt,
+		Recv: {List: {{
+			Names: []*ast.Ident{ast.newIdent(recvName)},
+			Type:  recvType,
+		}}},
+	}
+}
diff --git a/cmd/chore/gopbuiltingen/builtingen.gox b/cmd/chore/gopbuiltingen/builtingen.gox
new file mode 100644
index 00000000..13935193
--- /dev/null
+++ b/cmd/chore/gopbuiltingen/builtingen.gox
@@ -0,0 +1,137 @@
+import (
+	"bytes"
+	"go/ast"
+	"go/format"
+	"go/parser"
+	"go/token"
+	gopast "gop/ast"
+	"gop/ast/gopq"
+	"gop/parser"
+	"os"
+)
+
+type builtinTI struct {
+	Methods []builtin
+}
+
+var (
+	Builtins []builtin
+	Types    []*builtinTI
+)
+
+func gen() []byte {
+	f := &ast.File{
+		Name:  ast.newIdent("builtin"),
+		Decls: make([]ast.Decl, 0, 128),
+	}
+	f.Decls <- &ast.GenDecl{
+		Tok: token.IMPORT,
+		Specs: []ast.Spec{
+			importSpec("gop/builtin/iox"),
+			importSpec("io"),
+			importSpec("os"),
+			importSpec("reflect"),
+		},
+	}
+	genDecls f
+	b := new(bytes.Buffer)
+	format.node! b, fset, f
+	return format.source(b.bytes)!
+}
+
+func genDecls(f *ast.File) {
+	for built <- Builtins {
+		f.Decls <- built.genAST()
+	}
+	for t <- Types {
+		mthds := t.Methods
+		for m <- mthds {
+			f.Decls <- m.genMethodAST(mthds)
+		}
+	}
+}
+
+func initBuiltinTIs(fn gopq.NodeSet, f *gopast.File) (tistr *builtinTI) {
+	ti := fn.body.any.assignStmt.rhs(0).x.compositeLit("BuiltinTI")
+	methods := ti.elt("methods").cache
+	for method <- methods {
+		aTI := &builtinTI{}
+		for item <- method.elt {
+			mthd := item.elt(0).unquotedString!
+			fn := item.elt(1).one
+			if ref := fn.callExpr.one; ref.ok {
+				pkg := ref.fun.x.ident!
+				name := ref.arg(0).unquotedString!
+				var ex *exargs
+				if e := item.elt(2).compositeLit("bmExargs").one; e.ok {
+					pos := e.positions!
+					code := gopq.codeOf(fset, f, pos[2]+1, pos[3])
+					ex = &exargs{e.eltLen!, code}
+				}
+				aTI.Methods <- builtin{mthd, {pkg, name, ex}}
+			} else {
+				name := fn.ident!
+				aTI.Methods <- builtin{mthd, {"", name, nil}}
+			}
+		}
+		Types <- aTI
+		if len(aTI.Methods) > 10 {
+			tistr = aTI
+		}
+	}
+	return
+}
+
+func newBuiltinDefault(fn gopq.NodeSet, tistr *builtinTI) {
+	methods := fn.body.any.exprStmt.x.callExpr("ti.AddMethods").cache
+	for method <- methods {
+		aTI := &builtinTI{}
+		for arg <- method.varg(0).x {
+			mthd := arg.elt("Name").unquotedString!
+			ref := arg.elt("Fn").callExpr.one
+			pkg := ref.fun.x.ident!
+			name := ref.arg(0).unquotedString!
+			if pkg == "strx" {
+				tistr.Methods <- builtin{mthd, {pkg, name, nil}}
+			} else {
+				aTI.Methods <- builtin{mthd, {pkg, name, nil}}
+			}
+		}
+		if len(aTI.Methods) > 0 {
+			Types <- aTI
+		}
+	}
+}
+
+func initBuiltin(fn gopq.NodeSet) {
+	stmt := fn.body.any.exprStmt.x.cache
+	item := stmt.callExpr("scope.Insert").arg(0).cache
+	for call <- item.callExpr("gogen.NewOverloadFunc") {
+		built := call.arg(2).unquotedString!
+		if built != "newRange" { // hide builtin `newRange`
+			ref := call.arg(3).callExpr.one
+			pkg := ref.fun.x.ident!
+			name := ref.arg(0).unquotedString!
+			Builtins <- builtin{built, {pkg, name, nil}}
+		}
+	}
+	for call <- stmt.callExpr("initBuiltinFns") {
+		pkg := call.arg(2).ident!
+		builtins := call.arg(3).unquotedStringElts!
+		for built <- builtins {
+			Builtins <- builtin{built, {pkg, built.capitalize, nil}}
+		}
+	}
+}
+
+f := parser.parseFile(fset, "${root}/../gogen/builtin.go", nil, parser.ParseComments)!
+fns := gopq.one(f).funcs
+tistr := initBuiltinTIs(fns.funcDecl("initBuiltinTIs").one, f)
+
+fns = gopq.fromFile(fset, "${root}/cl/builtin.go", nil, parser.ParseComments)!.funcs
+initBuiltin fns.funcDecl("initBuiltin").one
+newBuiltinDefault fns.funcDecl("newBuiltinDefault").one, tistr
+
+b := gen()
+os.Stdout.write b
+os.writeFile "${root}/builtin/doc/builtin.gop", b, 0777
diff --git a/cmd/chore/gopbuiltingen/helper.gop b/cmd/chore/gopbuiltingen/helper.gop
new file mode 100644
index 00000000..6278fb15
--- /dev/null
+++ b/cmd/chore/gopbuiltingen/helper.gop
@@ -0,0 +1,111 @@
+import (
+	"go/ast"
+)
+
+func recvNameOf(recvType ast.Expr) string {
+	var name string
+	switch t := recvType.(type) {
+	case *ast.Ident:
+		name = t.Name[:1]
+	case *ast.SelectorExpr:
+		name = t.Sel.Name[:1]
+	default:
+		return "v"
+	}
+	return name.toLower
+}
+
+func importSpec(path string) *ast.ImportSpec {
+	return {
+		Path: {
+			Value: path.quote,
+		},
+	}
+}
+
+func rmExargs(list []*ast.Field, ex *exargs) []*ast.Field {
+	if ex == nil {
+		return list
+	}
+	n := len(list)
+	ret := make([]*ast.Field, n)
+	for i, f <- list {
+		ret[i] = f
+	}
+	exargs := ex.N
+	for n > 0 && exargs > 0 {
+		f := ret[n-1]
+		if c := len(f.Names); c > exargs {
+			f.Names = f.Names[:c-exargs]
+			break
+		} else {
+			exargs -= c
+		}
+		n--
+	}
+	return ret[:n]
+}
+
+func toParams(params *ast.FieldList, at string) *ast.FieldList {
+	if params == nil {
+		return nil
+	}
+	list := make([]*ast.Field, len(params.List))
+	for i, p <- params.List {
+		typ := p.Type
+		switch t := typ.(type) {
+		case *ast.Ident:
+			if t.isExported {
+				typ = &ast.SelectorExpr{
+					X:   ast.newIdent(at),
+					Sel: t,
+				}
+			}
+		case *ast.StarExpr:
+			if x, ok := t.X.(*ast.Ident); ok && x.isExported {
+				typ = &ast.StarExpr{
+					X: &ast.SelectorExpr{
+						X:   ast.newIdent(at),
+						Sel: x,
+					},
+				}
+			}
+		}
+		list[i] = {
+			Doc:     p.Doc,
+			Names:   p.Names,
+			Type:    typ,
+			Tag:     p.Tag,
+			Comment: p.Comment,
+		}
+	}
+	return {List: list}
+}
+
+func docForFunc(doc *ast.CommentGroup, oldName, newName string) *ast.CommentGroup {
+	if doc == nil {
+		return nil
+	}
+	list := make([]*ast.Comment, len(doc.List))
+	for i, c := range doc.List {
+		text := c.Text.replaceAll(oldName, newName)
+		list[i] = {Text: text}
+	}
+	return {List: list}
+}
+
+func docForMethod(doc *ast.CommentGroup, at, oldName, newName, recvName string, ex *exargs) *ast.CommentGroup {
+	if doc == nil {
+		return nil
+	}
+	if ex == nil {
+		return docForFunc(doc, oldName, newName)
+	}
+	list := make([]*ast.Comment, 0, len(doc.List)+2)
+	list <- &ast.Comment{Text: "// ${newName} is equivalent to ${at}.${oldName}(${recvName}, ${ex.Code})"}
+	list <- &ast.Comment{Text: "//"}
+	for _, c := range doc.List {
+		list <- &ast.Comment{Text: c.Text}
+	}
+	return {List: list}
+}
diff --git a/cmd/chore/gopbuiltingen/reference.gox b/cmd/chore/gopbuiltingen/reference.gox
new file mode 100644
index 00000000..c29524f8
--- /dev/null
+++ b/cmd/chore/gopbuiltingen/reference.gox
@@ -0,0 +1,106 @@
+import (
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"gop/env"
+	"runtime"
+)
+
+type exargs struct {
+	N    int
+	Code string
+}
+
+var (
+	Pkg    string
+	Name   string
+	Exargs *exargs
+)
+
+func .toFuncType(t *ast.FuncType, at string) *ast.FuncType {
+	return {
+		Params:  t.Params,
+		Results: toParams(t.Results, at),
+	}
+}
+
+func .toMethodType(t *ast.FuncType, ex *exargs, at string) (mt *ast.FuncType, recvType ast.Expr) {
+	list := t.Params.List
+	first := list[0]
+	recvType = first.Type
+	if len(first.Names) == 1 {
+		list = list[1:]
+	} else {
+		list[0] = {
+			Doc:     first.Doc,
+			Names:   first.Names[1:],
+			Type:    recvType,
+			Comment: first.Comment,
+		}
+	}
+	mt = {
+		Params:  {List: rmExargs(list, ex)},
+		Results: toParams(t.Results, at),
+	}
+	return
+}
+
+var (
+	fset    = token.newFileSet
+	pkgs    = map[string]*ast.Package{}
+	root    = env.GOPROOT()
+	goroot  = runtime.GOROOT()
+	pkgDirs = {
+		"":        "${goroot}/src/builtin",
+		"fmt":     "${goroot}/src/fmt",
+		"os":      "${goroot}/src/os",
+		"reflect": "${goroot}/src/reflect",
+		"strconv": "${goroot}/src/strconv",
+		"strings": "${goroot}/src/strings",
+
+		"buil":        "${root}/builtin",
+		"iox":         "${root}/builtin/iox",
+		"stringslice": "${root}/builtin/stringslice",
+		"strx":        "${root}/../x/stringutil",
+	}
+)
+
+func getAST() *ast.FuncDecl {
+	name := Name
+	if Pkg == "" {
+		name = name.trimPrefix("bto").toLower
+	}
+	pkg := reference.pkgOf(Pkg)
+	return reference.funcDecl(pkg, name)
+}
+
+func .funcDecl(pkg *ast.Package, name string) *ast.FuncDecl {
+	for file <- pkg.Files {
+		for decl <- file.Decls {
+			if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name {
+				return fn
+			}
+		}
+	}
+	panic("function not found: ${pkg.Name}.${name}")
+}
+
+func .pkgOf(pkgPath string) *ast.Package {
+	if pkg, ok := pkgs[pkgPath]; ok {
+		return pkg
+	}
+
+	dir, ok := pkgDirs[pkgPath]
+	if !ok {
+		panic("unknown package: ${pkgPath}")
+	}
+
+	for name, pkg <- parser.parseDir(fset, dir, nil, parser.ParseComments)! {
+		if name.hasSuffix("_test") {
+			continue
+		}
+		pkgs[pkgPath] = pkg
+		return pkg
+	}
+	panic("package not found: ${pkgPath}")
+}
diff --git a/demo/gop-parser/_spec/expr.gop b/demo/gop-parser/_spec/expr.gop
new file mode 100644
index 00000000..6fde8d7e
--- /dev/null
+++ b/demo/gop-parser/_spec/expr.gop
@@ -0,0 +1,54 @@
+node Expr {
+	exprNode()
+}
+
+node (
+	Ident      Expr
+	BasicLit   Expr
+	CallExpr   Expr
+	ParenExpr  Expr
+	UnaryExpr  Expr
+	BinaryExpr Expr
+)
+
+expr = x:termExpr *(binOp:("+" | "-") Y:termExpr as BinaryExpr {
+	X: x
+	OpPos: binOp.Pos
+	Op: binOp.Tok
+})
+
+termExpr = x:unaryExpr *(binOp:("*" | "/" | "%") Y:unaryExpr as BinaryExpr {
+	X: x
+	OpPos: binOp.Pos
+	Op: binOp.Tok
+})
+
+unaryExpr = primaryExpr | (uOp:("+" | "-" | "!" | "^") X:primaryExpr as UnaryExpr {
+	OpPos: uOp.Pos
+	Op: uOp.Tok
+})
+
+primaryExpr = operand *(lp:"(" ?(Args:(expr % ",") ?ell:"..." ?",") rp:")" as CallExpr {
+	Fun: operand
+	Lparen: lp.Pos
+	Ellipsis: ell != nil ? ell.Pos : 0
+	Rparen: rp.Pos
+})
+
+operand = BasicLit | Ident | ParenExpr
+
+BasicLit = v:(INT | FLOAT | IMAG | CHAR | STRING) {
+	ValuePos: v.Pos
+	Kind: v.Tok
+	Value: v.Lit
+}
+
+Ident = i:IDENT {
+	NamePos: i.Pos
+	Name: i.Lit
+}
+
+ParenExpr = lp:"(" X:expr rp:")" {
+	Lparen: lp.Pos
+	Rparen: rp.Pos
+}
diff --git a/demo/gop-parser/_spec/simple.gop b/demo/gop-parser/_spec/simple.gop
new file mode 100644
index 00000000..868b29ce
--- /dev/null
+++ b/demo/gop-parser/_spec/simple.gop
@@ -0,0 +1,7 @@
+expr = termExpr | expr ("+" | "-") expr
+
+termExpr = unaryExpr | termExpr ("*" | "/") termExpr
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
diff --git a/demo/gop-parser/_spec/simple2.gop b/demo/gop-parser/_spec/simple2.gop
new file mode 100644
index 00000000..42c1a043
--- /dev/null
+++ b/demo/gop-parser/_spec/simple2.gop
@@ -0,0 +1,7 @@
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
diff --git a/demo/gop-parser/_spec/tpl.gop b/demo/gop-parser/_spec/tpl.gop
new file mode 100644
index 00000000..16ce1b57
--- /dev/null
+++ b/demo/gop-parser/_spec/tpl.gop
@@ -0,0 +1,9 @@
+doc = +rule
+
+rule = IDENT '=' expr ';'
+
+expr = +term % '|'
+
+term = factor % '%'
+
+factor = IDENT | CHAR | STRING | ('*' | '+' | '?') factor | '(' expr ')'
diff --git a/demo/gop-parser/gopcalc/calc.gop b/demo/gop-parser/gopcalc-1/calc.gop
similarity index 100%
rename from demo/gop-parser/gopcalc/calc.gop
rename to demo/gop-parser/gopcalc-1/calc.gop
diff --git a/demo/gop-parser/gopcalc-2/calc.gop b/demo/gop-parser/gopcalc-2/calc.gop
new file mode 100644
index 00000000..ba219945
--- /dev/null
+++ b/demo/gop-parser/gopcalc-2/calc.gop
@@ -0,0 +1,52 @@
+import (
+	"gop/tpl"
+	"os"
+)
+
+func calc(e any) float64 {
+	switch e := e.(type) {
+	case *tpl.Token:
+		return e.Lit.float!
+	case []any:
+		if x, ok := e[0].(*tpl.Token); ok && x.Tok == '-' {
+			return -calc(e[1])
+		}
+		x := calc(e[0])
+		for r <- e[1].([]any) {
+			e := r.([]any)
+			y := calc(e[1])
+			switch e[0].(*tpl.Token).Tok {
+			case '+':
+				x += y
+			case '-':
+				x -= y
+			case '*':
+				x *= y
+			case '/':
+				x /= y
+			}
+		}
+		return x
+	}
+	panic("unknown expression")
+}
+
+cl := tpl`
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
+`!
+
+print "> "
+for line <- os.Stdin {
+	e, err := cl.parseExpr(line, nil)
+	if err != nil {
+		print "${err}\n> "
+	} else {
+		print "${calc(e)}\n> "
+	}
+}
diff --git a/demo/gop-parser/gopcalc-dump/calc_dump.gop b/demo/gop-parser/gopcalc-dump/calc_dump.gop
new file mode 100644
index 00000000..e473d8e9
--- /dev/null
+++ b/demo/gop-parser/gopcalc-dump/calc_dump.gop
@@ -0,0 +1,25 @@
+import (
+	"gop/tpl"
+	"os"
+)
+
+cl := tpl`
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
+`!
+
+print "> "
+for line <- os.Stdin {
+	e, err := cl.parseExpr(line, nil)
+	if err != nil {
+		print "${err}\n> "
+	} else {
+		tpl.dump e
+		print "> "
+	}
+}
diff --git a/demo/gop-typeof/typeof.gop b/demo/gop-typeof/typeof.gop
new file mode 100644
index 00000000..bfb18c50
--- /dev/null
+++ b/demo/gop-typeof/typeof.gop
@@ -0,0 +1,1 @@
+echo type([1]), type("Hi")
diff --git a/doc/_spec/go.gop b/doc/_spec/go.gop
new file mode 100644
index 00000000..6b270105
--- /dev/null
+++ b/doc/_spec/go.gop
@@ -0,0 +1,187 @@
+// -----------------------------------------------------------------
+
+node Decl {
+	declNode()
+}
+
+node Spec {
+	specNode()
+}
+
+node Stmt {
+	stmtNode()
+}
+
+node Expr {
+	exprNode()
+}
+
+node GenDecl : Decl {
+	TokPos pos
+	Tok token
+	Lparen pos
+	Specs []Spec
+	Rparen pos
+}
+
+node ImportSpec : Spec {
+	Name Expr
+	Path BasicLit
+	EndPos pos
+}
+
+// -----------------------------------------------------------------
+
+File = (pkg:"package" Name:Ident ";") Decls:(*(importDecl ";") *(toplevelDecl ";")) {
+	Package: pkg.Pos
+}
+
+toplevelDecl = valueDecl | typeDecl | FuncDecl
+
+// -----------------------------------------------------------------
+
+importDecl = i:"import" Specs:(ImportSpec | lp:"(" *(ImportSpec ";") rp:")") as GenDecl {
+	TokPos: i.Pos
+	Tok: i.Tok
+	Lparen: lp.Pos
+	Rparen: rp.Pos
+}
+
+ImportSpec = ?Name:(dotPkgName | Ident) Path:stringLit {
+	EndPos: Path.End
+}
+
+dotPkgName = dot:"." as Ident {
+	NamePos: dot.Pos
+	Name: "."
+}
+
+stringLit = s:STRING as BasicLit {
+	ValuePos: s.Pos
+	Kind: s.Tok
+	Value: s.Lit
+}
+
+// -----------------------------------------------------------------
+
+valueDecl = valKw Specs:(ValueSpec | lp:"(" *(ValueSpec ";") rp:")") as GenDecl {
+	TokPos: valKw.Pos
+	Tok: valKw.Tok
+	Lparen: lp.Pos
+	Rparen: rp.Pos
+}
+
+valKw = "const" | "var"
+
+ValueSpec = Names:identList ?Type ?Values:exprList
+
+identList = Ident % ","
+
+Ident = i:IDENT {
+	NamePos: i.Pos
+	Name: i.Lit
+}
+
+// -----------------------------------------------------------------
+
+Type = typeNameEx | typeLit | "(" Type ")" as Expr
+
+typeLit = ArrayType | StructType | pointerType | FuncType | InterfaceType | MapType | ChanType
+
+typeNameEx = typeName ?(lp:"[" Indices:typeList rp:"]" as IndexListExpr {
+	X: typeName
+	Lbrack: lp.Pos
+	Rbrack: rp.Pos
+})
+
+typeName = Ident | qualifiedIdent as Expr
+
+typeList = Type % ","
+
+qualifiedIdent = X:Ident "." Sel:Ident as SelectorExpr
+
+// -----------------------------------------------------------------
+
+ArrayType = lp:"[" ?Len:expr "]" Elt:Type {
+	Lbrack: lp.Pos
+}
+
+StructType = "struct" Fields:structBody {
+	Struct: struct.Pos
+}
+
+structBody = lp:"{" List:(structField % ";") ?";" rp:"}" as FieldList {
+	Opening: lp.Pos
+	Closing: rp.Pos
+}
+
+structField = (Names:identList Type | Type:embeddedField) ?Tag:stringLit as Field
+
+embeddedField = typeNameEx | (op:"*" X:typeNameEx as StarExpr {
+	Star: op.Pos
+})
+
+// -----------------------------------------------------------------
+
+AssignStmt = Lhs:exprList assignOp Rhs:exprList {
+	TokPos: assignOp.Pos
+	Tok: assignOp.Tok
+}
+
+exprList = expr % ","
+
+assignOp = ":=" | "=" | "+=" | "-=" | "*=" | "/=" | "%="
+
+// -----------------------------------------------------------------
+
+expr = unaryExpr | BinaryExpr
+
+BinaryExpr = X:expr binOp Y:expr { // TODO: precedence
+	OpPos: binOp.Pos
+	Op: binOp.Tok
+}
+
+binOp = "||" | "&&" | relOp | addOp | mulOp
+
+relOp = "==" | "!=" | "<" | "<=" | ">" | ">="
+
+addOp = "+" | "-" | "|" | "^"
+
+mulOp = "*" | "/" | "%" | "<<" | ">>" | "&" | "&^"
+
+unaryExpr = primaryExpr | (unaryOp X:unaryExpr as UnaryExpr {
+	OpPos: unaryOp.Pos
+	Op: unaryOp.Tok
+})
+
+unaryOp = "+" | "-" | "!" | "^" | "*" | "&" | "<-"
+
+primaryExpr = operand | CallExpr | SelectorExpr | IndexExpr | SliceExpr | TypeAssertExpr
+
+operand = TODO
+
+CallExpr = Fun:primaryExpr lp:"(" Args:exprList ?el:"..." ?"," rp:")" {
+	Lparen: lp.Pos
+	Rparen: rp.Pos
+	Ellipsis: el != nil
+}
+
+SelectorExpr = X:primaryExpr "." Sel:Ident
+
+IndexExpr = X:primaryExpr lp:"[" Index:expr ?"," rp:"]" {
+	Lbrack: lp.Pos
+	Rbrack: rp.Pos
+}
+
+SliceExpr = X:primaryExpr lp:"[" ?Low:expr ":" ?High:expr ?(":" Max:expr) rp:"]" {
+	Lbrack: lp.Pos
+	Rbrack: rp.Pos
+	Slice3: Max != nil
+}
+
+TypeAssertExpr = X:primaryExpr "." lp:"(" Type rp:")" {
+	Lparen: lp.Pos
+	Rparen: rp.Pos
+}
+
+// -----------------------------------------------------------------
diff --git a/parser/interface.go b/parser/interface.go
index 7fd616af..831ac793 100644
--- a/parser/interface.go
+++ b/parser/interface.go
@@ -23,6 +23,7 @@ import (
 	"go/scanner"
 
 	"github.com/goplus/gop/ast"
+	"github.com/goplus/gop/parser/iox"
 	"github.com/goplus/gop/token"
 )
 
@@ -83,7 +84,7 @@ func parseFile(fset *token.FileSet, filename string, src interface{}, mode Mode)
 	}
 
 	// get source
-	text, err := readSourceLocal(filename, src)
+	text, err := iox.ReadSourceLocal(filename, src)
 	if err != nil {
 		return
 	}
@@ -133,7 +134,7 @@ func parseFile(fset *token.FileSet, filename string, src interface{}, mode Mode)
 // are returned via a scanner.ErrorList which is sorted by source position.
 func ParseExprFrom(fset *token.FileSet, filename string, src any, mode Mode) (expr ast.Expr, err error) {
 	// get source
-	text, err := readSourceLocal(filename, src)
+	text, err := iox.ReadSourceLocal(filename, src)
 	if err != nil {
 		return
 	}
diff --git a/parser/iox/io.go b/parser/iox/io.go
new file mode 100644
index 00000000..3aa29653
--- /dev/null
+++ b/parser/iox/io.go
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package iox
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"os"
+)
+
+// -----------------------------------------------------------------------------
+
+var (
+	ErrInvalidSource = errors.New("invalid source")
+)
+
+func ReadSource(src any) ([]byte, error) {
+	switch s := src.(type) {
+	case string:
+		return []byte(s), nil
+	case []byte:
+		return s, nil
+	case *bytes.Buffer:
+		// is io.Reader, but src is already available in []byte form
+		if s != nil {
+			return s.Bytes(), nil
+		}
+	case io.Reader:
+		return io.ReadAll(s)
+	}
+	return nil, ErrInvalidSource
+}
+
+// If src != nil, readSource converts src to a []byte if possible;
+// otherwise it returns an error. If src == nil, readSource returns
+// the result of reading the file specified by filename.
+func ReadSourceLocal(filename string, src any) ([]byte, error) {
+	if src != nil {
+		return ReadSource(src)
+	}
+	return os.ReadFile(filename)
+}
+
+// -----------------------------------------------------------------------------
diff --git a/parser/parser.go b/parser/parser.go
index dd7110dc..7c03fb01 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -1718,9 +1718,23 @@ func (p *parser) parseOperand(lhs, allowTuple, allowCmd bool) (x ast.Expr, isTup
 
 	switch p.tok {
 	case token.IDENT:
-		x = p.parseIdent()
-		if !lhs {
-			p.resolve(x)
+		ident := p.parseIdent()
+		if p.tok == token.STRING && p.pos == ident.End() && strings.HasPrefix(p.lit, "`") {
+			// domain text: tpl`...`
+			x = &ast.DomainTextLit{
+				Domain:   ident,
+				ValuePos: p.pos,
+				Value:    p.lit,
+			}
+			if debugParseOutput {
+				log.Printf("ast.DomainTextLit{Domain: %s, Value: %s}\n", ident.Name, p.lit)
+			}
+			p.next()
+		} else {
+			x = ident
+			if !lhs {
+				p.resolve(x)
+			}
 		}
 		return
 
@@ -1800,8 +1814,8 @@ func (p *parser) parseOperand(lhs, allowTuple, allowCmd bool) (x ast.Expr, isTup
 			break
 		}
 		fallthrough
-	case token.GOTO, token.BREAK, token.CONTINUE, token.FALLTHROUGH:
-		// token.RANGE, token.IMPORT, token.TYPE, token.SELECT, token.INTERFACE:
+	case token.GOTO, token.TYPE, token.BREAK, token.CONTINUE, token.FALLTHROUGH:
+		// token.RANGE, token.IMPORT, token.SELECT, token.INTERFACE:
 		// Go+: allow goto() as a function
 		p.tok = token.IDENT
 		x = p.parseIdent()
@@ -2182,7 +2196,6 @@ func (p *parser) checkExpr(x ast.Expr) ast.Expr {
 	case *ast.BadExpr:
 	case *ast.Ident:
 	case *ast.BasicLit:
-	case *ast.NumberUnitLit:
 	case *ast.FuncLit:
 	case *ast.CompositeLit:
 	case *ast.SliceLit:
@@ -2216,6 +2229,8 @@ func (p *parser) checkExpr(x ast.Expr) ast.Expr {
 		x = &ast.BadExpr{From: v.opening, To: v.closing}
 	case *ast.EnvExpr:
 	case *ast.ElemEllipsis:
+	case *ast.NumberUnitLit:
+	case *ast.DomainTextLit:
 	default:
 		// all other nodes are not proper expressions
 		p.errorExpected(x.Pos(), "expression", 3)
@@ -2372,7 +2387,8 @@ func (p *parser) checkCmd() bool {
 	case token.IDENT, token.DRARROW,
 		token.STRING, token.CSTRING, token.PYSTRING,
 		token.INT, token.FLOAT, token.IMAG, token.CHAR, token.RAT,
-		token.FUNC, token.GOTO, token.MAP, token.INTERFACE, token.CHAN, token.STRUCT, token.ENV:
+		token.FUNC, token.GOTO, token.TYPE, token.MAP, token.INTERFACE,
+		token.CHAN, token.STRUCT, token.ENV:
 		return true
 	case token.SUB, token.AND, token.MUL, token.ARROW, token.XOR, token.ADD:
 		oldtok, oldpos := p.tok, p.pos
diff --git a/parser/parser_gop.go b/parser/parser_gop.go
index e7b3b02b..6309871b 100644
--- a/parser/parser_gop.go
+++ b/parser/parser_gop.go
@@ -17,11 +17,8 @@
 package parser
 
 import (
-	"bytes"
 	"errors"
-	"io"
 	"io/fs"
-	"os"
 	"path"
 	"strings"
 
@@ -30,6 +27,7 @@ import (
 
 	"github.com/goplus/gop/ast"
 	"github.com/goplus/gop/parser/fsx"
+	"github.com/goplus/gop/parser/iox"
 	"github.com/goplus/gop/token"
 )
 
@@ -340,40 +338,9 @@ func ParseFSFile(fset *token.FileSet, fs FileSystem, filename string, src interf
 	return parseFile(fset, filename, code, mode)
 }
 
-var (
-	errInvalidSource = errors.New("invalid source")
-)
-
-func readSource(src interface{}) ([]byte, error) {
-	switch s := src.(type) {
-	case string:
-		return []byte(s), nil
-	case []byte:
-		return s, nil
-	case *bytes.Buffer:
-		// is io.Reader, but src is already available in []byte form
-		if s != nil {
-			return s.Bytes(), nil
-		}
-	case io.Reader:
-		return io.ReadAll(s)
-	}
-	return nil, errInvalidSource
-}
-
-// If src != nil, readSource converts src to a []byte if possible;
-// otherwise it returns an error. If src == nil, readSource returns
-// the result of reading the file specified by filename.
-func readSourceLocal(filename string, src interface{}) ([]byte, error) {
-	if src != nil {
-		return readSource(src)
-	}
-	return os.ReadFile(filename)
-}
-
 func readSourceFS(fs FileSystem, filename string, src interface{}) ([]byte, error) {
 	if src != nil {
-		return readSource(src)
+		return iox.ReadSource(src)
 	}
 	return fs.ReadFile(filename)
 }
diff --git a/printer/nodes.go b/printer/nodes.go
index 9899f5ca..2f36c021 100644
--- a/printer/nodes.go
+++ b/printer/nodes.go
@@ -1135,6 +1135,10 @@ func (p *printer) expr1(expr ast.Expr, prec1, depth int) {
 		p.expr(x.Elt)
 		p.print(token.ELLIPSIS)
 
+	case *ast.DomainTextLit:
+		p.print(x.Domain)
+		p.print(&ast.BasicLit{Kind: token.STRING, Value: x.Value})
+
 	default:
 		log.Fatalf("unreachable %T\n", x)
 	}
diff --git a/tpl/ast/ast.go b/tpl/ast/ast.go
new file mode 100644
index 00000000..4cead83d
--- /dev/null
+++ b/tpl/ast/ast.go
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ast
+
+import (
+	"github.com/goplus/gop/tpl/token"
+)
+
+// -----------------------------------------------------------------------------
+
+// Node: File, Decl, Expr
+type Node interface {
+	Pos() token.Pos
+	End() token.Pos
+}
+
+// Decl: Rule
+type Decl interface {
+	Node
+	declNode()
+}
+
+// Expr: Ident, BasicLit, Choice, Sequence, UnaryExpr, BinaryExpr
+type Expr interface {
+	Node
+	exprNode()
+}
+
+// -----------------------------------------------------------------------------
+
+// File: *Decl
+type File struct {
+	Decls     []Decl
+	FileStart token.Pos
+}
+
+func (p *File) Pos() token.Pos {
+	if n := len(p.Decls); n > 0 {
+		return p.Decls[0].Pos()
+	}
+	return p.FileStart
+}
+
+func (p *File) End() token.Pos {
+	if n := len(p.Decls); n > 0 {
+		return p.Decls[n-1].End()
+	}
+	return p.FileStart
+}
+
+// -----------------------------------------------------------------------------
+
+// Rule: IDENT '=' Expr
+type Rule struct {
+	Name   *Ident
+	TokPos token.Pos // position of '='
+	Expr   Expr
+}
+
+func (p *Rule) Pos() token.Pos { return p.Name.Pos() }
+func (p *Rule) End() token.Pos { return p.Expr.End() }
+func (p *Rule) declNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// Ident: IDENT
+type Ident struct {
+	NamePos token.Pos // identifier position
+	Name    string    // identifier name
+}
+
+func (p *Ident) Pos() token.Pos { return p.NamePos }
+func (p *Ident) End() token.Pos { return p.NamePos + token.Pos(len(p.Name)) }
+func (p *Ident) exprNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// BasicLit: STRING | CHAR
+type BasicLit struct {
+	ValuePos token.Pos   // literal position
+	Kind     token.Token // token.STRING or token.CHAR
+	Value    string
+}
+
+func (p *BasicLit) Pos() token.Pos { return p.ValuePos }
+func (p *BasicLit) End() token.Pos { return p.ValuePos + token.Pos(len(p.Value)) }
+func (p *BasicLit) exprNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// Choice: R1 | R2 | ... | Rn
+type Choice struct {
+	Options []Expr // multiple options
+}
+
+func (p *Choice) Pos() token.Pos { return p.Options[0].Pos() }
+func (p *Choice) End() token.Pos { return p.Options[len(p.Options)-1].End() }
+func (p *Choice) exprNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// Sequence: R1 R2 ... Rn
+type Sequence struct {
+	Items []Expr // multiple items
+}
+
+func (p *Sequence) Pos() token.Pos { return p.Items[0].Pos() }
+func (p *Sequence) End() token.Pos { return p.Items[len(p.Items)-1].End() }
+func (p *Sequence) exprNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// UnaryExpr: *R, +R or ?R
+type UnaryExpr struct {
+	OpPos token.Pos   // operator position
+	Op    token.Token // operator: token.MUL, token.ADD or token.QUESTION
+	X     Expr        // operand
+}
+
+func (p *UnaryExpr) Pos() token.Pos { return p.OpPos }
+func (p *UnaryExpr) End() token.Pos { return p.X.End() }
+func (p *UnaryExpr) exprNode()      {}
+
+// -----------------------------------------------------------------------------
+
+// BinaryExpr: R1 % R2
+type BinaryExpr struct {
+	X     Expr        // left operand
+	OpPos token.Pos   // operator position
+	Op    token.Token // operator: token.REM (list operator)
+	Y     Expr        // right operand
+}
+
+func (p *BinaryExpr) Pos() token.Pos { return p.X.Pos() }
+func (p *BinaryExpr) End() token.Pos { return p.Y.End() }
+func (p *BinaryExpr) exprNode()      {}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/cl/compile.go b/tpl/cl/compile.go
new file mode 100644
index 00000000..2d528b07
--- /dev/null
+++ b/tpl/cl/compile.go
@@ -0,0 +1,218 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package cl
+
+import (
+	"fmt"
+	"strconv"
+
+	"github.com/goplus/gop/tpl/ast"
+	"github.com/goplus/gop/tpl/matcher"
+	"github.com/goplus/gop/tpl/token"
+	"github.com/qiniu/x/ctype"
+	"github.com/qiniu/x/errors"
+)
+
+var (
+	// ErrNoDocFound error
+	ErrNoDocFound = errors.New("no document rule found")
+)
+
+// Result represents the result of compiling a set of rules.
+type Result struct {
+	Doc   *matcher.Var
+	Rules map[string]*matcher.Var
+}
+
+type context struct {
+	rules map[string]*matcher.Var
+	errs  errors.List
+	fset  *token.FileSet
+}
+
+func (p *context) newErrorf(pos token.Pos, format string, args ...any) error {
+	return &matcher.Error{Fset: p.fset, Pos: pos, Msg: fmt.Sprintf(format, args...)}
+}
+
+func (p *context) addErrorf(pos token.Pos, format string, args ...any) {
+	p.errs.Add(p.newErrorf(pos, format, args...))
+}
+
+func (p *context) addError(pos token.Pos, msg string) {
+	p.errs.Add(&matcher.Error{Fset: p.fset, Pos: pos, Msg: msg})
+}
+
+// New compiles a set of rules from the given files.
+func New(fset *token.FileSet, files ...*ast.File) (ret Result, err error) {
+	rules := make(map[string]*matcher.Var)
+	ctx := &context{rules: rules, fset: fset}
+	for _, f := range files {
+		for _, decl := range f.Decls {
+			switch decl := decl.(type) {
+			case *ast.Rule:
+				ident := decl.Name
+				name := ident.Name
+				if old, ok := rules[name]; ok {
+					oldPos := fset.Position(old.Pos)
+					ctx.addErrorf(ident.Pos(),
+						"duplicate rule `%s`, previous declaration at %v", name, oldPos)
+					continue
+				}
+				v := matcher.NewVar(ident.Pos(), name)
+				rules[name] = v
+			default:
+				ctx.addError(decl.Pos(), "unknown declaration")
+			}
+		}
+	}
+	var doc *matcher.Var
+	for _, f := range files {
+		for _, decl := range f.Decls {
+			switch decl := decl.(type) {
+			case *ast.Rule:
+				ident := decl.Name
+				v := rules[ident.Name]
+				if r, ok := compileExpr(decl.Expr, ctx); ok {
+					if e := v.Assign(r); e != nil {
+						ctx.addError(ident.Pos(), e.Error())
+					}
+					if doc == nil {
+						doc = v
+					}
+				}
+			}
+		}
+	}
+	if doc == nil {
+		err = ErrNoDocFound
+		return
+	}
+	return Result{doc, rules}, ctx.errs.ToError()
+}
+
+var (
+	idents = map[string]token.Token{
+		"EOF":     token.EOF,
+		"COMMENT": token.COMMENT,
+		"IDENT":   token.IDENT,
+		"INT":     token.INT,
+		"FLOAT":   token.FLOAT,
+		"IMAG":    token.IMAG,
+		"CHAR":    token.CHAR,
+		"STRING":  token.STRING,
+	}
+)
+
+func compileExpr(expr ast.Expr, ctx *context) (matcher.Matcher, bool) {
+	switch expr := expr.(type) {
+	case *ast.Ident:
+		name := expr.Name
+		if v, ok := ctx.rules[name]; ok {
+			return v, true
+		}
+		if tok, ok := idents[name]; ok {
+			return matcher.Token(tok), true
+		}
+		ctx.addErrorf(expr.Pos(), "`%s` is undefined", name)
+	case *ast.BasicLit:
+		lit := expr.Value
+		switch expr.Kind {
+		case token.CHAR:
+			v, multibyte, tail, e := strconv.UnquoteChar(lit[1:len(lit)-1], '\'')
+			if e != nil {
+				ctx.addErrorf(expr.Pos(), "invalid literal %s: %v", lit, e)
+				break
+			}
+			if tail != "" || multibyte {
+				ctx.addErrorf(expr.Pos(), "invalid literal %s", lit)
+				break
+			}
+			return tokenExpr(v, expr, ctx)
+		case token.STRING:
+			v, e := strconv.Unquote(lit)
+			if e != nil {
+				ctx.addErrorf(expr.Pos(), "invalid literal %s: %v", lit, e)
+				break
+			}
+			if len(v) == 1 {
+				return tokenExpr(rune(v[0]), expr, ctx)
+			}
+			if ctype.IsCSymbol(v) {
+				return matcher.Literal(token.IDENT, v), true
+			}
+			fallthrough
+		default:
+			ctx.addErrorf(expr.Pos(), "invalid literal %s", lit)
+		}
+	case *ast.Sequence:
+		items := make([]matcher.Matcher, len(expr.Items))
+		for i, item := range expr.Items {
+			if r, ok := compileExpr(item, ctx); ok {
+				items[i] = r
+			} else {
+				return nil, false
+			}
+		}
+		return matcher.Sequence(items...), true
+	case *ast.Choice:
+		options := make([]matcher.Matcher, len(expr.Options))
+		for i, option := range expr.Options {
+			if r, ok := compileExpr(option, ctx); ok {
+				options[i] = r
+			} else {
+				return nil, false
+			}
+		}
+		return matcher.Choice(options...), true
+	case *ast.UnaryExpr:
+		if x, ok := compileExpr(expr.X, ctx); ok {
+			switch expr.Op {
+			case token.QUESTION:
+				return matcher.Repeat01(x), true
+			case token.MUL:
+				return matcher.Repeat0(x), true
+			case token.ADD:
+				return matcher.Repeat1(x), true
+			default:
+				ctx.addErrorf(expr.Pos(), "invalid token %v", expr.Op)
+			}
+		}
+	case *ast.BinaryExpr:
+		x, ok1 := compileExpr(expr.X, ctx)
+		y, ok2 := compileExpr(expr.Y, ctx)
+		if ok1 && ok2 {
+			switch expr.Op {
+			case token.REM: // %
+				return matcher.List(x, y), true
+			default:
+				ctx.addErrorf(expr.Pos(), "invalid token %v", expr.Op)
+			}
+		}
+	default:
+		ctx.addError(expr.Pos(), "unknown expression")
+	}
+	return nil, false
+}
+
+func tokenExpr(v rune, expr *ast.BasicLit, ctx *context) (matcher.Matcher, bool) {
+	tok := token.Token(v)
+	if tok.Len() > 0 {
+		return matcher.Token(tok), true
+	}
+	ctx.addErrorf(expr.Pos(), "invalid token: %s", expr.Value)
+	return nil, false
+}
diff --git a/tpl/matcher/match.go b/tpl/matcher/match.go
new file mode 100644
index 00000000..82c4f470
--- /dev/null
+++ b/tpl/matcher/match.go
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package matcher
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/goplus/gop/tpl/token"
+	"github.com/goplus/gop/tpl/types"
+)
+
+// -----------------------------------------------------------------------------
+
+// Error represents a matching error.
+type Error struct {
+	Fset *token.FileSet
+	Pos  token.Pos
+	Msg  string
+}
+
+func (p *Error) Error() string {
+	pos := p.Fset.Position(p.Pos)
+	return fmt.Sprintf("%v: %s", pos, p.Msg)
+}
+
+// -----------------------------------------------------------------------------
+
+// Context represents the context of a matching process.
+type Context struct {
+	Fset    *token.FileSet
+	FileEnd token.Pos
+}
+
+func (p *Context) NewError(pos token.Pos, msg string) *Error {
+	return &Error{p.Fset, pos, msg}
+}
+
+func (p *Context) NewErrorf(pos token.Pos, format string, args ...any) error {
+	return &Error{p.Fset, pos, fmt.Sprintf(format, args...)}
+}
+
+// -----------------------------------------------------------------------------
+// Matcher
+
+// Matcher represents a matcher.
+type Matcher interface {
+	Match(src []*types.Token, ctx *Context) (n int, result any, err error)
+}
+
+// -----------------------------------------------------------------------------
+
+type gToken struct {
+	tok token.Token
+}
+
+func (p *gToken) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	if len(src) == 0 {
+		return 0, nil, &Error{} // TODO(xsw): error
+	}
+	t := src[0]
+	if t.Tok != p.tok {
+		return 0, nil, &Error{}
+	}
+	return 1, t, nil
+}
+
+// Token: ADD, SUB, IDENT, INT, FLOAT, CHAR, STRING, etc.
+func Token(tok token.Token) Matcher {
+	return &gToken{tok}
+}
+
+// -----------------------------------------------------------------------------
+
+type gLiteral struct {
+	tok token.Token
+	lit string
+}
+
+func (p *gLiteral) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	if len(src) == 0 {
+		return 0, nil, ctx.NewErrorf(ctx.FileEnd, "expect `%s`, but got EOF", p.lit)
+	}
+	t := src[0]
+	if t.Tok != p.tok || t.Lit != p.lit {
+		return 0, nil, ctx.NewErrorf(t.Pos, "expect `%s`, but got `%s`", p.lit, t.Lit)
+	}
+	return 1, t, nil
+}
+
+// Literal: "abc", 'a', 123, 1.23, etc.
+func Literal(tok token.Token, lit string) Matcher {
+	return &gLiteral{tok, lit}
+}
+
+// -----------------------------------------------------------------------------
+
+type gChoice struct {
+	options []Matcher
+}
+
+func (p *gChoice) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	var nMax int
+	var errMax error
+	var multiErr = true
+
+	for _, g := range p.options {
+		if n, result, err = g.Match(src, ctx); err == nil {
+			return
+		}
+		if n >= nMax {
+			if n == nMax {
+				multiErr = true
+			} else {
+				nMax, errMax, multiErr = n, err, false
+			}
+		}
+	}
+	if multiErr {
+		errMax = ctx.NewError(src[nMax].End(), "TODO: error msg") // TODO(xsw)
+	}
+	return nMax, nil, errMax
+}
+
+// Choice: R1 | R2 | ... | Rn
+func Choice(options ...Matcher) Matcher {
+	return &gChoice{options}
+}
+
+// -----------------------------------------------------------------------------
+
+type gSequence struct {
+	items []Matcher
+}
+
+func (p *gSequence) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	rets := make([]any, len(p.items))
+	for i, g := range p.items {
+		n1, ret1, err1 := g.Match(src[n:], ctx)
+		if err1 != nil {
+			return n + n1, nil, err1
+		}
+		rets[i] = ret1
+		n += n1
+	}
+	result = rets
+	return
+}
+
+// Sequence: R1 R2 ... Rn
+func Sequence(items ...Matcher) Matcher {
+	return &gSequence{items}
+}
+
+// -----------------------------------------------------------------------------
+
+type gRepeat0 struct {
+	r Matcher
+}
+
+func (p *gRepeat0) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	g := p.r
+	rets := make([]any, 0, 2)
+	for {
+		n1, ret1, err1 := g.Match(src, ctx)
+		if err1 != nil {
+			result = rets
+			return
+		}
+		rets = append(rets, ret1)
+		n += n1
+		src = src[n1:]
+	}
+}
+
+// Repeat0: *R
+func Repeat0(r Matcher) Matcher {
+	return &gRepeat0{r}
+}
+
+// -----------------------------------------------------------------------------
+
+type gRepeat1 struct {
+	r Matcher
+}
+
+func (p *gRepeat1) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	g := p.r
+	n, ret0, err := g.Match(src, ctx)
+	if err != nil {
+		return
+	}
+
+	rets := make([]any, 1, 2)
+	rets[0] = ret0
+	for {
+		n1, ret1, err1 := g.Match(src[n:], ctx)
+		if err1 != nil {
+			result = rets
+			return
+		}
+		rets = append(rets, ret1)
+		n += n1
+	}
+}
+
+// Repeat1: +R
+func Repeat1(r Matcher) Matcher {
+	return &gRepeat1{r}
+}
+
+// -----------------------------------------------------------------------------
+
+type gRepeat01 struct {
+	r Matcher
+}
+
+func (p *gRepeat01) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	n, result, err = p.r.Match(src, ctx)
+	if err != nil {
+		return 0, nil, nil
+	}
+	return
+}
+
+// Repeat01: ?R
+func Repeat01(r Matcher) Matcher {
+	return &gRepeat01{r}
+}
+
+// -----------------------------------------------------------------------------
+
+// List: R1 % R2 is equivalent to R1 *(R2 R1)
+func List(a, b Matcher) Matcher {
+	return Sequence(a, Repeat0(Sequence(b, a)))
+}
+
+// -----------------------------------------------------------------------------
+
+var (
+	// ErrVarAssigned error
+	ErrVarAssigned = errors.New("variable is already assigned")
+)
+
+type Var struct {
+	Elem Matcher
+	Name string
+	Pos  token.Pos
+}
+
+func (p *Var) Match(src []*types.Token, ctx *Context) (n int, result any, err error) {
+	g := p.Elem
+	if g == nil {
+		return 0, nil, ctx.NewErrorf(p.Pos, "variable `%s` not found", p.Name)
+	}
+	return g.Match(src, ctx)
+}
+
+// Assign assigns a value to this variable.
+func (p *Var) Assign(elem Matcher) error {
+	if p.Elem != nil {
+		return ErrVarAssigned
+	}
+	p.Elem = elem
+	return nil
+}
+
+// NewVar creates a new Var instance.
+func NewVar(pos token.Pos, name string) *Var {
+	return &Var{Pos: pos, Name: name}
+}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/parser/parser.go b/tpl/parser/parser.go
new file mode 100644
index 00000000..23cf5ee0
--- /dev/null
+++ b/tpl/parser/parser.go
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package parser
+
+import (
+	"github.com/goplus/gop/parser/iox"
+	"github.com/goplus/gop/tpl/ast"
+	"github.com/goplus/gop/tpl/scanner"
+	"github.com/goplus/gop/tpl/token"
+)
+
+// -----------------------------------------------------------------------------
+
+// A Mode value is a set of flags (or 0).
+// They control the amount of source code parsed and other optional
+// parser functionality.
+type Mode uint
+
+// ParseFile parses a file and returns the AST.
+func ParseFile(fset *token.FileSet, filename string, src any, _ Mode) (f *ast.File, err error) {
+	b, err := iox.ReadSourceLocal(filename, src)
+	if err != nil {
+		return nil, err
+	}
+	var p parser
+	p.init(fset, filename, b)
+	f = p.parseFile()
+	switch p.errors.Len() {
+	case 0:
+	case 1:
+		err = p.errors[0]
+	default:
+		p.errors.Sort()
+		err = p.errors
+	}
+	return
+}
+
+// -----------------------------------------------------------------------------
+
+// parser represents a parser.
+type parser struct {
+	scanner scanner.Scanner
+	file    *token.File
+
+	// Current token
+	pos token.Pos
+	tok token.Token
+	lit string
+
+	// Error handling
+	errors scanner.ErrorList
+}
+
+func (p *parser) init(fset *token.FileSet, filename string, src []byte) {
+	p.file = fset.AddFile(filename, -1, len(src))
+	eh := func(pos token.Position, msg string) { p.errors.Add(pos, msg) }
+	p.scanner.Init(p.file, src, eh, 0)
+	p.next() // initialize first token
+}
+
+// next advances to the next token.
+func (p *parser) next() {
+	t := p.scanner.Scan()
+	p.pos, p.tok, p.lit = t.Pos, t.Tok, t.Lit
+}
+
+func (p *parser) errorExpected(pos token.Pos, msg string) {
+	msg = "expected " + msg
+	if pos == p.pos {
+		// the error happened at the current position;
+		// make the error message more specific
+		switch {
+		case p.tok == token.SEMICOLON && p.lit == "\n":
+			msg += ", found newline"
+		case len(p.lit) > 0:
+			// print 123 rather than 'INT', etc.
+			msg += ", found " + p.lit
+		default:
+			msg += ", found '" + p.tok.String() + "'"
+		}
+	}
+	p.error(pos, msg)
+}
+
+// expect consumes the current token if it matches the expected token.
+// If not, it adds an error.
+func (p *parser) expect(tok token.Token) token.Pos {
+	pos := p.pos
+	if p.tok != tok {
+		p.errorExpected(pos, "'"+tok.String()+"'")
+	}
+	p.next() // make progress
+	return pos
+}
+
+func (p *parser) error(pos token.Pos, msg string) {
+	epos := p.file.Position(pos)
+	p.errors.Add(epos, msg)
+}
+
+// parseFile parses a file and returns the AST.
+func (p *parser) parseFile() *ast.File {
+	file := &ast.File{
+		FileStart: p.pos,
+	}
+
+	for p.tok != token.EOF {
+		rule := p.parseRule()
+		file.Decls = append(file.Decls, rule)
+	}
+
+	return file
+}
+
+func (p *parser) parseIdent() *ast.Ident {
+	pos := p.pos
+	name := "_"
+	if p.tok == token.IDENT {
+		name = p.lit
+		p.next()
+	} else {
+		p.errorExpected(p.pos, "'IDENT'")
+	}
+	return &ast.Ident{NamePos: pos, Name: name}
+}
+
+// parseRule parses a rule: IDENT '=' expr ';'
+func (p *parser) parseRule() *ast.Rule {
+	if p.tok != token.IDENT {
+		p.errorExpected(p.pos, "'IDENT'")
+		return nil
+	}
+
+	name := p.parseIdent()
+	tokPos := p.expect(token.ASSIGN)
+	expr := p.parseExpr()
+	if expr == nil {
+		return nil
+	}
+
+	p.expect(token.SEMICOLON)
+	return &ast.Rule{
+		Name:   name,
+		TokPos: tokPos,
+		Expr:   expr,
+	}
+}
+
+// parseExpr parses an expression: +term % '|'
+func (p *parser) parseExpr() ast.Expr {
+	termList := p.parseTermList()
+	for p.tok != token.OR {
+		return termList
+	}
+
+	options := make([]ast.Expr, 0, 4)
+	options = append(options, termList)
+	for p.tok == token.OR {
+		p.next()
+		termList = p.parseTermList()
+		options = append(options, termList)
+	}
+	return &ast.Choice{Options: options}
+}
+
+// parseTermList parses a list of terms: +term
+func (p *parser) parseTermList() ast.Expr {
+	terms := make([]ast.Expr, 0, 1)
+	for {
+		term, ok := p.parseTerm()
+		if !ok {
+			break
+		}
+		terms = append(terms, term)
+	}
+	switch n := len(terms); n {
+	case 1:
+		return terms[0]
+	case 0:
+		p.error(p.pos, "expected factor")
+		fallthrough // TODO(xsw): BadExpr
+	default:
+		return &ast.Sequence{Items: terms}
+	}
+}
+
+// parseTerm parses a term: factor % '%'
+func (p *parser) parseTerm() (ast.Expr, bool) {
+	x, ok := p.parseFactor()
+	if !ok {
+		return nil, false
+	}
+
+	for p.tok == token.REM {
+		opPos := p.pos
+		p.next()
+		y, ok := p.parseFactor()
+		if !ok {
+			p.error(p.pos, "expected factor")
+		}
+		x = &ast.BinaryExpr{
+			X:     x,
+			OpPos: opPos,
+			Op:    token.REM,
+			Y:     y,
+		}
+	}
+	return x, true
+}
+
+// parseFactor: IDENT | CHAR | STRING | ('*' | '+' | '?') factor | '(' expr ')'
+func (p *parser) parseFactor() (ast.Expr, bool) {
+	switch tok := p.tok; tok {
+	case token.IDENT:
+		ident := &ast.Ident{
+			NamePos: p.pos,
+			Name:    p.lit,
+		}
+		p.next()
+		return ident, true
+
+	case token.CHAR, token.STRING:
+		lit := &ast.BasicLit{
+			ValuePos: p.pos,
+			Kind:     tok,
+			Value:    p.lit,
+		}
+		p.next()
+		return lit, true
+
+	case token.MUL, token.ADD, token.QUESTION:
+		opPos := p.pos
+		p.next()
+
+		factor, ok := p.parseFactor()
+		if !ok {
+			p.error(p.pos, "expected factor")
+		}
+		ret := &ast.UnaryExpr{
+			OpPos: opPos,
+			Op:    tok,
+			X:     factor,
+		}
+		return ret, true
+
+	case token.LPAREN:
+		p.next()
+		expr := p.parseExpr()
+		p.expect(token.RPAREN)
+		return expr, true
+
+	default:
+		return nil, false
+	}
+}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/scanner/error.go b/tpl/scanner/error.go
new file mode 100644
index 00000000..6a6a47a9
--- /dev/null
+++ b/tpl/scanner/error.go
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package scanner
+
+import (
+	"go/scanner"
+	"io"
+)
+
+// -----------------------------------------------------------------------------
+
+// Error is an alias of go/scanner.Error
+type Error = scanner.Error
+
+// ErrorList is an alias of go/scanner.ErrorList
+type ErrorList = scanner.ErrorList
+
+// PrintError is a utility function that prints a list of errors to w,
+// one error per line, if the err parameter is an ErrorList. Otherwise
+// it prints the err string.
+func PrintError(w io.Writer, err error) {
+	scanner.PrintError(w, err)
+}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/scanner/scanner.go b/tpl/scanner/scanner.go
new file mode 100644
index 00000000..de65b44c
--- /dev/null
+++ b/tpl/scanner/scanner.go
@@ -0,0 +1,796 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package scanner
+
+import (
+	"bytes"
+	"fmt"
+	"path/filepath"
+	"strconv"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/goplus/gop/tpl/token"
+	"github.com/goplus/gop/tpl/types"
+)
+
+// An ScanErrorHandler may be provided to Scanner.Init. If a syntax error is
+// encountered and a handler was installed, the handler is called with a
+// position and an error message. The position points to the beginning of
+// the offending token.
+type ScanErrorHandler func(pos token.Position, msg string)
+
+// A Scanner holds the scanner's internal state while processing
+// a given text.  It can be allocated as part of another data
+// structure but must be initialized via Init before use.
+type Scanner struct {
+	// immutable state
+	file *token.File      // source file handle
+	dir  string           // directory portion of file.Name()
+	src  []byte           // source
+	err  ScanErrorHandler // error reporting; or nil
+	mode ScanMode         // scanning mode
+
+	// scanning state
+	ch         rune // current character
+	offset     int  // character offset
+	rdOffset   int  // reading offset (position after current character)
+	lineOffset int  // current line offset
+	insertSemi bool // insert a semicolon before next newline
+
+	// public state - ok to modify
+	ErrorCount int // number of errors encountered
+}
+
+const bom = 0xFEFF // byte order mark, only permitted as very first character
+
+// Read the next Unicode char into s.ch.
+// s.ch < 0 means end-of-file.
+func (s *Scanner) next() {
+	if s.rdOffset < len(s.src) {
+		s.offset = s.rdOffset
+		if s.ch == '\n' {
+			s.lineOffset = s.offset
+			s.file.AddLine(s.offset)
+		}
+		r, w := rune(s.src[s.rdOffset]), 1
+		switch {
+		case r == 0:
+			s.error(s.offset, "illegal character NUL")
+		case r >= 0x80:
+			// not ASCII
+			r, w = utf8.DecodeRune(s.src[s.rdOffset:])
+			if r == utf8.RuneError && w == 1 {
+				s.error(s.offset, "illegal UTF-8 encoding")
+			} else if r == bom && s.offset > 0 {
+				s.error(s.offset, "illegal byte order mark")
+			}
+		}
+		s.rdOffset += w
+		s.ch = r
+	} else {
+		s.offset = len(s.src)
+		if s.ch == '\n' {
+			s.lineOffset = s.offset
+			s.file.AddLine(s.offset)
+		}
+		s.ch = -1 // eof
+	}
+}
+
+// A ScanMode value is a set of flags (or 0).
+// They control scanner behavior.
+type ScanMode uint
+
+const (
+	// ScanComments means returning comments as COMMENT tokens
+	ScanComments ScanMode = 1 << iota
+
+	// NoInsertSemis means don't automatically insert semicolons
+	NoInsertSemis
+)
+
+// Init prepares the scanner s to tokenize the text src by setting the
+// scanner at the beginning of src. The scanner uses the file set file
+// for position information and it adds line information for each line.
+// It is ok to re-use the same file when re-scanning the same file as
+// line information which is already present is ignored. Init causes a
+// panic if the file size does not match the src size.
+//
+// Calls to Scan will invoke the error handler err if they encounter a
+// syntax error and err is not nil. Also, for each error encountered,
+// the Scanner field ErrorCount is incremented by one. The mode parameter
+// determines how comments are handled.
+//
+// Note that Init may call err if there is an error in the first character
+// of the file.
+func (s *Scanner) Init(file *token.File, src []byte, err ScanErrorHandler, mode ScanMode) {
+	// Explicitly initialize all fields since a scanner may be reused.
+	if file.Size() != len(src) {
+		panic(fmt.Sprintf("file size (%d) does not match src len (%d)", file.Size(), len(src)))
+	}
+	s.file = file
+	s.dir, _ = filepath.Split(file.Name())
+	s.src = src
+	s.err = err
+	s.mode = mode
+
+	s.ch = ' '
+	s.offset = 0
+	s.rdOffset = 0
+	s.lineOffset = 0
+	s.insertSemi = false
+	s.ErrorCount = 0
+
+	s.next()
+	if s.ch == bom {
+		s.next() // ignore BOM at file beginning
+	}
+}
+
+func (s *Scanner) error(offs int, msg string) {
+	if s.err != nil {
+		s.err(s.file.Position(s.file.Pos(offs)), msg)
+	}
+	s.ErrorCount++
+}
+
+var prefix = []byte("//line ")
+
+func (s *Scanner) interpretLineComment(text []byte) {
+	if bytes.HasPrefix(text, prefix) {
+		// get filename and line number, if any
+		if i := bytes.LastIndex(text, []byte{':'}); i > 0 {
+			if line, err := strconv.Atoi(string(text[i+1:])); err == nil && line > 0 {
+				// valid //line filename:line comment
+				filename := string(bytes.TrimSpace(text[len(prefix):i]))
+				if filename != "" {
+					filename = filepath.Clean(filename)
+					if !filepath.IsAbs(filename) {
+						// make filename relative to current directory
+						filename = filepath.Join(s.dir, filename)
+					}
+				}
+				// update scanner position
+				s.file.AddLineInfo(
+					s.lineOffset+len(text)+1, filename, line) // +len(text)+1 since comment applies to next line
+			}
+		}
+	}
+}
+
+func (s *Scanner) scanComment() string {
+	// initial '/' already consumed; s.ch == '/' || s.ch == '*'
+	offs := s.offset - 1 // position of initial '/'
+	hasCR := false
+
+	if s.ch == '/' {
+		//-style comment
+		s.next()
+		for s.ch != '\n' && s.ch >= 0 {
+			if s.ch == '\r' {
+				hasCR = true
+			}
+			s.next()
+		}
+		if offs == s.lineOffset {
+			// comment starts at the beginning of the current line
+			s.interpretLineComment(s.src[offs:s.offset])
+		}
+		goto exit
+	}
+
+	/*-style comment */
+	s.next()
+	for s.ch >= 0 {
+		ch := s.ch
+		if ch == '\r' {
+			hasCR = true
+		}
+		s.next()
+		if ch == '*' && s.ch == '/' {
+			s.next()
+			goto exit
+		}
+	}
+
+	s.error(offs, "comment not terminated")
+
+exit:
+	lit := s.src[offs:s.offset]
+	if hasCR {
+		lit = stripCR(lit)
+	}
+
+	return string(lit)
+}
+
+func (s *Scanner) findLineEnd() bool {
+	// initial '/' already consumed
+
+	defer func(offs int) {
+		// reset scanner state to where it was upon calling findLineEnd
+		s.ch = '/'
+		s.offset = offs
+		s.rdOffset = offs + 1
+		s.next() // consume initial '/' again
+	}(s.offset - 1)
+
+	// read ahead until a newline, EOF, or non-comment token is found
+	for s.ch == '/' || s.ch == '*' {
+		if s.ch == '/' {
+			//-style comment always contains a newline
+			return true
+		}
+		/*-style comment: look for newline */
+		s.next()
+		for s.ch >= 0 {
+			ch := s.ch
+			if ch == '\n' {
+				return true
+			}
+			s.next()
+			if ch == '*' && s.ch == '/' {
+				s.next()
+				break
+			}
+		}
+		s.skipWhitespace() // s.insertSemi is set
+		if s.ch < 0 || s.ch == '\n' {
+			return true
+		}
+		if s.ch != '/' {
+			// non-comment token
+			return false
+		}
+		s.next() // consume '/'
+	}
+
+	return false
+}
+
+func isLetter(ch rune) bool {
+	return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= 0x80 && unicode.IsLetter(ch)
+}
+
+func isDigit(ch rune) bool {
+	return '0' <= ch && ch <= '9' || ch >= 0x80 && unicode.IsDigit(ch)
+}
+
+func (s *Scanner) scanIdentifier() string {
+	offs := s.offset
+	for isLetter(s.ch) || isDigit(s.ch) {
+		s.next()
+	}
+	return string(s.src[offs:s.offset])
+}
+
+func digitVal(ch rune) int {
+	switch {
+	case '0' <= ch && ch <= '9':
+		return int(ch - '0')
+	case 'a' <= ch && ch <= 'f':
+		return int(ch - 'a' + 10)
+	case 'A' <= ch && ch <= 'F':
+		return int(ch - 'A' + 10)
+	}
+	return 16 // larger than any legal digit val
+}
+
+func (s *Scanner) scanMantissa(base int) {
+	for digitVal(s.ch) < base {
+		s.next()
+	}
+}
+
+func (s *Scanner) scanNumber(seenDecimalPoint bool) (token.Token, string) {
+	// digitVal(s.ch) < 10
+	offs := s.offset
+	tok := token.INT
+
+	if seenDecimalPoint {
+		offs--
+		tok = token.FLOAT
+		s.scanMantissa(10)
+		goto exponent
+	}
+
+	if s.ch == '0' {
+		// int or float
+		offs := s.offset
+		s.next()
+		if s.ch == 'x' || s.ch == 'X' {
+			// hexadecimal int
+			s.next()
+			s.scanMantissa(16)
+			if s.offset-offs <= 2 {
+				// only scanned "0x" or "0X"
+				s.error(offs, "illegal hexadecimal number")
+			}
+		} else {
+			// octal int or float
+			seenDecimalDigit := false
+			s.scanMantissa(8)
+			if s.ch == '8' || s.ch == '9' {
+				// illegal octal int or float
+				seenDecimalDigit = true
+				s.scanMantissa(10)
+			}
+			if s.ch == '.' || s.ch == 'e' || s.ch == 'E' || s.ch == 'i' {
+				goto fraction
+			}
+			// octal int
+			if seenDecimalDigit {
+				s.error(offs, "illegal octal number")
+			}
+		}
+		goto exit
+	}
+
+	// decimal int or float
+	s.scanMantissa(10)
+
+fraction:
+	if s.ch == '.' {
+		tok = token.FLOAT
+		s.next()
+		s.scanMantissa(10)
+	}
+
+exponent:
+	if s.ch == 'e' || s.ch == 'E' {
+		tok = token.FLOAT
+		s.next()
+		if s.ch == '-' || s.ch == '+' {
+			s.next()
+		}
+		s.scanMantissa(10)
+	}
+
+	if s.ch == 'i' {
+		tok = token.IMAG
+		s.next()
+	}
+
+exit:
+	return tok, string(s.src[offs:s.offset])
+}
+
+// scanEscape parses an escape sequence where rune is the accepted
+// escaped quote. In case of a syntax error, it stops at the offending
+// character (without consuming it) and returns false. Otherwise
+// it returns true.
+func (s *Scanner) scanEscape(quote rune) bool {
+	offs := s.offset
+
+	var n int
+	var base, max uint32
+	switch s.ch {
+	case 'a', 'b', 'f', 'n', 'r', 't', 'v', '\\', quote:
+		s.next()
+		return true
+	case '0', '1', '2', '3', '4', '5', '6', '7':
+		n, base, max = 3, 8, 255
+	case 'x':
+		s.next()
+		n, base, max = 2, 16, 255
+	case 'u':
+		s.next()
+		n, base, max = 4, 16, unicode.MaxRune
+	case 'U':
+		s.next()
+		n, base, max = 8, 16, unicode.MaxRune
+	default:
+		msg := "unknown escape sequence"
+		if s.ch < 0 {
+			msg = "escape sequence not terminated"
+		}
+		s.error(offs, msg)
+		return false
+	}
+
+	var x uint32
+	for n > 0 {
+		d := uint32(digitVal(s.ch))
+		if d >= base {
+			msg := fmt.Sprintf("illegal character %#U in escape sequence", s.ch)
+			if s.ch < 0 {
+				msg = "escape sequence not terminated"
+			}
+			s.error(s.offset, msg)
+			return false
+		}
+		x = x*base + d
+		s.next()
+		n--
+	}
+
+	if x > max || 0xD800 <= x && x < 0xE000 {
+		s.error(offs, "escape sequence is invalid Unicode code point")
+		return false
+	}
+
+	return true
+}
+
+func (s *Scanner) scanRune() string {
+	// '\'' opening already consumed
+	offs := s.offset - 1
+
+	valid := true
+	n := 0
+	for {
+		ch := s.ch
+		if ch == '\n' || ch < 0 {
+			// only report error if we don't have one already
+			if valid {
+				s.error(offs, "rune literal not terminated")
+				valid = false
+			}
+			break
+		}
+		s.next()
+		if ch == '\'' {
+			break
+		}
+		n++
+		if ch == '\\' {
+			if !s.scanEscape('\'') {
+				valid = false
+			}
+			// continue to read to closing quote
+		}
+	}
+
+	if valid && n != 1 {
+		s.error(offs, "illegal rune literal")
+	}
+
+	return string(s.src[offs:s.offset])
+}
+
+func (s *Scanner) scanSharpComment() string {
+	// '#' opening already consumed
+	offs := s.offset - 1
+
+	for {
+		ch := s.ch
+		if ch == '\n' || ch < 0 {
+			break
+		}
+		s.next()
+	}
+
+	return string(s.src[offs:s.offset])
+}
+
+func (s *Scanner) scanString() string {
+	// '"' opening already consumed
+	offs := s.offset - 1
+
+	for {
+		ch := s.ch
+		if ch == '\n' || ch < 0 {
+			s.error(offs, "string literal not terminated")
+			break
+		}
+		s.next()
+		if ch == '"' {
+			break
+		}
+		if ch == '\\' {
+			s.scanEscape('"')
+		}
+	}
+
+	return string(s.src[offs:s.offset])
+}
+
+func stripCR(b []byte) []byte {
+	c := make([]byte, len(b))
+	i := 0
+	for _, ch := range b {
+		if ch != '\r' {
+			c[i] = ch
+			i++
+		}
+	}
+	return c[:i]
+}
+
+func (s *Scanner) scanRawString() string {
+	// '`' opening already consumed
+	offs := s.offset - 1
+
+	hasCR := false
+	for {
+		ch := s.ch
+		if ch < 0 {
+			s.error(offs, "raw string literal not terminated")
+			break
+		}
+		s.next()
+		if ch == '`' {
+			break
+		}
+		if ch == '\r' {
+			hasCR = true
+		}
+	}
+
+	lit := s.src[offs:s.offset]
+	if hasCR {
+		lit = stripCR(lit)
+	}
+
+	return string(lit)
+}
+
+func (s *Scanner) skipWhitespace() {
+	for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !s.insertSemi || s.ch == '\r' {
+		s.next()
+	}
+}
+
+// Helper functions for scanning multi-byte tokens such as >> += >>= .
+// Different routines recognize different length tok_i based on matches
+// of ch_i. If a token ends in '=', the result is tok1 or tok3
+// respectively. Otherwise, the result is tok0 if there was no other
+// matching character, or tok2 if the matching character was ch2.
+
+func (s *Scanner) switch2(tok0, tok1 token.Token) token.Token {
+	if s.ch == '=' {
+		s.next()
+		return tok1
+	}
+	return tok0
+}
+
+func (s *Scanner) switch3(tok0, tok1 token.Token, ch2 rune, tok2 token.Token) token.Token {
+	if s.ch == '=' {
+		s.next()
+		return tok1
+	}
+	if s.ch == ch2 {
+		s.next()
+		return tok2
+	}
+	return tok0
+}
+
+func (s *Scanner) switch4(tok0, tok1 token.Token, ch2 rune, tok2, tok3 token.Token) token.Token {
+	if s.ch == '=' {
+		s.next()
+		return tok1
+	}
+	if s.ch == ch2 {
+		s.next()
+		if s.ch == '=' {
+			s.next()
+			return tok3
+		}
+		return tok2
+	}
+	return tok0
+}
+
+// Scan scans the next token and returns the token position, the token,
+// and its literal string if applicable. The source end is indicated by
+// token.EOF.
+//
+// If the returned token is a literal (IDENT, INT, FLOAT, IMAG, CHAR, STRING)
+// or COMMENT, the literal string has the corresponding value.
+//
+// If the returned token is SEMICOLON, the corresponding
+// literal string is ";" if the semicolon was present in the source,
+// and "\n" if the semicolon was inserted because of a newline or
+// at EOF.
+//
+// If the returned token is ILLEGAL, the literal string is the offending
+// character.
+//
+// In all other cases, Scan returns an empty literal string.
+//
+// For more tolerant parsing, Scan will return a valid token if
+// possible even if a syntax error was encountered. Thus, even
+// if the resulting token sequence contains no illegal tokens,
+// a client may not assume that no error occurred. Instead it
+// must check the scanner's ErrorCount or the number of calls
+// of the error handler, if there was one installed.
+//
+// Scan adds line information to the file added to the file
+// set with Init. Token positions are relative to that file
+// and thus relative to the file set.
+func (s *Scanner) Scan() (t types.Token) {
+scanAgain:
+	s.skipWhitespace()
+
+	// current token start
+	t.Pos = s.file.Pos(s.offset)
+
+	// determine token value
+	insertSemi := false
+	switch ch := s.ch; {
+	case isLetter(ch):
+		t.Lit = s.scanIdentifier()
+		t.Tok = token.IDENT
+		insertSemi = true
+	case '0' <= ch && ch <= '9':
+		insertSemi = true
+		t.Tok, t.Lit = s.scanNumber(false)
+	default:
+		s.next() // always make progress
+		switch ch {
+		case -1:
+			if s.insertSemi {
+				s.insertSemi = false // EOF consumed
+				t.Tok, t.Lit = token.SEMICOLON, "\n"
+				return
+			}
+			t.Tok = token.EOF
+		case '\n':
+			// we only reach here if s.insertSemi was
+			// set in the first place and exited early
+			// from s.skipWhitespace()
+			s.insertSemi = false // newline consumed
+			t.Tok, t.Lit = token.SEMICOLON, "\n"
+			return
+		case '"':
+			insertSemi = true
+			t.Tok = token.STRING
+			t.Lit = s.scanString()
+		case '\'':
+			insertSemi = true
+			t.Tok = token.CHAR
+			t.Lit = s.scanRune()
+		case '`':
+			insertSemi = true
+			t.Tok = token.STRING
+			t.Lit = s.scanRawString()
+		case ':':
+			t.Tok = s.switch2(token.COLON, token.DEFINE)
+		case '.':
+			if '0' <= s.ch && s.ch <= '9' {
+				insertSemi = true
+				t.Tok, t.Lit = s.scanNumber(true)
+			} else if s.ch == '.' {
+				s.next()
+				if s.ch == '.' {
+					s.next()
+					t.Tok = token.ELLIPSIS
+				}
+			} else {
+				t.Tok = token.PERIOD
+			}
+		case ',':
+			t.Tok = token.COMMA
+		case ';':
+			t.Tok = token.SEMICOLON
+			t.Lit = ";"
+		case '(':
+			t.Tok = token.LPAREN
+		case ')':
+			insertSemi = true
+			t.Tok = token.RPAREN
+		case '[':
+			t.Tok = token.LBRACK
+		case ']':
+			insertSemi = true
+			t.Tok = token.RBRACK
+		case '{':
+			t.Tok = token.LBRACE
+		case '}':
+			insertSemi = true
+			t.Tok = token.RBRACE
+		case '+':
+			t.Tok = s.switch3(token.ADD, token.ADD_ASSIGN, '+', token.INC)
+			if t.Tok == token.INC {
+				insertSemi = true
+			}
+		case '-':
+			t.Tok = s.switch3(token.SUB, token.SUB_ASSIGN, '-', token.DEC)
+			if t.Tok == token.DEC {
+				insertSemi = true
+			}
+		case '*':
+			t.Tok = s.switch2(token.MUL, token.MUL_ASSIGN)
+		case '/':
+			if s.ch == '/' || s.ch == '*' {
+				// comment
+				if s.insertSemi && s.findLineEnd() {
+					// reset position to the beginning of the comment
+					s.ch = '/'
+					s.offset = s.file.Offset(t.Pos)
+					s.rdOffset = s.offset + 1
+					s.insertSemi = false // newline consumed
+					t.Tok, t.Lit = token.SEMICOLON, "\n"
+					return
+				}
+				comment := s.scanComment()
+				if s.mode&ScanComments == 0 {
+					// skip comment
+					s.insertSemi = false // newline consumed
+					goto scanAgain
+				}
+				t.Tok = token.COMMENT
+				t.Lit = comment
+			} else {
+				t.Tok = s.switch2(token.QUO, token.QUO_ASSIGN)
+			}
+		case '#':
+			if s.insertSemi {
+				s.ch = '#'
+				s.offset = s.file.Offset(t.Pos)
+				s.rdOffset = s.offset + 1
+				s.insertSemi = false
+				t.Tok, t.Lit = token.SEMICOLON, "\n"
+				return
+			}
+			comment := s.scanSharpComment()
+			if s.mode&ScanComments == 0 { // skip comment
+				goto scanAgain
+			}
+			t.Tok = token.COMMENT
+			t.Lit = comment
+		case '%':
+			t.Tok = s.switch2(token.REM, token.REM_ASSIGN)
+		case '^':
+			t.Tok = s.switch2(token.XOR, token.XOR_ASSIGN)
+		case '<':
+			if s.ch == '-' {
+				s.next()
+				t.Tok = token.ARROW
+			} else {
+				t.Tok = s.switch4(token.LT, token.LE, '<', token.SHL, token.SHL_ASSIGN)
+			}
+		case '>':
+			t.Tok = s.switch4(token.GT, token.GE, '>', token.SHR, token.SHR_ASSIGN)
+		case '=':
+			t.Tok = s.switch2(token.ASSIGN, token.EQ)
+		case '!':
+			t.Tok = s.switch2(token.NOT, token.NE)
+		case '&':
+			if s.ch == '^' {
+				s.next()
+				t.Tok = s.switch2(token.AND_NOT, token.AND_NOT_ASSIGN)
+			} else {
+				t.Tok = s.switch3(token.AND, token.AND_ASSIGN, '&', token.LAND)
+			}
+		case '|':
+			t.Tok = s.switch3(token.OR, token.OR_ASSIGN, '|', token.LOR)
+		case '?':
+			t.Tok = token.QUESTION
+		case '~':
+			t.Tok = token.TILDE
+		case '@':
+			t.Tok = token.AT
+		default:
+			// next reports unexpected BOMs - don't repeat
+			if ch != bom {
+				s.error(s.file.Offset(t.Pos), fmt.Sprintf("illegal character %#U", ch))
+			}
+			insertSemi = s.insertSemi // preserve insertSemi info
+			t.Tok = token.ILLEGAL
+			t.Lit = string(ch)
+		}
+	}
+	if s.mode&NoInsertSemis == 0 {
+		s.insertSemi = insertSemi
+	}
+
+	return
+}
diff --git a/tpl/token/token.go b/tpl/token/token.go
new file mode 100644
index 00000000..627c6ae4
--- /dev/null
+++ b/tpl/token/token.go
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package token
+
+import (
+	"strconv"
+)
+
+// -----------------------------------------------------------------------------
+
+type Token uint
+
+func (tok Token) String() (s string) {
+	if tok < Token(len(tokens)) {
+		s = tokens[tok]
+	}
+	if s == "" {
+		s = "token(" + strconv.Itoa(int(tok)) + ")"
+	}
+	return
+}
+
+// Len returns
+// 1) len of is token literal, if token is an operator.
+// 2) 0 for else.
+func (tok Token) Len() int {
+	if tok > ' ' && tok <= Token(len(tokens)) {
+		return len(tokens[tok])
+	}
+	return 0
+}
+
+// -----------------------------------------------------------------------------
+
+const (
+	ILLEGAL Token = iota
+	EOF
+	COMMENT
+
+	IDENT  // main
+	INT    // 12345
+	FLOAT  // 123.45
+	IMAG   // 123.45i
+	CHAR   // 'a'
+	STRING // "abc"
+
+	ADD = '+'
+	SUB = '-'
+	MUL = '*'
+	QUO = '/'
+	REM = '%'
+
+	AND = '&'
+	OR  = '|'
+	XOR = '^'
+
+	LT     = '<'
+	GT     = '>'
+	ASSIGN = '='
+	NOT    = '!'
+
+	LPAREN = '('
+	LBRACK = '['
+	LBRACE = '{'
+	COMMA  = ','
+	PERIOD = '.'
+
+	RPAREN    = ')'
+	RBRACK    = ']'
+	RBRACE    = '}'
+	SEMICOLON = ';'
+	COLON     = ':'
+	QUESTION  = '?'
+	TILDE     = '~'
+	AT        = '@'
+)
+
+const (
+	operator_beg Token = 0x80 + iota
+
+	SHL     // <<
+	SHR     // >>
+	AND_NOT // &^
+
+	ADD_ASSIGN // +=
+	SUB_ASSIGN // -=
+	MUL_ASSIGN // *=
+	QUO_ASSIGN // /=
+	REM_ASSIGN // %=
+
+	AND_ASSIGN     // &=
+	OR_ASSIGN      // |=
+	XOR_ASSIGN     // ^=
+	SHL_ASSIGN     // <<=
+	SHR_ASSIGN     // >>=
+	AND_NOT_ASSIGN // &^=
+
+	LAND  // &&
+	LOR   // ||
+	ARROW // <-
+	INC   // ++
+	DEC   // --
+
+	EQ       // ==
+	NE       // !=
+	LE       // <=
+	GE       // >=
+	DEFINE   // :=
+	ELLIPSIS // ...
+
+	_ = operator_beg
+)
+
+// -----------------------------------------------------------------------------
+
+var tokens = [...]string{
+	ILLEGAL: "ILLEGAL",
+
+	EOF:     "EOF",
+	COMMENT: "COMMENT",
+
+	IDENT:  "IDENT",
+	INT:    "INT",
+	FLOAT:  "FLOAT",
+	IMAG:   "IMAG",
+	CHAR:   "CHAR",
+	STRING: "STRING",
+
+	ADD: "+",
+	SUB: "-",
+	MUL: "*",
+	QUO: "/",
+	REM: "%",
+
+	AND: "&",
+	OR:  "|",
+	XOR: "^",
+
+	LT:     "<",
+	GT:     ">",
+	ASSIGN: "=",
+	NOT:    "!",
+
+	LPAREN: "(",
+	LBRACK: "[",
+	LBRACE: "{",
+	COMMA:  ",",
+	PERIOD: ".",
+
+	RPAREN:    ")",
+	RBRACK:    "]",
+	RBRACE:    "}",
+	SEMICOLON: ";",
+	COLON:     ":",
+	QUESTION:  "?",
+	TILDE:     "~",
+	AT:        "@",
+
+	SHL:     "<<",
+	SHR:     ">>",
+	AND_NOT: "&^",
+
+	ADD_ASSIGN: "+=",
+	SUB_ASSIGN: "-=",
+	MUL_ASSIGN: "*=",
+	QUO_ASSIGN: "/=",
+	REM_ASSIGN: "%=",
+
+	AND_ASSIGN:     "&=",
+	OR_ASSIGN:      "|=",
+	XOR_ASSIGN:     "^=",
+	SHL_ASSIGN:     "<<=",
+	SHR_ASSIGN:     ">>=",
+	AND_NOT_ASSIGN: "&^=",
+
+	LAND:  "&&",
+	LOR:   "||",
+	ARROW: "<-",
+	INC:   "++",
+	DEC:   "--",
+
+	EQ:       "==",
+	NE:       "!=",
+	LE:       "<=",
+	GE:       ">=",
+	DEFINE:   ":=",
+	ELLIPSIS: "...",
+}
+
+// ForEach iterates all tokens.
+func ForEach(f func(tok Token, lit string)) {
+	for i, s := range tokens {
+		if s != "" {
+			f(Token(i), s)
+		}
+	}
+}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/token/types.go b/tpl/token/types.go
new file mode 100644
index 00000000..caac534d
--- /dev/null
+++ b/tpl/token/types.go
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package token
+
+import (
+	"go/token"
+)
+
+// Pos is a compact encoding of a source position within a file set.
+// It can be converted into a Position for a more convenient, but much
+// larger, representation.
+//
+// The Pos value for a given file is a number in the range [base, base+size],
+// where base and size are specified when adding the file to the file set via
+// AddFile.
+//
+// To create the Pos value for a specific source offset (measured in bytes),
+// first add the respective file to the current file set using FileSet.AddFile
+// and then call File.Pos(offset) for that file. Given a Pos value p
+// for a specific file set fset, the corresponding Position value is
+// obtained by calling fset.Position(p).
+//
+// Pos values can be compared directly with the usual comparison operators:
+// If two Pos values p and q are in the same file, comparing p and q is
+// equivalent to comparing the respective source file offsets. If p and q
+// are in different files, p < q is true if the file implied by p was added
+// to the respective file set before the file implied by q.
+type Pos = token.Pos
+
+const (
+	// NoPos - The zero value for Pos is NoPos; there is no file and line
+	// information associated with it, and NoPos.IsValid() is false. NoPos
+	// is always smaller than any other Pos value. The corresponding
+	// Position value for NoPos is the zero value for Position.
+	NoPos = token.NoPos
+)
+
+// Position describes an arbitrary source position
+// including the file, line, and column location.
+// A Position is valid if the line number is > 0.
+type Position = token.Position
+
+// A File is a handle for a file belonging to a FileSet.
+// A File has a name, size, and line offset table.
+type File = token.File
+
+// A FileSet represents a set of source files. Methods of file sets are
+// synchronized; multiple goroutines may invoke them concurrently.
+type FileSet = token.FileSet
+
+// NewFileSet creates a new file set.
+func NewFileSet() *FileSet {
+	return token.NewFileSet()
+}
diff --git a/tpl/tpl.go b/tpl/tpl.go
new file mode 100644
index 00000000..fbdd79ab
--- /dev/null
+++ b/tpl/tpl.go
@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package tpl
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/goplus/gop/parser/iox"
+	"github.com/goplus/gop/tpl/cl"
+	"github.com/goplus/gop/tpl/matcher"
+	"github.com/goplus/gop/tpl/parser"
+	"github.com/goplus/gop/tpl/scanner"
+	"github.com/goplus/gop/tpl/token"
+	"github.com/goplus/gop/tpl/types"
+)
+
+// -----------------------------------------------------------------------------
+
+// Compiler represents a TPL compiler.
+type Compiler struct {
+	cl.Result
+	Fset *token.FileSet
+}
+
+// New creates a new TPL compiler.
+func New(src any) (ret Compiler, err error) {
+	return FromFile("", src, nil)
+}
+
+// FromFile creates a new TPL compiler from a file.
+func FromFile(filename string, src any, fset *token.FileSet) (ret Compiler, err error) {
+	if fset == nil {
+		fset = token.NewFileSet()
+	}
+	f, err := parser.ParseFile(fset, filename, src, 0)
+	if err != nil {
+		return
+	}
+	ret.Result, err = cl.New(fset, f)
+	ret.Fset = fset
+	return
+}
+
+// -----------------------------------------------------------------------------
+
+// A Token is a lexical unit returned by Scan.
+type Token = types.Token
+
+// Scanner represents a TPL scanner.
+type Scanner interface {
+	Scan() Token
+	Init(file *token.File, src []byte, err scanner.ScanErrorHandler, mode scanner.ScanMode)
+}
+
+// Config represents a parsing configuration of [Compiler.Parse].
+type Config struct {
+	Scanner          Scanner
+	ScanMode         scanner.ScanMode
+	ScanErrorHandler scanner.ScanErrorHandler
+}
+
+// ParseExpr parses an expression.
+func (p *Compiler) ParseExpr(x string, conf *Config) (result any, err error) {
+	return p.ParseExprFrom("", x, conf)
+}
+
+// ParseExprFrom parses an expression from a file.
+func (p *Compiler) ParseExprFrom(filename string, src any, conf *Config) (result any, err error) {
+	next, result, err := p.Match(filename, src, conf)
+	if err != nil {
+		return
+	}
+	if len(next) == 0 || isEOL(next[0].Tok) {
+		return
+	}
+	t := next[0]
+	err = &matcher.Error{Fset: p.Fset, Pos: t.Pos, Msg: fmt.Sprintf("unexpected token: %v", t)}
+	return
+}
+
+// Parse parses a source file.
+func (p *Compiler) Parse(filename string, src any, conf *Config) (result any, err error) {
+	next, result, err := p.Match(filename, src, conf)
+	if err != nil {
+		return
+	}
+	if len(next) > 0 {
+		t := next[0]
+		err = &matcher.Error{Fset: p.Fset, Pos: t.Pos, Msg: fmt.Sprintf("unexpected token: %v", t)}
+	}
+	return
+}
+
+// Match matches a source file.
+func (p *Compiler) Match(filename string, src any, conf *Config) (next []*Token, result any, err error) {
+	b, err := iox.ReadSourceLocal(filename, src)
+	if err != nil {
+		return
+	}
+	if conf == nil {
+		conf = &Config{}
+	}
+	s := conf.Scanner
+	if s == nil {
+		s = new(scanner.Scanner)
+	}
+	fset := p.Fset
+	f := fset.AddFile(filename, fset.Base(), len(b))
+	s.Init(f, b, conf.ScanErrorHandler, conf.ScanMode)
+	n := (len(b) >> 3) &^ 7
+	if n < 8 {
+		n = 8
+	}
+	toks := make([]*Token, 0, n)
+	for {
+		t := s.Scan()
+		if t.Tok == token.EOF {
+			break
+		}
+		toks = append(toks, &t)
+	}
+	ctx := &matcher.Context{
+		Fset:    fset,
+		FileEnd: token.Pos(f.Base() + len(b)),
+	}
+	n, result, err = p.Doc.Match(toks, ctx)
+	if err != nil {
+		return
+	}
+	next = toks[n:]
+	return
+}
+
+// -----------------------------------------------------------------------------
+
+func isEOL(tok token.Token) bool {
+	return tok == token.SEMICOLON || tok == token.EOF
+}
+
+func isPlain(result []any) bool {
+	for _, v := range result {
+		if _, ok := v.(*Token); !ok {
+			return false
+		}
+	}
+	return true
+}
+
+// -----------------------------------------------------------------------------
+
+func Dump(result any) {
+	Fdump(os.Stdout, result, "", "  ")
+}
+
+func Fdump(w io.Writer, result any, prefix, indent string) {
+	switch result := result.(type) {
+	case *Token:
+		fmt.Fprint(w, prefix, result, "\n")
+	case []any:
+		if isPlain(result) {
+			fmt.Print(prefix, "[")
+			for i, v := range result {
+				if i > 0 {
+					fmt.Print(" ")
+				}
+				fmt.Fprint(w, v)
+			}
+			fmt.Print("]\n")
+		} else {
+			fmt.Print(prefix, "[\n")
+			for _, v := range result {
+				Fdump(w, v, prefix+indent, indent)
+			}
+			fmt.Print(prefix, "]\n")
+		}
+	default:
+		panic("unexpected node")
+	}
+}
+
+// -----------------------------------------------------------------------------
diff --git a/tpl/types/types.go b/tpl/types/types.go
new file mode 100644
index 00000000..813f6b37
--- /dev/null
+++ b/tpl/types/types.go
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package types
+
+import (
+	"github.com/goplus/gop/tpl/token"
+)
+
+// A Token is a lexical unit returned by Scan.
+type Token struct {
+	Tok token.Token
+	Pos token.Pos
+	Lit string
+}
+
+// String returns the string representation of a token.
+func (p *Token) String() string {
+	n := len(p.Lit)
+	if n == 0 {
+		return p.Tok.String()
+	}
+	return p.Lit
+}
+
+// End returns end position of this token.
+func (p *Token) End() token.Pos {
+	n := len(p.Lit)
+	if n == 0 {
+		n = p.Tok.Len()
+	}
+	return p.Pos + token.Pos(n)
+}


Test patch:
diff --git a/ast/goptest/gopq.go b/ast/goptest/gopq.go
index c567dda2..ff57e0ee 100644
--- a/ast/goptest/gopq.go
+++ b/ast/goptest/gopq.go
@@ -23,13 +23,17 @@ import (
 	"github.com/goplus/gop/parser/fsx/memfs"
 )
 
+const (
+	GopPackage = "github.com/goplus/gop/ast/gopq"
+)
+
 // -----------------------------------------------------------------------------
 
 // New creates a nodeset object that represents a Go+ dom tree.
 func New(script string) (gopq.NodeSet, error) {
 	fset := token.NewFileSet()
 	fs := memfs.SingleFile("/foo", "bar.gop", script)
-	return gopq.NewSourceFrom(fset, fs, "/foo", nil, 0)
+	return gopq.FromFSDir(fset, fs, "/foo", nil, 0)
 }
 
 // -----------------------------------------------------------------------------
diff --git a/cl/_testgop/domaintext/in.gop b/cl/_testgop/domaintext/in.gop
new file mode 100644
index 00000000..78cac1d1
--- /dev/null
+++ b/cl/_testgop/domaintext/in.gop
@@ -0,0 +1,3 @@
+cl, err := tpl`expr = INT % ("+" | "-")`
+cl.parseExpr "1+2", nil
+_ = err
diff --git a/cl/_testgop/domaintext/out.go b/cl/_testgop/domaintext/out.go
new file mode 100644
index 00000000..af0289cf
--- /dev/null
+++ b/cl/_testgop/domaintext/out.go
@@ -0,0 +1,9 @@
+package main
+
+import "github.com/goplus/gop/tpl"
+
+func main() {
+	cl, err := tpl.New(`expr = INT % ("+" | "-")`)
+	cl.ParseExpr("1+2", nil)
+	_ = err
+}
diff --git a/cl/compile_test.go b/cl/compile_test.go
index 23b7a32b..b72c4ad3 100644
--- a/cl/compile_test.go
+++ b/cl/compile_test.go
@@ -1224,8 +1224,8 @@ func TestAutoProperty(t *testing.T) {
 func foo(script string) {
 	doc := goptest.New(script)!
 
-	println(doc.any.funcDecl.name)
-	println(doc.any.importSpec.name)
+	echo doc.any.funcDecl.name
+	echo doc.any.importSpec.name
 }
 `, `package main
 
@@ -1246,7 +1246,7 @@ func foo(script string) {
 		}
 		return
 	}()
-	fmt.Println(doc.Any().FuncDecl().Name())
+	fmt.Println(doc.Any().FuncDecl__0().Name())
 	fmt.Println(doc.Any().ImportSpec().Name())
 }
 `)
@@ -1280,7 +1280,7 @@ func foo(script string) {
 		}
 		return
 	}()
-	fmt.Println(doc.Any().FuncDecl().Name())
+	fmt.Println(doc.Any().FuncDecl__0().Name())
 	fmt.Println(doc.Any().ImportSpec().Name())
 }
 `)
diff --git a/parser/_testdata/domaintext/parser.expect b/parser/_testdata/domaintext/parser.expect
new file mode 100644
index 00000000..aadb4b7f
--- /dev/null
+++ b/parser/_testdata/domaintext/parser.expect
@@ -0,0 +1,37 @@
+package main
+
+file tpl.gop
+noEntrypoint
+ast.FuncDecl:
+  Name:
+    ast.Ident:
+      Name: main
+  Type:
+    ast.FuncType:
+      Params:
+        ast.FieldList:
+  Body:
+    ast.BlockStmt:
+      List:
+        ast.AssignStmt:
+          Lhs:
+            ast.Ident:
+              Name: cl
+          Tok: :=
+          Rhs:
+            ast.ErrWrapExpr:
+              X:
+                ast.DomainTextLit:
+                  Domain:
+                    ast.Ident:
+                      Name: tpl
+                  Value: `
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
+`
+              Tok: !
diff --git a/parser/_testdata/domaintext/tpl.gop b/parser/_testdata/domaintext/tpl.gop
new file mode 100644
index 00000000..cb852f19
--- /dev/null
+++ b/parser/_testdata/domaintext/tpl.gop
@@ -0,0 +1,9 @@
+cl := tpl`
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
+`!
diff --git a/parser/_testdata/typeof/parser.expect b/parser/_testdata/typeof/parser.expect
new file mode 100644
index 00000000..4f5ea586
--- /dev/null
+++ b/parser/_testdata/typeof/parser.expect
@@ -0,0 +1,38 @@
+package main
+
+file typeof.gop
+noEntrypoint
+ast.FuncDecl:
+  Name:
+    ast.Ident:
+      Name: main
+  Type:
+    ast.FuncType:
+      Params:
+        ast.FieldList:
+  Body:
+    ast.BlockStmt:
+      List:
+        ast.ExprStmt:
+          X:
+            ast.CallExpr:
+              Fun:
+                ast.Ident:
+                  Name: echo
+              Args:
+                ast.CallExpr:
+                  Fun:
+                    ast.Ident:
+                      Name: type
+                  Args:
+                    ast.BasicLit:
+                      Kind: INT
+                      Value: 1
+                ast.CallExpr:
+                  Fun:
+                    ast.Ident:
+                      Name: type
+                  Args:
+                    ast.BasicLit:
+                      Kind: STRING
+                      Value: "Hi"
diff --git a/parser/_testdata/typeof/typeof.gop b/parser/_testdata/typeof/typeof.gop
new file mode 100644
index 00000000..5363bca3
--- /dev/null
+++ b/parser/_testdata/typeof/typeof.gop
@@ -0,0 +1,1 @@
+echo type(1), type("Hi")
diff --git a/parser/parserdir_test.go b/parser/parserdir_test.go
index 086a27fc..8c4d29ed 100644
--- a/parser/parserdir_test.go
+++ b/parser/parserdir_test.go
@@ -28,6 +28,7 @@ import (
 	"github.com/goplus/gop/ast"
 	"github.com/goplus/gop/parser/fsx"
 	"github.com/goplus/gop/parser/fsx/memfs"
+	"github.com/goplus/gop/parser/iox"
 	"github.com/goplus/gop/parser/parsertest"
 	"github.com/goplus/gop/scanner"
 	"github.com/goplus/gop/token"
@@ -319,17 +320,17 @@ func TestParseExprFrom(t *testing.T) {
 
 func TestReadSource(t *testing.T) {
 	buf := bytes.NewBuffer(nil)
-	if _, err := readSource(buf); err != nil {
+	if _, err := iox.ReadSource(buf); err != nil {
 		t.Fatal("readSource failed:", err)
 	}
 	sr := strings.NewReader("")
-	if _, err := readSource(sr); err != nil {
+	if _, err := iox.ReadSource(sr); err != nil {
 		t.Fatal("readSource strings.Reader failed:", err)
 	}
-	if _, err := readSource(0); err == nil {
+	if _, err := iox.ReadSource(0); err == nil {
 		t.Fatal("readSource int failed: no error?")
 	}
-	if _, err := readSourceLocal("/foo/bar/not-exists", nil); err == nil {
+	if _, err := iox.ReadSourceLocal("/foo/bar/not-exists", nil); err == nil {
 		t.Fatal("readSourceLocal int failed: no error?")
 	}
 }
@@ -343,7 +344,7 @@ func TestParseFiles(t *testing.T) {
 
 func TestIparseFileInvalidSrc(t *testing.T) {
 	fset := token.NewFileSet()
-	if _, err := parseFile(fset, "/foo/bar/not-exists", 1, PackageClauseOnly); err != errInvalidSource {
+	if _, err := parseFile(fset, "/foo/bar/not-exists", 1, PackageClauseOnly); err != iox.ErrInvalidSource {
 		t.Fatal("ParseFile failed: not errInvalidSource?")
 	}
 }
diff --git a/tpl/parser/_testdata/simple1/in.gop b/tpl/parser/_testdata/simple1/in.gop
new file mode 100644
index 00000000..868b29ce
--- /dev/null
+++ b/tpl/parser/_testdata/simple1/in.gop
@@ -0,0 +1,7 @@
+expr = termExpr | expr ("+" | "-") expr
+
+termExpr = unaryExpr | termExpr ("*" | "/") termExpr
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
diff --git a/tpl/parser/_testdata/simple1/out.expect b/tpl/parser/_testdata/simple1/out.expect
new file mode 100644
index 00000000..a5fe173b
--- /dev/null
+++ b/tpl/parser/_testdata/simple1/out.expect
@@ -0,0 +1,83 @@
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: expr
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: termExpr
+        ast.Sequence:
+          Items:
+            ast.Ident:
+              Name: expr
+            ast.Choice:
+              Options:
+                ast.BasicLit:
+                  Kind: STRING
+                  Value: "+"
+                ast.BasicLit:
+                  Kind: STRING
+                  Value: "-"
+            ast.Ident:
+              Name: expr
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: termExpr
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: unaryExpr
+        ast.Sequence:
+          Items:
+            ast.Ident:
+              Name: termExpr
+            ast.Choice:
+              Options:
+                ast.BasicLit:
+                  Kind: STRING
+                  Value: "*"
+                ast.BasicLit:
+                  Kind: STRING
+                  Value: "/"
+            ast.Ident:
+              Name: termExpr
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: unaryExpr
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: operand
+        ast.Sequence:
+          Items:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "-"
+            ast.Ident:
+              Name: unaryExpr
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: operand
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: INT
+        ast.Ident:
+          Name: FLOAT
+        ast.Sequence:
+          Items:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "("
+            ast.Ident:
+              Name: expr
+            ast.BasicLit:
+              Kind: STRING
+              Value: ")"
diff --git a/tpl/parser/_testdata/simple2/in.gop b/tpl/parser/_testdata/simple2/in.gop
new file mode 100644
index 00000000..42c1a043
--- /dev/null
+++ b/tpl/parser/_testdata/simple2/in.gop
@@ -0,0 +1,7 @@
+expr = termExpr % ("+" | "-")
+
+termExpr = unaryExpr % ("*" | "/")
+
+unaryExpr = operand | "-" unaryExpr
+
+operand = INT | FLOAT | "(" expr ")"
diff --git a/tpl/parser/_testdata/simple2/out.expect b/tpl/parser/_testdata/simple2/out.expect
new file mode 100644
index 00000000..0b22f9af
--- /dev/null
+++ b/tpl/parser/_testdata/simple2/out.expect
@@ -0,0 +1,75 @@
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: expr
+  Expr:
+    ast.BinaryExpr:
+      X:
+        ast.Ident:
+          Name: termExpr
+      Op: %
+      Y:
+        ast.Choice:
+          Options:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "+"
+            ast.BasicLit:
+              Kind: STRING
+              Value: "-"
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: termExpr
+  Expr:
+    ast.BinaryExpr:
+      X:
+        ast.Ident:
+          Name: unaryExpr
+      Op: %
+      Y:
+        ast.Choice:
+          Options:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "*"
+            ast.BasicLit:
+              Kind: STRING
+              Value: "/"
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: unaryExpr
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: operand
+        ast.Sequence:
+          Items:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "-"
+            ast.Ident:
+              Name: unaryExpr
+ast.Rule:
+  Name:
+    ast.Ident:
+      Name: operand
+  Expr:
+    ast.Choice:
+      Options:
+        ast.Ident:
+          Name: INT
+        ast.Ident:
+          Name: FLOAT
+        ast.Sequence:
+          Items:
+            ast.BasicLit:
+              Kind: STRING
+              Value: "("
+            ast.Ident:
+              Name: expr
+            ast.BasicLit:
+              Kind: STRING
+              Value: ")"
diff --git a/tpl/parser/parser_test.go b/tpl/parser/parser_test.go
new file mode 100644
index 00000000..711ce09b
--- /dev/null
+++ b/tpl/parser/parser_test.go
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package parser
+
+import (
+	"go/scanner"
+	"log"
+	"os"
+	"path"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/goplus/gop/tpl/parser/parsertest"
+	"github.com/goplus/gop/tpl/token"
+)
+
+func testFrom(t *testing.T, pkgDir, sel string) {
+	if sel != "" && !strings.Contains(pkgDir, sel) {
+		return
+	}
+	t.Helper()
+	log.Println("Parsing", pkgDir)
+	fset := token.NewFileSet()
+	f, err := ParseFile(fset, pkgDir+"/in.gop", nil, 0)
+	if err != nil {
+		if errs, ok := err.(scanner.ErrorList); ok {
+			for _, e := range errs {
+				t.Log(e)
+			}
+		}
+		t.Fatal("ParseFile failed:", err, reflect.TypeOf(err))
+	}
+	b, err := os.ReadFile(pkgDir + "/out.expect")
+	if err != nil {
+		t.Fatal("Parsing", pkgDir, "-", err)
+	}
+	parsertest.Expect(t, f, string(b))
+}
+
+func testFromDir(t *testing.T, sel, relDir string) {
+	dir, err := os.Getwd()
+	if err != nil {
+		t.Fatal("Getwd failed:", err)
+	}
+	dir = path.Join(dir, relDir)
+	fis, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatal("ReadDir failed:", err)
+	}
+	for _, fi := range fis {
+		name := fi.Name()
+		if strings.HasPrefix(name, "_") {
+			continue
+		}
+		t.Run(name, func(t *testing.T) {
+			testFrom(t, dir+"/"+name, sel)
+		})
+	}
+}
+
+func TestFromTestdata(t *testing.T) {
+	testFromDir(t, "", "./_testdata")
+}
diff --git a/tpl/parser/parsertest/parsertest.go b/tpl/parser/parsertest/parsertest.go
new file mode 100644
index 00000000..dcf1c25a
--- /dev/null
+++ b/tpl/parser/parsertest/parsertest.go
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2021 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package parsertest
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"reflect"
+	"testing"
+
+	"github.com/goplus/gop/tpl/ast"
+	"github.com/goplus/gop/tpl/token"
+)
+
+var (
+	tyNode   = reflect.TypeOf((*ast.Node)(nil)).Elem()
+	tyString = reflect.TypeOf("")
+	tyToken  = reflect.TypeOf(token.Token(0))
+)
+
+// FprintNode prints a tpl AST node.
+func FprintNode(w io.Writer, lead string, v any, prefix, indent string) {
+	val := reflect.ValueOf(v)
+	switch val.Kind() {
+	case reflect.Slice:
+		n := val.Len()
+		if n > 0 && lead != "" {
+			io.WriteString(w, lead)
+		}
+		for i := 0; i < n; i++ {
+			FprintNode(w, "", val.Index(i).Interface(), prefix, indent)
+		}
+	case reflect.Ptr:
+		t := val.Type()
+		if val.IsNil() {
+			return
+		}
+		if t.Implements(tyNode) {
+			if lead != "" {
+				io.WriteString(w, lead)
+			}
+			elem, tyElem := val.Elem(), t.Elem()
+			fmt.Fprintf(w, "%s%v:\n", prefix, tyElem)
+			n := elem.NumField()
+			prefix += indent
+			for i := 0; i < n; i++ {
+				sf := tyElem.Field(i)
+				sfv := elem.Field(i).Interface()
+				switch sf.Type {
+				case tyString, tyToken:
+					fmt.Fprintf(w, "%s%v: %v\n", prefix, sf.Name, sfv)
+				default:
+					FprintNode(w, fmt.Sprintf("%s%v:\n", prefix, sf.Name), sfv, prefix+indent, indent)
+				}
+			}
+		} else {
+			log.Panicln("FprintNode unexpected type:", t)
+		}
+	case reflect.Int, reflect.Bool, reflect.Invalid:
+		// skip
+	default:
+		log.Panicln("FprintNode unexpected kind:", val.Kind(), "type:", val.Type())
+	}
+}
+
+// Fprint prints a tpl ast.File node.
+func Fprint(w io.Writer, f *ast.File) {
+	FprintNode(w, "", f.Decls, "", "  ")
+}
+
+// Expect asserts a tpl AST equals output or not.
+func Expect(t *testing.T, f *ast.File, expected string) {
+	b := bytes.NewBuffer(nil)
+	Fprint(b, f)
+	output := b.String()
+	if expected != output {
+		fmt.Fprint(os.Stderr, output)
+		t.Fatal("tpl.Parser: unexpect result")
+	}
+}
diff --git a/tpl/scanner/scanner_test.go b/tpl/scanner/scanner_test.go
new file mode 100644
index 00000000..ed0bcc2b
--- /dev/null
+++ b/tpl/scanner/scanner_test.go
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package scanner
+
+import (
+	"testing"
+
+	"github.com/goplus/gop/tpl/token"
+	"github.com/goplus/gop/tpl/types"
+)
+
+type Token = types.Token
+
+type tokenTest struct {
+	Pos     token.Pos
+	Kind    token.Token
+	Literal string
+}
+
+func TestScanner(t *testing.T) {
+	var expected = []tokenTest{
+		{3, token.IDENT, `term`},
+		{8, '=', ``},
+		{10, token.IDENT, `factor`},
+		{17, '*', ``},
+		{18, '(', ``},
+		{19, token.CHAR, `'*'`},
+		{23, token.IDENT, `factor`},
+		{29, '/', ``},
+		{30, token.IDENT, `mul`},
+		{34, '|', ``},
+		{36, token.CHAR, `'/'`},
+		{40, token.IDENT, `factor`},
+		{46, '/', ``},
+		{47, token.IDENT, `div`},
+		{50, ')', ``},
+		{51, ';', "\n"},
+		{53, token.IDENT, `expr`},
+		{58, '=', ``},
+		{60, token.IDENT, `term`},
+		{65, '*', ``},
+		{66, '(', ``},
+		{67, token.CHAR, `'+'`},
+		{71, token.IDENT, `term`},
+		{75, '/', ``},
+		{76, token.IDENT, `add`},
+		{80, '|', ``},
+		{82, token.CHAR, `'-'`},
+		{86, token.IDENT, `term`},
+		{90, '/', ``},
+		{91, token.IDENT, `sub`},
+		{94, ')', ``},
+		{95, ';', "\n"},
+		{97, token.IDENT, `factor`},
+		{104, '=', ``},
+		{107, token.IDENT, `FLOAT`},
+		{112, '/', ``},
+		{113, token.IDENT, `push`},
+		{118, '|', ``},
+		{121, token.CHAR, `'-'`},
+		{125, token.IDENT, `factor`},
+		{131, '/', ``},
+		{132, token.IDENT, `neg`},
+		{136, '|', ``},
+		{139, token.CHAR, `'('`},
+		{143, token.IDENT, `expr`},
+		{148, token.CHAR, `')'`},
+		{152, '|', ``},
+		{155, '(', ``},
+		{156, token.IDENT, `IDENT`},
+		{162, token.CHAR, `'('`},
+		{166, token.IDENT, `expr`},
+		{171, '%', ``},
+		{173, token.CHAR, `','`},
+		{176, '/', ``},
+		{177, token.IDENT, `arity`},
+		{183, token.CHAR, `')'`},
+		{186, ')', ``},
+		{187, '/', ``},
+		{188, token.IDENT, `call`},
+		{193, '|', ``},
+		{196, token.CHAR, `'+'`},
+		{200, token.IDENT, `factor`},
+		{206, ';', "\n"},
+		{208, token.IDENT, `hello`},
+		{214, ';', "\n"},
+		{214, token.COMMENT, "#!/user/bin/env tpl 中文"},
+		{241, token.IDENT, `hello`},
+		{247, ';', "\n"},
+		{247, token.COMMENT, "//!/user/bin/env tpl 中文"},
+		{275, token.IDENT, `world`},
+		{281, token.NE, ``},
+	}
+
+	const grammar = `
+
+term = factor *('*' factor/mul | '/' factor/div)
+
+expr = term *('+' term/add | '-' term/sub)
+
+factor =
+	FLOAT/push |
+	'-' factor/neg |
+	'(' expr ')' |
+	(IDENT '(' expr % ','/arity ')')/call |
+	'+' factor
+
+hello #!/user/bin/env tpl 中文
+hello //!/user/bin/env tpl 中文
+world !=
+`
+	var s Scanner
+
+	fset := token.NewFileSet()
+	file := fset.AddFile("", -1, len(grammar))
+
+	s.Init(file, []byte(grammar), nil /* no error handler */, ScanComments)
+	i := 0
+	for {
+		c := s.Scan()
+		if c.Tok == token.EOF {
+			break
+		}
+		expect := Token{Tok: expected[i].Kind, Pos: expected[i].Pos, Lit: expected[i].Literal}
+		if c != expect {
+			t.Fatal("Scan failed:", c, expect)
+		}
+		i++
+	}
+	if len(expected) != i {
+		t.Fatalf("len(expected) != i: %d, %d\n", len(expected), i)
+	}
+
+	s.Init(file, []byte(grammar), nil, NoInsertSemis)
+	i = 0
+	for {
+		c := s.Scan()
+		if c.Tok == token.EOF {
+			break
+		}
+		if expected[i].Kind == ';' {
+			i++
+		}
+		if expected[i].Kind == token.COMMENT {
+			i++
+		}
+		expect := Token{Tok: expected[i].Kind, Pos: expected[i].Pos, Lit: expected[i].Literal}
+		if c != expect {
+			t.Fatal("Scan failed:", c.Pos, c.Lit, expect)
+		}
+		switch c.Tok.Len() {
+		case 0, 1, 2, 3:
+		default:
+			t.Fatal("TokenLen failed:", c.Tok.Len())
+		}
+		i++
+	}
+	if i < len(expected) && expected[i].Kind == ';' {
+		i++
+	}
+	if len(expected) != i {
+		t.Fatalf("len(expected) != i: %d, %d\n", len(expected), i)
+	}
+
+	s.Init(file, []byte(grammar), nil, 0)
+	i = 0
+	for {
+		c := s.Scan()
+		if c.Tok == token.EOF {
+			break
+		}
+		if expected[i].Kind == token.COMMENT {
+			i++
+		}
+		expect := Token{Tok: expected[i].Kind, Pos: expected[i].Pos, Lit: expected[i].Literal}
+		if c != expect {
+			t.Fatal("Scan failed:", c.Pos, c.Lit, expect)
+		}
+		i++
+	}
+	if len(expected) != i {
+		t.Fatalf("len(expected) != i: %d, %d\n", len(expected), i)
+	}
+}
diff --git a/tpl/token/token_test.go b/tpl/token/token_test.go
new file mode 100644
index 00000000..d3c3af86
--- /dev/null
+++ b/tpl/token/token_test.go
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package token
+
+import (
+	"testing"
+)
+
+func TestToken(t *testing.T) {
+	if IDENT.String() != "IDENT" {
+		t.Fatal("IDENT")
+	}
+	if Token(' ').String() != "token(32)" {
+		t.Fatal("token(32)")
+	}
+	if IDENT.Len() != 0 {
+		t.Fatal("TokLen IDENT")
+	}
+	if Token('+').Len() != 1 {
+		t.Fatal("TokLen +")
+	}
+	count := 0
+	ForEach(func(tok Token, name string) {
+		count++
+	})
+	if count != 59 {
+		t.Fatal("ForEach:", count)
+	}
+}
diff --git a/x/build/build_test.go b/x/build/build_test.go
index 9944747b..ea82939a 100644
--- a/x/build/build_test.go
+++ b/x/build/build_test.go
@@ -119,6 +119,7 @@ func (this *Rect) Main() {
 	fmt.Println("Go+")
 }
 func main() {
+	new(Rect).Main()
 }
 `)
 	gopClTestEx(t, "Rect.gox", `
@@ -146,6 +147,7 @@ func (this *Rect) Main() {
 	fmt.Println("Go+")
 }
 func main() {
+	new(Rect).Main()
 }
 `)
 	gopClTestEx(t, "Rect.gox", `
@@ -173,6 +175,7 @@ func (this *Rect) Main() {
 	fmt.Println("Go+")
 }
 func main() {
+	new(Rect).Main()
 }
 `)
 	gopClTestEx(t, "Rect.gox", `
@@ -198,6 +201,7 @@ func (this *Rect) Main() {
 	fmt.Println("Go+")
 }
 func main() {
+	new(Rect).Main()
 }
 `)
 	gopClTestEx(t, "Rect.gox", `
@@ -223,6 +227,7 @@ func (this *Rect) Main() {
 	fmt.Println("Go+")
 }
 func main() {
+	new(Rect).Main()
 }
 `)
 }