Repository: goplus/gop
Pull request #1816 (2024-03-10 17:21:28)

v1.2.5


Patch:
diff --git a/README.md b/README.md
index 8b2993fa..44087027 100644
--- a/README.md
+++ b/README.md
@@ -92,44 +92,45 @@ Sound a bit abstract? Let's see some Go+ classfiles.
 * STEM Education: [spx: A Go+ 2D Game Engine](https://github.com/goplus/spx)
 
 
-### gsh: Go+ DevOps Tools
+### yap: Yet Another HTTP Web Framework
 
-Yes, now you can write `shell script` in Go+. It supports all shell commands.
+This classfile has the file suffix `.yap`.
 
-Let's create a file named `./example.gsh` and write the following code:
+Create a file named [get.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_hello/get.yap) with the following content:
 
-```coffee
-mkdir "testgsh"
+```go
+html `<html><body>Hello, YAP!</body></html>`
 ```
 
-Don't need a `go.mod` file, just enter `gop run ./example.gsh` directly to run.
+Execute the following commands:
 
-It's strange to you that the file extension of Go+ source is not `.gop` but `.gsh`. It is only because Go+ register `.gsh` as a builtin [classfile](doc/classfile.md).
+```sh
+gop mod init hello
+gop get github.com/goplus/yap@latest
+gop mod tidy
+gop run .
+```
 
-See [gsh: Go+ DevOps Tools](https://github.com/qiniu/x/tree/main/gsh) for more details.
+A simplest web program is running now. At this time, if you visit http://localhost:8080, you will get:
 
+```
+Hello, YAP!
+```
 
-### yap: Yet Another HTTP Web Framework
+YAP uses filenames to define routes. `get.yap`'s route is `get "/"` (GET homepage), and `get_p_#id.yap`'s route is `get "/p/:id"` (In fact, the filename can also be `get_p_:id.yap`, but it is not recommended because `:` is not allowed to exist in filenames under Windows).
 
-Demo of serving static files and ability to handle dynamic GET/POST requests:
+Let's create a file named [get_p_#id.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_hello/get_p_%23id.yap) with the following content:
 
 ```coffee
-static "/foo", FS("public")
-static "/"    # Equivalent to static "/", FS("static")
-
-get "/p/:id", ctx => {
-	ctx.json {
-		"id": ctx.param("id"),
-	}
+json {
+	"id": ${id},
 }
-
-run ":8080"
 ```
 
-If you run it and visit http://localhost:8080/p/123, you will get:
+Execute `gop run .` and visit http://localhost:8080/p/123, you will get:
 
 ```
-{"id":"123"}
+{"id": "123"}
 ```
 
 See [yap: Yet Another HTTP Web Framework](https://github.com/goplus/yap) for more details.
@@ -173,6 +174,21 @@ The following procedures are very similar. In this way you can implement dialogu
 See [spx: A Go+ 2D Game Engine](https://github.com/goplus/spx) for more details.
 
 
+### gsh: Go+ DevOps Tools
+
+Yes, now you can write `shell script` in Go+. It supports all shell commands.
+
+Let's create a file named [example.gsh](https://github.com/qiniu/x/blob/main/gsh/demo/hello/example.gsh) and write the following code:
+
+```coffee
+mkdir "testgsh"
+```
+
+Don't need a `go.mod` file, just enter `gop run ./example.gsh` directly to run.
+
+See [gsh: Go+ DevOps Tools](https://github.com/qiniu/x/tree/main/gsh) for more details.
+
+
 ## Key Features of Go+
 
 * A static typed language.
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 4367f900..00000000
--- a/VERSION
+++ /dev/null
@@ -1,1 +0,0 @@
-v1.2.3
diff --git a/ast/ast_gop.go b/ast/ast_gop.go
index 17ec40ec..251716a4 100644
--- a/ast/ast_gop.go
+++ b/ast/ast_gop.go
@@ -85,6 +85,36 @@ func NextPartPos(pos token.Pos, part any) (nextPos token.Pos) {
 
 // -----------------------------------------------------------------------------
 
+// A EnvExpr node represents a ${name} expression.
+type EnvExpr struct {
+	TokPos token.Pos // position of "$"
+	Lbrace token.Pos // position of "{"
+	Name   *Ident    // name
+	Rbrace token.Pos // position of "}"
+}
+
+// Pos - position of first character belonging to the node.
+func (p *EnvExpr) Pos() token.Pos {
+	return p.TokPos
+}
+
+// End - position of first character immediately after the node.
+func (p *EnvExpr) End() token.Pos {
+	if p.Rbrace != token.NoPos {
+		return p.Rbrace
+	}
+	return p.Name.End()
+}
+
+// HasBrace checks is this EnvExpr ${name} or $name.
+func (p *EnvExpr) HasBrace() bool {
+	return p.Rbrace != token.NoPos
+}
+
+func (*EnvExpr) exprNode() {}
+
+// -----------------------------------------------------------------------------
+
 // A SliceLit node represents a slice literal.
 type SliceLit struct {
 	Lbrack     token.Pos // position of "["
diff --git a/ast/walk.go b/ast/walk.go
index 4ce089ff..0cea28fe 100644
--- a/ast/walk.go
+++ b/ast/walk.go
@@ -456,6 +456,9 @@ func Walk(v Visitor, node Node) {
 		Walk(v, n.Name)
 		walkExprList(v, n.Funcs)
 
+	case *EnvExpr:
+		Walk(v, n.Name)
+
 	default:
 		panic(fmt.Sprintf("ast.Walk: unexpected node type %T", n))
 	}
diff --git a/cl/classfile.go b/cl/classfile.go
index 6f09e237..2eecbcd5 100644
--- a/cl/classfile.go
+++ b/cl/classfile.go
@@ -19,9 +19,11 @@ package cl
 import (
 	goast "go/ast"
 	"go/constant"
+	gotoken "go/token"
 	"go/types"
 	"log"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"github.com/goplus/gogen"
@@ -41,7 +43,7 @@ type gmxClass struct {
 
 type gmxProject struct {
 	gameClass  string               // <gmtype>.gmx
-	game       gogen.Ref            // Game
+	game       gogen.Ref            // Game (project base class)
 	sprite     map[string]gogen.Ref // .spx => Sprite
 	sptypes    []string             // <sptype>.spx
 	scheds     []string
@@ -244,67 +246,108 @@ func genTestFunc(pkg *gogen.Package, name, testType, param, paramType string) {
 		End()
 }
 
-func gmxMainFunc(pkg *gogen.Package, ctx *pkgCtx, noAutoGenMain bool) func() {
-	var proj *gmxProject
+func gmxCheckProjs(pkg *gogen.Package, ctx *pkgCtx) (proj *gmxProject, multi bool) {
 	for _, v := range ctx.projs {
 		if v.isTest {
 			continue
 		} else if proj != nil {
-			return nil
+			multi = true
+		} else {
+			proj = v
+		}
+		if v.game != nil { // just to make testcase happy
+			gmxProjMain(pkg, ctx, v)
 		}
-		proj = v
 	}
-	if proj != nil { // only one project file
-		scope := pkg.Types.Scope()
-		var o types.Object
-		if proj.gameClass != "" {
-			o = scope.Lookup(proj.gameClass)
-			if noAutoGenMain && o != nil && hasMethod(o, "MainEntry") {
-				noAutoGenMain = false
+	return
+}
+
+func gmxProjMain(pkg *gogen.Package, parent *pkgCtx, proj *gmxProject) {
+	base := proj.game
+	classType := proj.gameClass
+	if classType == "" {
+		classType = base.Name()
+		proj.gameClass = classType
+	}
+
+	ld := getTypeLoader(parent, parent.syms, token.NoPos, proj.gameClass)
+	if ld.typ == nil {
+		ld.typ = func() {
+			if debugLoad {
+				log.Println("==> Load > NewType", classType)
 			}
-		} else {
-			o = proj.game
-		}
-		if !noAutoGenMain && o != nil {
-			// new(Game).Main()
-			// new(Game).Main(workers...)
-			fn := pkg.NewFunc(nil, "main", nil, nil, false)
-			return func() {
-				new := pkg.Builtin().Ref("new")
-				cb := fn.BodyStart(pkg).Val(new).Val(o).Call(1).MemberVal("Main")
-
-				// force remove //line comments for main func
-				cb.SetComments(nil, false)
-
-				sig := cb.Get(-1).Type.(*types.Signature)
-				narg := gmxMainNarg(sig)
-				if narg > 0 {
-					narg = len(proj.sptypes)
-					for _, spt := range proj.sptypes {
-						sp := scope.Lookup(spt)
-						cb.Val(new).Val(sp).Call(1)
-					}
+			old, _ := pkg.SetCurFile(defaultGoFile, true)
+			defer pkg.RestoreCurFile(old)
+
+			baseType := base.Type()
+			if proj.gameIsPtr {
+				baseType = types.NewPointer(baseType)
+			}
+			name := base.Name()
+			flds := []*types.Var{
+				types.NewField(token.NoPos, pkg.Types, name, baseType, true),
+			}
+			decl := pkg.NewTypeDefs().NewType(classType)
+			ld.typInit = func() { // decycle
+				if debugLoad {
+					log.Println("==> Load > InitType", classType)
 				}
+				old, _ := pkg.SetCurFile(defaultGoFile, true)
+				defer pkg.RestoreCurFile(old)
 
-				cb.Call(narg).EndStmt().End()
+				decl.InitType(pkg, types.NewStruct(flds, nil))
 			}
+			parent.tylds = append(parent.tylds, ld)
 		}
 	}
-	return nil
+	ld.methods = append(ld.methods, func() {
+		old, _ := pkg.SetCurFile(defaultGoFile, true)
+		defer pkg.RestoreCurFile(old)
+		doInitType(ld)
+
+		t := pkg.Ref(classType).Type()
+		recv := types.NewParam(token.NoPos, pkg.Types, "this", types.NewPointer(t))
+		fn := pkg.NewFunc(recv, "Main", nil, nil, false)
+
+		parent.inits = append(parent.inits, func() {
+			old, _ := pkg.SetCurFile(defaultGoFile, true)
+			defer pkg.RestoreCurFile(old)
+
+			cb := fn.BodyStart(pkg).Typ(base.Type()).MemberVal("Main")
+
+			// force remove //line comments for main func
+			cb.SetComments(nil, false)
+
+			sigParams := cb.Get(-1).Type.(*types.Signature).Params()
+			if _, ok := sigParams.At(0).Type().(*types.Pointer); !ok {
+				cb.Val(recv) // template recv method
+			} else {
+				cb.Val(recv).MemberRef(base.Name()).UnaryOp(gotoken.AND)
+			}
+			narg := sigParams.Len()
+			if narg > 1 {
+				narg = 1 + len(proj.sptypes)
+				new := pkg.Builtin().Ref("new")
+				for _, spt := range proj.sptypes {
+					sp := pkg.Ref(spt)
+					cb.Val(new).Val(sp).Call(1)
+				}
+			}
+
+			cb.Call(narg).EndStmt().End()
+		})
+	})
 }
 
-func gmxMainNarg(sig *types.Signature) int {
-	if fex, ok := gogen.CheckFuncEx(sig); ok {
-		if trm, ok := fex.(*gogen.TyTemplateRecvMethod); ok {
-			sig = trm.Func.Type().(*types.Signature)
-			return sig.Params().Len() - 1
+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()
 		}
 	}
-	return sig.Params().Len()
-}
-
-func hasMethod(o types.Object, name string) bool {
-	return findMethod(o, name) != nil
 }
 
 func findMethod(o types.Object, name string) *types.Func {
@@ -338,4 +381,71 @@ func makeMainSig(recv *types.Var, f *types.Func) *types.Signature {
 	return types.NewSignatureType(recv, nil, nil, types.NewTuple(params...), nil, false)
 }
 
+func astFnClassfname(c *gmxClass) *ast.FuncDecl {
+	return &ast.FuncDecl{
+		Name: &ast.Ident{
+			Name: "Classfname",
+		},
+		Type: &ast.FuncType{
+			Params: &ast.FieldList{},
+			Results: &ast.FieldList{
+				List: []*ast.Field{
+					{Type: &ast.Ident{Name: "string"}},
+				},
+			},
+		},
+		Body: &ast.BlockStmt{
+			List: []ast.Stmt{
+				&ast.ReturnStmt{
+					Results: []ast.Expr{
+						&ast.BasicLit{Kind: token.STRING, Value: strconv.Quote(c.clsfile)},
+					},
+				},
+			},
+		},
+	}
+}
+
+func astEmptyEntrypoint(f *ast.File) {
+	var entry = getEntrypoint(f)
+	var hasEntry bool
+	for _, decl := range f.Decls {
+		switch d := decl.(type) {
+		case *ast.FuncDecl:
+			if d.Name.Name == entry {
+				hasEntry = true
+			}
+		}
+	}
+	if !hasEntry {
+		f.Decls = append(f.Decls, &ast.FuncDecl{
+			Name: &ast.Ident{
+				Name: entry,
+			},
+			Type: &ast.FuncType{
+				Params: &ast.FieldList{},
+			},
+			Body:   &ast.BlockStmt{},
+			Shadow: true,
+		})
+	}
+}
+
+func getEntrypoint(f *ast.File) string {
+	switch {
+	case f.IsProj:
+		return "MainEntry"
+	case f.IsClass:
+		return "Main"
+	case inMainPkg(f):
+		return "main"
+	default:
+		return "init"
+	}
+}
+
+func inMainPkg(f *ast.File) bool {
+	return f.Name.Name == "main"
+}
+
 // -----------------------------------------------------------------------------
diff --git a/cl/compile.go b/cl/compile.go
index 5359e50c..c5948cfd 100644
--- a/cl/compile.go
+++ b/cl/compile.go
@@ -548,7 +548,7 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 		gmx := f.File
 		if gmx.IsClass && !gmx.IsNormalGox {
 			if debugLoad {
-				log.Println("==> File", f.path, "normalGox:", gmx.IsNormalGox)
+				log.Println("==> ClassFile", f.path)
 			}
 			loadClass(ctx, p, f.path, gmx, conf)
 		}
@@ -568,6 +568,8 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 		preloadGopFile(p, ctx, f.path, f.File, conf)
 	}
 
+	proj, multi := gmxCheckProjs(p, ctx)
+
 	gopSyms := make(map[string]bool) // TODO: remove this map
 	for name := range ctx.syms {
 		gopSyms[name] = true
@@ -599,8 +601,11 @@ func NewPackage(pkgPath string, pkg *ast.Package, conf *Config) (p *gogen.Packag
 			loadFile(ctx, f.File)
 		}
 	}
+
 	if genMain { // make classfile main func if need
-		gen = gmxMainFunc(p, ctx, conf.NoAutoGenMain)
+		if proj != nil && !multi { // only one project file
+			gen = gmxMainFunc(p, proj)
+		}
 	}
 
 	for _, f := range sfiles {
@@ -657,23 +662,6 @@ func initGopPkg(ctx *pkgCtx, pkg *gogen.Package, gopSyms map[string]bool) {
 	gogen.InitThisGopPkg(pkg.Types)
 }
 
-func inMainPkg(f *ast.File) bool {
-	return f.Name.Name == "main"
-}
-
-func getEntrypoint(f *ast.File) string {
-	switch {
-	case f.IsProj:
-		return "MainEntry"
-	case f.IsClass:
-		return "Main"
-	case inMainPkg(f):
-		return "main"
-	default:
-		return "init"
-	}
-}
-
 func loadFile(ctx *pkgCtx, f *ast.File) {
 	for _, decl := range f.Decls {
 		switch d := decl.(type) {
@@ -854,56 +842,16 @@ func preloadGopFile(p *gogen.Package, ctx *blockCtx, file string, f *ast.File, c
 		}}}
 		// func Classfname() string
 		if spxClass {
-			f.Decls = append(f.Decls, &ast.FuncDecl{
-				Name: &ast.Ident{
-					Name: "Classfname",
-				},
-				Type: &ast.FuncType{
-					Params: &ast.FieldList{},
-					Results: &ast.FieldList{
-						List: []*ast.Field{
-							{Type: &ast.Ident{Name: "string"}},
-						},
-					},
-				},
-				Body: &ast.BlockStmt{
-					List: []ast.Stmt{
-						&ast.ReturnStmt{
-							Results: []ast.Expr{
-								&ast.BasicLit{Kind: token.STRING, Value: strconv.Quote(c.clsfile)},
-							},
-						},
-					},
-				},
-			})
+			f.Decls = append(f.Decls, astFnClassfname(c))
 		}
 	}
+
 	if d := f.ShadowEntry; d != nil {
 		d.Name.Name = getEntrypoint(f)
-	} else if f.IsProj && !conf.NoAutoGenMain && inMainPkg(f) {
-		var entry = getEntrypoint(f)
-		var hasEntry bool
-		for _, decl := range f.Decls {
-			switch d := decl.(type) {
-			case *ast.FuncDecl:
-				if d.Name.Name == entry {
-					hasEntry = true
-				}
-			}
-		}
-		if !hasEntry {
-			f.Decls = append(f.Decls, &ast.FuncDecl{
-				Name: &ast.Ident{
-					Name: entry,
-				},
-				Type: &ast.FuncType{
-					Params: &ast.FieldList{},
-				},
-				Body:   &ast.BlockStmt{},
-				Shadow: true,
-			})
-		}
+	} else if baseTypeName != "" { // isClass && not isNormalGox
+		astEmptyEntrypoint(f)
 	}
+
 	preloadFile(p, ctx, f, goFile, !conf.Outline)
 	if goxTestFile {
 		parent.inits = append(parent.inits, func() {
diff --git a/cl/expr.go b/cl/expr.go
index 7a49b06c..b261dcf7 100644
--- a/cl/expr.go
+++ b/cl/expr.go
@@ -188,6 +188,21 @@ find:
 	return
 }
 
+func compileEnvExpr(ctx *blockCtx, v *ast.EnvExpr) {
+	cb := ctx.cb
+	if ctx.isClass { // in a Go+ class file
+		if recv := classRecv(cb); recv != nil {
+			if gopMember(cb, recv, "Gop_Env", v) == nil {
+				name := v.Name
+				cb.Val(name.Name, name).CallWith(1, 0, v)
+				return
+			}
+		}
+	}
+	invalidVal(cb)
+	ctx.handleErrorf(v.Pos(), "operator $%v undefined", v.Name)
+}
+
 func classRecv(cb *gogen.CodeBuilder) *types.Var {
 	if fn := cb.Func(); fn != nil {
 		sig := fn.Ancestor().Type().(*types.Signature)
@@ -346,6 +361,8 @@ func compileExpr(ctx *blockCtx, expr ast.Expr, inFlags ...int) {
 		compileErrWrapExpr(ctx, v, 0)
 	case *ast.FuncType:
 		ctx.cb.Typ(toFuncType(ctx, v, nil, nil), v)
+	case *ast.EnvExpr:
+		compileEnvExpr(ctx, v)
 	default:
 		log.Panicf("compileExpr failed: unknown - %T\n", v)
 	}
@@ -938,6 +955,10 @@ func compileBasicLit(ctx *blockCtx, v *ast.BasicLit) {
 	}
 }
 
+func invalidVal(cb *gogen.CodeBuilder) {
+	cb.Val(&gogen.Element{Type: types.Typ[types.Invalid]})
+}
+
 func basicLit(cb *gogen.CodeBuilder, v *ast.BasicLit) {
 	cb.Val(&goast.BasicLit{Kind: gotoken.Token(v.Kind), Value: v.Value}, v)
 }
diff --git a/doc/classfile.md b/doc/classfile.md
index 6b11b7a3..a9598609 100644
--- a/doc/classfile.md
+++ b/doc/classfile.md
@@ -25,54 +25,41 @@ Go+ introduces `classfile` to abstract domain knowledge.
 * Unit Test: [classfile: Unit Test](#classfile-unit-test)
 * Mechanism: [What's Classfile](#whats-classfile)
 
-Sound a bit abstract? Let's take web programming as an example. First let us initialize a hello project:
+Sound a bit abstract? Let's take web programming as an example. First let us create a file named [get.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_hello/get.yap) with the following content:
 
-```sh
-gop mod init hello
+```go
+html `<html><body>Hello, YAP!</body></html>`
 ```
 
-Then we have it reference a classfile called `yap` as the HTTP Web Framework:
+Execute the following commands:
 
 ```sh
+gop mod init hello
 gop get github.com/goplus/yap@latest
+gop mod tidy
+gop run .
 ```
 
-We can use it to implement a static file server:
-
-```coffee
-static "/foo", FS("public")
-static "/"    # Equivalent to static "/", FS("static")
+A simplest web program is running now. At this time, if you visit http://localhost:8080, you will get:
 
-run ":8080"
+```
+Hello, YAP!
 ```
 
-We can also add the ability to handle dynamic GET/POST requests:
+YAP uses filenames to define routes. `get.yap`'s route is `get "/"` (GET homepage), and `get_p_#id.yap`'s route is `get "/p/:id"` (In fact, the filename can also be `get_p_:id.yap`, but it is not recommended because `:` is not allowed to exist in filenames under Windows).
 
-```coffee
-static "/foo", FS("public")
-static "/"    # Equivalent to static "/", FS("static")
+Let's create a file named [get_p_#id.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_hello/get_p_%23id.yap) with the following content:
 
-get "/p/:id", ctx => {
-	ctx.json {
-		"id": ctx.param("id"),
-	}
+```coffee
+json {
+	"id": ${id},
 }
-
-run ":8080"
 ```
 
-Save this code to `hello_yap.gox` file and execute:
+Execute `gop run .` and visit http://localhost:8080/p/123, you will get:
 
-```sh
-mkdir -p yap/static yap/public    # Static resources can be placed in these directories
-gop mod tidy
-gop run .
 ```
-
-A simplest web program is running now. At this time, if you visit http://localhost:8080/p/123, you will get:
-
-```
-{"id":"123"}
+{"id": "123"}
 ```
 
 Why is `yap` so easy to use? How does it do it? Let us analyze the principles one by one.
@@ -187,151 +174,109 @@ If you want to run a subtest case, use `t.run`.
 
 ### yap: Yet Another Go/Go+ HTTP Web Framework
 
-This classfile has the file suffix `_yap.gox`. See [yap: Yet Another HTTP Web Framework](https://github.com/goplus/yap) for more details.
+This classfile has the file suffix `.yap`. See [yap: Yet Another HTTP Web Framework](https://github.com/goplus/yap) for more details.
 
 #### Router and Parameters
 
-demo in Go+ classfile ([hello_yap.gox](https://github.com/goplus/yap/blob/main/demo/classfile_hello/hello_yap.gox)):
+YAP uses filenames to define routes. `get.yap`'s route is `get "/"` (GET homepage), and `get_p_#id.yap`'s route is `get "/p/:id"` (In fact, the filename can also be `get_p_:id.yap`, but it is not recommended because `:` is not allowed to exist in filenames under Windows).
+
+Let's create a file named [get_p_#id.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_hello/get_p_%23id.yap) with the following content:
 
 ```coffee
-get "/p/:id", ctx => {
-	ctx.json {
-		"id": ctx.param("id"),
-	}
-}
-handle "/", ctx => {
-	ctx.html `<html><body>Hello, <a href="/p/123">Yap</a>!</body></html>`
+json {
+	"id": ${id},
 }
+```
 
-run ":8080"
+Execute `gop run .` and visit http://localhost:8080/p/123, you will get:
+
+```
+{"id": "123"}
 ```
 
-#### Static files
 
-Static files server demo in Go+ classfile ([staticfile_yap.gox](https://github.com/goplus/yap/blob/main/demo/classfile_static/staticfile_yap.gox)):
+#### YAP Template
+
+In most cases, we don't use the `html` directive to generate html pages, but use the `yap` template engine. See [get_p_#id.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_blog/get_p_%23id.yap):
 
 ```coffee
-static "/foo", FS("public")
-static "/"
+yap "article", {
+	"id": ${id},
+}
+```
 
-run ":8080"
+It means finding a template called `article` to render. See [yap/article_yap.html](https://github.com/goplus/yap/blob/main/demo/classfile2_blog/yap/article_yap.html):
+
+```html
+<html>
+<head><meta charset="utf-8"/></head>
+<body>Article {{.id}}</body>
+</html>
 ```
 
-#### YAP Template
+#### Run at specified address
 
-demo in Go+ classfile ([blog_yap.gox](https://github.com/goplus/yap/blob/main/demo/classfile_blog/blog_yap.gox), [article_yap.html](https://github.com/goplus/yap/blob/main/demo/classfile_blog/yap/article_yap.html)):
+By default the YAP server runs on `localhost:8080`, but you can change it in [main.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_blog/main.yap) file:
 
 ```coffee
-get "/p/:id", ctx => {
-	ctx.yap "article", {
-		"id": ctx.param("id"),
-	}
-}
-
-run ":8080"
+run ":8888"
 ```
 
 
-### yaptest: HTTP Test Framework
+#### Static files
 
-Suppose we have a web server named `foo` ([demo/foo/foo_yap.gox](https://github.com/goplus/yap/blob/main/ytest/demo/foo/foo_yap.gox)):
+Static files server demo ([main.yap](https://github.com/goplus/yap/blob/main/demo/classfile2_static/main.yap)):
 
-```go
-get "/p/:id", ctx => {
-	ctx.json {
-		"id": ctx.param("id"),
-	}
-}
+```coffee
+static "/foo", FS("public")
+static "/"
 
 run ":8080"
 ```
 
-Then we create a yaptest file ([demo/foo/foo_ytest.gox](https://github.com/goplus/yap/blob/main/ytest/demo/foo/foo_ytest.gox)):
 
-```go
-mock "foo.com", new(foo)
-
-run "test get /p/$id", => {
-	id := "123"
-	get "http://foo.com/p/${id}"
-	ret 200
-	json {
-		"id": id,
-	}
-}
-```
+### yaptest: HTTP Test Framework
 
-The directive `mock` creates the `foo` server by [mockhttp](https://pkg.go.dev/github.com/qiniu/x/mockhttp). Then we call the directive `run` to run a subtest.
+This classfile has the file suffix `_ytest.gox`.
 
-You can change the directive `mock` to `testServer` (see [demo/foo/bar_ytest.gox](https://github.com/goplus/yap/blob/main/ytest/demo/foo/bar_ytest.gox)), and keep everything else unchanged:
+Suppose we have a web server ([foo/get_p_#id.yap](https://github.com/goplus/yap/blob/main/ytest/demo/foo/get_p_%23id.yap)):
 
 ```go
-testServer "foo.com", new(foo)
-
-run "test get /p/$id", => {
-	id := "123"
-	get "http://foo.com/p/${id}"
-	ret 200
-	json {
-		"id": id,
-	}
+json {
+	"id": ${id},
 }
 ```
 
-The directive `testServer` creates the `foo` server by [net/http/httptest](https://pkg.go.dev/net/http/httptest#NewServer) and obtained a random port as the service address. Then it calls the directive [host](https://pkg.go.dev/github.com/goplus/yap/ytest#App.Host) to map the random service address to `foo.com`. This makes all other code no need to changed.
+Then we create a yaptest file ([foo/foo_ytest.gox](https://github.com/goplus/yap/blob/main/ytest/demo/foo/bar_ytest.gox)):
 
-We can change this example more complicated:
+```go
+mock "foo.com", new(AppV2)  // name of any YAP v2 web server is `AppV2`
 
-```coffee
-host "https://example.com", "http://localhost:8080"
-testauth := oauth2("...")
-
-run "urlWithVar", => {
-	id := "123"
-	get "https://example.com/p/${id}"
-	ret
-	echo "code:", resp.code
-	echo "body:", resp.body
+id := "123"
+get "http://foo.com/p/${id}"
+ret 200
+json {
+	"id": id,
 }
+```
 
-run "matchWithVar", => {
-	code := Var(int)
-	id := "123"
-	get "https://example.com/p/${id}"
-	ret code
-	echo "code:", code
-	match code, 200
-}
+The directive `mock` creates the web server by [mockhttp](https://pkg.go.dev/github.com/qiniu/x/mockhttp). Then we write test code directly.
 
-run "postWithAuth", => {
-	id := "123"
-	title := "title"
-	author := "author"
-	post "https://example.com/p/${id}"
-	auth testauth
-	json {
-		"title":  title,
-		"author": author,
-	}
-	ret 200 # match resp.code, 200
-	echo "body:", resp.body
-}
+You can change the directive `mock` to `testServer` (see [foo/bar_ytest.gox](https://github.com/goplus/yap/blob/main/ytest/demo/foo/bar_ytest.gox)), and keep everything else unchanged:
 
-run "matchJsonObject", => {
-	title := Var(string)
-	author := Var(string)
-	id := "123"
-	get "https://example.com/p/${id}"
-	ret 200
-	json {
-		"title":  title,
-		"author": author,
-	}
-	echo "title:", title
-	echo "author:", author
+```go
+testServer "foo.com", new(AppV2)
+
+id := "123"
+get "http://foo.com/p/${id}"
+ret 200
+json {
+	"id": id,
 }
 ```
 
+The directive `testServer` creates the web server by [net/http/httptest](https://pkg.go.dev/net/http/httptest#NewServer) and obtained a random port as the service address. Then it calls the directive [host](https://pkg.go.dev/github.com/goplus/yap/ytest#App.Host) to map the random service address to `foo.com`. This makes all other code no need to changed.
+
 For more details, see [yaptest - Go+ HTTP Test Framework](https://github.com/goplus/yap/blob/main/ytest).
 
 
diff --git a/go.mod b/go.mod
index f71d1939..4da48795 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.18
 require (
 	github.com/fsnotify/fsnotify v1.7.0
 	github.com/goplus/c2go v0.7.25
-	github.com/goplus/gogen v1.15.0
+	github.com/goplus/gogen v1.15.1
 	github.com/goplus/mod v0.13.9
 	github.com/qiniu/x v1.13.9
 	golang.org/x/tools v0.19.0
diff --git a/go.sum b/go.sum
index 5ac4a157..2f87c7a3 100644
--- a/go.sum
+++ b/go.sum
@@ -6,8 +6,8 @@ github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyT
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/goplus/c2go v0.7.25 h1:QvQfOwGVGKFYOTLry8i4p9U55jnIOQWeLsnSyFg0hhc=
 github.com/goplus/c2go v0.7.25/go.mod h1:e9oe4jDVhGFMJLEGmPSrVkLuXbLZAEmAu0/uD6fSz5E=
-github.com/goplus/gogen v1.15.0 h1:mQphpjiw/Fka46lEVnDSWwql3s6VF9VGjKc0EohlEYI=
-github.com/goplus/gogen v1.15.0/go.mod h1:92qEzVgv7y8JEFICWG9GvYI5IzfEkxYdsA1DbmnTkqk=
+github.com/goplus/gogen v1.15.1 h1:iz/fFpOeldjwmnjLzEdNsZF2mCf+sOHJavbAvV3o7sY=
+github.com/goplus/gogen v1.15.1/go.mod h1:92qEzVgv7y8JEFICWG9GvYI5IzfEkxYdsA1DbmnTkqk=
 github.com/goplus/mod v0.13.9 h1:B9zZoHi2AzMltTSOFqZNVjqGlSMlhhNTWwEzVqhTQzg=
 github.com/goplus/mod v0.13.9/go.mod h1:MibsLSftGmxaQq78YzUzNviyFwB9RtpMaoscufvEKH4=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
diff --git a/parser/parser.go b/parser/parser.go
index c1246138..75b92bdf 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -1266,6 +1266,7 @@ func (p *parser) parseParameters(scope *ast.Scope, ellipsisOk bool) *ast.FieldLi
 	if p.trace {
 		defer un(trace(p, "Parameters"))
 	}
+	_ = ellipsisOk
 
 	var params []*ast.Field
 	lparen := p.expect(token.LPAREN)
@@ -1774,6 +1775,9 @@ func (p *parser) parseOperand(lhs, allowTuple, allowCmd bool) (x ast.Expr, isTup
 			p.resolve(x)
 		}
 		return
+
+	case token.ENV:
+		return p.parseEnvExpr(), false
 	}
 
 	typ, result := p.tryIdentOrType(stateArrayTypeOrSliceLit, nil)
@@ -1794,6 +1798,23 @@ func (p *parser) parseOperand(lhs, allowTuple, allowCmd bool) (x ast.Expr, isTup
 	return &ast.BadExpr{From: pos, To: p.pos}, false
 }
 
+func (p *parser) parseEnvExpr() (ret *ast.EnvExpr) {
+	if p.trace {
+		defer un(trace(p, "EnvExpr"))
+	}
+	ret = &ast.EnvExpr{TokPos: p.pos}
+	p.next()
+	if p.tok == token.LBRACE { // ${name}
+		ret.Lbrace = p.pos
+		p.next()
+		ret.Name = p.parseIdent()
+		ret.Rbrace = p.expect(token.RBRACE)
+	} else { // $name
+		ret.Name = p.parseIdent()
+	}
+	return
+}
+
 func (p *parser) parseSelector(x ast.Expr) ast.Expr {
 	if p.trace {
 		defer un(trace(p, "Selector"))
@@ -2158,6 +2179,7 @@ func (p *parser) checkExpr(x ast.Expr) ast.Expr {
 	case *tupleExpr:
 		p.error(v.opening, msgTupleNotSupported)
 		x = &ast.BadExpr{From: v.opening, To: v.closing}
+	case *ast.EnvExpr:
 	default:
 		// all other nodes are not proper expressions
 		p.errorExpected(x.Pos(), "expression", 3)
@@ -2285,7 +2307,7 @@ L:
 			x = &ast.ErrWrapExpr{X: x, Tok: p.tok, TokPos: p.pos}
 			p.next()
 		default:
-			if allowCmd && p.isCmd(x) && p.checkCmd(x) {
+			if allowCmd && p.isCmd(x) && p.checkCmd() {
 				if lhs {
 					p.resolve(x)
 				}
@@ -2307,11 +2329,11 @@ func (p *parser) isCmd(x ast.Expr) bool {
 	return false
 }
 
-func (p *parser) checkCmd(x ast.Expr) bool {
+func (p *parser) checkCmd() bool {
 	switch p.tok {
 	case token.IDENT, token.DRARROW,
 		token.STRING, token.CSTRING, token.INT, token.FLOAT, token.IMAG, token.CHAR, token.RAT,
-		token.FUNC, token.GOTO, token.MAP, token.INTERFACE, token.CHAN, token.STRUCT:
+		token.FUNC, token.GOTO, 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
@@ -3321,7 +3343,7 @@ func (p *parser) parseStmt(allowCmd bool) (s ast.Stmt) {
 	case
 		// tokens that may start an expression
 		token.INT, token.FLOAT, token.IMAG, token.RAT, token.CHAR, token.STRING, token.CSTRING, token.FUNC, token.LPAREN, // operands
-		token.ADD, token.SUB, token.MUL, token.AND, token.XOR, token.ARROW, token.NOT, // unary operators
+		token.ADD, token.SUB, token.MUL, token.AND, token.XOR, token.ARROW, token.NOT, token.ENV, // unary operators
 		token.LBRACK, token.STRUCT, token.CHAN, token.INTERFACE: // composite types
 		allowCmd = false
 		fallthrough
diff --git a/printer/nodes.go b/printer/nodes.go
index f6943369..c1628863 100644
--- a/printer/nodes.go
+++ b/printer/nodes.go
@@ -1101,6 +1101,14 @@ func (p *printer) expr1(expr ast.Expr, prec1, depth int) {
 			p.expr(x.Expr3)
 		}
 
+	case *ast.EnvExpr:
+		p.print(token.ENV)
+		if x.HasBrace() {
+			p.print(token.LBRACE, x.Name, token.RBRACE)
+		} else {
+			p.print(x.Name)
+		}
+
 	default:
 		log.Fatalf("unreachable %T\n", x)
 	}
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 926e337a..f84fa245 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -1002,6 +1002,8 @@ scanAgain:
 		case '?':
 			tok = token.QUESTION
 			insertSemi = true
+		case '$':
+			tok = token.ENV
 		default:
 			// next reports unexpected BOMs - don't repeat
 			if ch != bom {
diff --git a/token/token.go b/token/token.go
index 1bd7f2a9..f13a2f22 100644
--- a/token/token.go
+++ b/token/token.go
@@ -141,6 +141,9 @@ const (
 	additional_beg
 	TILDE // additional tokens, handled in an ad-hoc manner
 	additional_end
+	additional_end2
+
+	ENV = additional_end2 // ${name}
 
 	CSTRING  = literal_beg  // C"Hello"
 	RAT      = literal_end  // 123.5r
@@ -228,6 +231,7 @@ var tokens = [...]string{
 	DRARROW:   "=>",
 	SRARROW:   "->",
 	BIDIARROW: "<>",
+	ENV:       "$",
 	TILDE:     "~",
 
 	BREAK:    "break",
@@ -335,7 +339,7 @@ func (tok Token) IsLiteral() bool {
 // IsOperator returns true for tokens corresponding to operators and
 // delimiters; it returns false otherwise.
 func (tok Token) IsOperator() bool {
-	return operator_beg <= tok && tok <= operator_end || tok >= additional_beg && tok <= additional_end
+	return operator_beg <= tok && tok <= operator_end || tok >= additional_beg && tok <= additional_end2
 }
 
 // IsKeyword returns true for tokens corresponding to keywords;


Test patch:
diff --git a/cl/builtin_test.go b/cl/builtin_test.go
index d8666ce1..5837b440 100644
--- a/cl/builtin_test.go
+++ b/cl/builtin_test.go
@@ -252,12 +252,15 @@ func TestErrParseTypeEmbedName(t *testing.T) {
 	parseTypeEmbedName(&ast.StructType{})
 }
 
-func TestGmxMainFunc(t *testing.T) {
-	gmxMainFunc(nil, &pkgCtx{
+func TestGmxCheckProjs(t *testing.T) {
+	_, multi := gmxCheckProjs(nil, &pkgCtx{
 		projs: map[string]*gmxProject{
 			".a": {}, ".b": {},
 		},
-	}, false)
+	})
+	if !multi {
+		t.Fatal("gmxCheckProjs: not multi?")
+	}
 }
 
 func TestNodeInterp(t *testing.T) {
diff --git a/cl/compile_spx_test.go b/cl/compile_spx_test.go
index bca91346..a51deb70 100644
--- a/cl/compile_spx_test.go
+++ b/cl/compile_spx_test.go
@@ -92,6 +92,9 @@ func gopSpxTestExConf(t *testing.T, name string, conf *cl.Config, gmx, spxcode,
 		defer cl.SetDisableRecover(false)
 
 		fs := memfs.TwoFiles("/foo", spxfile, spxcode, gmxfile, gmx)
+		if gmxfile == "" {
+			fs = memfs.SingleFile("/foo", spxfile, spxcode)
+		}
 		pkgs, err := parser.ParseFSDir(gblFset, fs, "/foo", spxParserConf())
 		if err != nil {
 			scanner.PrintError(os.Stderr, err)
@@ -135,6 +138,34 @@ func gopSpxErrorTestEx(t *testing.T, msg, gmx, spxcode, gmxfile, spxfile string)
 	}
 }
 
+func TestSpxNoGame(t *testing.T) {
+	gopSpxTestEx(t, ``, `
+`, `package main
+
+import "github.com/goplus/gop/cl/internal/spx"
+
+type Kai struct {
+	spx.Sprite
+	*MyGame
+}
+type MyGame struct {
+	*spx.MyGame
+}
+
+func (this *Kai) Classfname() string {
+	return "Kai"
+}
+func (this *Kai) Main() {
+}
+func (this *MyGame) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
+func main() {
+	new(MyGame).Main()
+}
+`, "", "Kai.tspx")
+}
+
 func TestSpxError(t *testing.T) {
 	gopSpxErrorTestEx(t, `Game.tgmx:6:2: userScore redeclared
 	Game.tgmx:5:2 other declaration of userScore`, `
@@ -197,8 +228,8 @@ func (this *Game) onInit() {
 func (this *Game) MainEntry() {
 	this.InitGameApp()
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(Game))
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *Kai) onMsg(msg string) {
 	for {
@@ -209,6 +240,11 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -253,9 +289,54 @@ func (this *index) onInit() {
 	this.bar()
 	fmt.Println("Hi")
 }
+func (this *index) MainEntry() {
+}
+func (this *index) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (this *bar) Classfname() string {
 	return "bar"
 }
+func (this *bar) Main() {
+}
+func main() {
+	new(index).Main()
+}
+`)
+}
+
+func TestEnvOp(t *testing.T) {
+	gopSpxTest(t, `
+echo ${PATH}, $id
+`, ``, `package main
+
+import (
+	"fmt"
+	"github.com/goplus/gop/cl/internal/spx"
+)
+
+type bar struct {
+	spx.Sprite
+	*index
+}
+type index struct {
+	*spx.MyGame
+}
+
+func (this *index) MainEntry() {
+	fmt.Println(this.Gop_Env("PATH"), this.Gop_Env("id"))
+}
+func (this *index) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
+func (this *bar) Classfname() string {
+	return "bar"
+}
+func (this *bar) Main() {
+}
+func main() {
+	new(index).Main()
+}
 `)
 }
 
@@ -281,12 +362,17 @@ type index struct {
 func (this *index) MainEntry() {
 	fmt.Println(strconv.Itoa(this.Gop_Env("PATH")))
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(index))
+func (this *index) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *bar) Classfname() string {
 	return "bar"
 }
+func (this *bar) Main() {
+}
+func main() {
+	new(index).Main()
+}
 `)
 }
 
@@ -320,12 +406,17 @@ func (this *index) MainEntry() {
 		this.Gop_Exec("ls", "-l")
 	})
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(index))
+func (this *index) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *bar) Classfname() string {
 	return "bar"
 }
+func (this *bar) Main() {
+}
+func main() {
+	new(index).Main()
+}
 `)
 }
 
@@ -365,6 +456,11 @@ func (this *Game) onInit() {
 	spx.TestIntValue = 1
 	x := math.Round(1.2)
 }
+func (this *Game) MainEntry() {
+}
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (this *bar) onInit() {
 	this.SetCostume("kai-a")
 	this.Play("recordingWhere")
@@ -374,6 +470,11 @@ func (this *bar) onInit() {
 func (this *bar) Classfname() string {
 	return "bar"
 }
+func (this *bar) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "bar.tspx")
 }
 
@@ -417,6 +518,11 @@ func (this *Game) onInit() {
 	spx.Gopt_Sprite_Clone__0(this.Kai)
 	this.Broadcast__0("msg1")
 }
+func (this *Game) MainEntry() {
+}
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (this *Kai) onInit() {
 	this.a = 1
 }
@@ -426,6 +532,11 @@ func (this *Kai) onCloned() {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -463,8 +574,8 @@ var x float64 = spx.Rand__1(1.2)
 func (this *index) MainEntry() {
 	spx.Gopt_MyGame_Run(this, "hzip://open.qiniu.us/weather/res.zip")
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(index))
+func (this *index) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *Kai) Main() {
 	fmt.Println("Hi")
@@ -472,6 +583,9 @@ func (this *Kai) Main() {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func main() {
+	new(index).Main()
+}
 `, "index.tgmx", "Kai.tspx")
 }
 
@@ -504,8 +618,8 @@ type Game struct {
 func (this *Game) MainEntry() {
 	this.Run()
 }
-func main() {
-	spx3.Gopt_Game_Main(new(Game), new(Kai))
+func (this *Game) Main() {
+	spx3.Gopt_Game_Main(this, new(Kai))
 }
 func (this *Kai) Main(_gop_arg0 string) {
 	this.Sprite.Main(_gop_arg0)
@@ -514,6 +628,9 @@ func (this *Kai) Main(_gop_arg0 string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func main() {
+	new(Game).Main()
+}
 `, "main_spx.gox", "Kai_spx.gox")
 }
 
@@ -544,12 +661,18 @@ func (this *Game) MainEntry() {
 	b := new(spx3.Sprite)
 	fmt.Println(b.Name())
 }
-func main() {
-	spx3.Gopt_Game_Main(new(Game), new(Kai))
+func (this *Game) Main() {
+	spx3.Gopt_Game_Main(this, new(Kai))
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main(_gop_arg0 string) {
+	this.Sprite.Main(_gop_arg0)
+}
+func main() {
+	new(Game).Main()
+}
 `, "main_spx.gox", "Kai_spx.gox")
 }
 
@@ -577,14 +700,19 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	fmt.Println("Hi")
 }
-func main() {
-	new(Game).Main()
+func (this *Game) Main() {
+	(*spx2.Game).Main(&this.Game)
 }
 func (this *Kai) onMsg(msg string) {
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.t2gmx", "Kai.t2spx")
 }
 
@@ -612,14 +740,19 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	fmt.Println("Hi, Sprite2")
 }
-func main() {
-	new(Game).Main()
+func (this *Game) Main() {
+	(*spx2.Game).Main(&this.Game)
 }
 func (this *Kai) onMsg(msg string) {
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.t2gmx", "Kai.t2spx2")
 }
 
@@ -654,6 +787,8 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
 `, "Dog_t3spx.gox", "Kai.t3spx2")
 }
 
@@ -678,12 +813,17 @@ type Kai struct {
 
 func (this *Game) MainEntry() {
 }
-func main() {
-	new(Game).Main()
+func (this *Game) Main() {
+	(*spx2.Game).Main(&this.Game)
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.t2gmx", "Kai.t2spx", "")
 	gopSpxTestExConf(t, "OnlyGmx", &conf, `
 var (
@@ -705,12 +845,17 @@ type Kai struct {
 
 func (this *Game) MainEntry() {
 }
-func main() {
-	new(Game).Main()
+func (this *Game) Main() {
+	(*spx2.Game).Main(&this.Game)
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.t2gmx", "Kai.t2spx", "")
 
 	gopSpxTestExConf(t, "KaiAndGmx", &conf, `
@@ -745,8 +890,8 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	fmt.Println("Hi")
 }
-func main() {
-	new(Game).Main()
+func (this *Game) Main() {
+	(*spx2.Game).Main(&this.Game)
 }
 func (this *Kai) Main() {
 	fmt.Println("Hello")
@@ -756,6 +901,9 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func main() {
+	new(Game).Main()
+}
 `, "Game.t2gmx", "Kai.t2spx", "")
 }
 
@@ -788,6 +936,11 @@ func (this *Game) onInit() {
 		spx.SchedNow()
 	}
 }
+func (this *Game) MainEntry() {
+}
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (this *Kai) onMsg(msg string) {
 	for {
 		spx.Sched()
@@ -797,6 +950,11 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -852,6 +1010,11 @@ func (this *Game) onInit() {
 	spx.Gopt_Sprite_Clone__0(this.Kai)
 	this.Broadcast__0("msg1")
 }
+func (this *Game) MainEntry() {
+}
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (this *Kai) onInit() {
 	this.a = 1
 	spx.Gopt_Sprite_Clone__0(this)
@@ -864,6 +1027,11 @@ func (this *Kai) onCloned() {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -896,14 +1064,19 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	this.SendMessage("Hi")
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(Game))
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *Kai) onMsg(msg string) {
 }
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -932,8 +1105,8 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	fmt.Println("Hi")
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(Game))
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *Kai) onMsg(msg string) {
 	this.Position().Add__0(100, 200)
@@ -941,6 +1114,11 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -982,8 +1160,8 @@ type Kai struct {
 func (this *Game) MainEntry() {
 	fmt.Println("hi")
 }
-func main() {
-	spx.Gopt_MyGame_Main(new(Game))
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
 }
 func (this *Kai) onMsg(msg string) {
 	fmt.Println(msg)
@@ -1003,6 +1181,11 @@ func (this *Kai) onMsg(msg string) {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func (this *Kai) Main() {
+}
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
@@ -1074,6 +1257,11 @@ func (this *Game) onInit() {
 	spx.Gopt_Sprite_OnKey__1(this.Kai, "hello", func(key string) {
 	})
 }
+func (this *Game) MainEntry() {
+}
+func (this *Game) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func (p *Mesh) Name() string {
 	return "hello"
 }
@@ -1107,6 +1295,9 @@ func (this *Kai) Main() {
 func (this *Kai) Classfname() string {
 	return "Kai"
 }
+func main() {
+	new(Game).Main()
+}
 `, "Game.tgmx", "Kai.tspx")
 }
 
diff --git a/cl/error_msg_test.go b/cl/error_msg_test.go
index cc27271a..5c268cd9 100644
--- a/cl/error_msg_test.go
+++ b/cl/error_msg_test.go
@@ -509,6 +509,15 @@ func (p *[]byte) foo() {
 `)
 }
 
+func TestErrEnvOp(t *testing.T) {
+	codeErrorTest(t, `bar.gop:2:6: operator $name undefined`, `
+echo ${name}
+`)
+	codeErrorTest(t, `bar.gop:2:1: operator $id undefined`, `
+$id
+`)
+}
+
 func TestErrStringLit(t *testing.T) {
 	codeErrorTest(t, `bar.gop:2:9: [].string undefined (type []interface{} has no field or method string)`, `
 echo "${[]}"
diff --git a/parser/_testdata/envop1/envop.gop b/parser/_testdata/envop1/envop.gop
new file mode 100644
index 00000000..842986b5
--- /dev/null
+++ b/parser/_testdata/envop1/envop.gop
@@ -0,0 +1,1 @@
+echo ${name}, $id
diff --git a/parser/_testdata/envop1/parser.expect b/parser/_testdata/envop1/parser.expect
new file mode 100644
index 00000000..cc99fef5
--- /dev/null
+++ b/parser/_testdata/envop1/parser.expect
@@ -0,0 +1,30 @@
+package main
+
+file envop.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.EnvExpr:
+                  Name:
+                    ast.Ident:
+                      Name: name
+                ast.EnvExpr:
+                  Name:
+                    ast.Ident:
+                      Name: id
diff --git a/parser/_testdata/envop2/envop.gop b/parser/_testdata/envop2/envop.gop
new file mode 100644
index 00000000..3429d651
--- /dev/null
+++ b/parser/_testdata/envop2/envop.gop
@@ -0,0 +1,2 @@
+${name}
+echo {"id": $id}
diff --git a/parser/_testdata/envop2/parser.expect b/parser/_testdata/envop2/parser.expect
new file mode 100644
index 00000000..86833c59
--- /dev/null
+++ b/parser/_testdata/envop2/parser.expect
@@ -0,0 +1,40 @@
+package main
+
+file envop.gop
+noEntrypoint
+ast.FuncDecl:
+  Name:
+    ast.Ident:
+      Name: main
+  Type:
+    ast.FuncType:
+      Params:
+        ast.FieldList:
+  Body:
+    ast.BlockStmt:
+      List:
+        ast.ExprStmt:
+          X:
+            ast.EnvExpr:
+              Name:
+                ast.Ident:
+                  Name: name
+        ast.ExprStmt:
+          X:
+            ast.CallExpr:
+              Fun:
+                ast.Ident:
+                  Name: echo
+              Args:
+                ast.CompositeLit:
+                  Elts:
+                    ast.KeyValueExpr:
+                      Key:
+                        ast.BasicLit:
+                          Kind: STRING
+                          Value: "id"
+                      Value:
+                        ast.EnvExpr:
+                          Name:
+                            ast.Ident:
+                              Name: id
diff --git a/x/build/build_test.go b/x/build/build_test.go
index fed51751..2d26bfe9 100644
--- a/x/build/build_test.go
+++ b/x/build/build_test.go
@@ -380,8 +380,11 @@ type MyGame struct {
 func (this *MyGame) MainEntry() {
 	fmt.Println("hi")
 }
+func (this *MyGame) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
 func main() {
-	spx.Gopt_MyGame_Main(new(MyGame))
+	new(MyGame).Main()
 }
 `)
 	gopClTestEx(t, "Cat.tspx", `println "hi"`, `package main
@@ -393,17 +396,24 @@ import (
 
 type Cat struct {
 	spx.Sprite
+	*MyGame
 }
-
-func main() {
-	spx.Gopt_MyGame_Main(new(spx.MyGame))
+type MyGame struct {
+	spx.MyGame
 }
+
 func (this *Cat) Main() {
 	fmt.Println("hi")
 }
 func (this *Cat) Classfname() string {
 	return "Cat"
 }
+func (this *MyGame) Main() {
+	spx.Gopt_MyGame_Main(this)
+}
+func main() {
+	new(MyGame).Main()
+}
 `)
 }
 
diff --git a/x/typesutil/info_test.go b/x/typesutil/info_test.go
index cda5e112..07b8dfdd 100644
--- a/x/typesutil/info_test.go
+++ b/x/typesutil/info_test.go
@@ -1622,56 +1622,60 @@ func onCloned() {
 }
 `, `== types ==
 000:  0: 0 | "Kai"               *ast.BasicLit                  | value   : untyped string = "Kai" | constant
-001:  0: 0 | Kai                 *ast.Ident                     | type    : main.Kai | type
-002:  0: 0 | string              *ast.Ident                     | type    : string | type
-003:  3: 4 | int                 *ast.Ident                     | type    : int | type
-004:  6:11 | struct {
+001:  0: 0 | *MyGame             *ast.StarExpr                  | type    : *main.MyGame | type
+002:  0: 0 | Kai                 *ast.Ident                     | type    : main.Kai | type
+003:  0: 0 | MyGame              *ast.Ident                     | type    : main.MyGame | type
+004:  0: 0 | string              *ast.Ident                     | type    : string | type
+005:  3: 4 | int                 *ast.Ident                     | type    : int | type
+006:  6:11 | struct {
 	x int
 	y int
 } *ast.StructType                | type    : struct{x int; y int} | type
-005:  7: 4 | int                 *ast.Ident                     | type    : int | type
-006:  8: 4 | int                 *ast.Ident                     | type    : int | type
-007: 12: 2 | a                   *ast.Ident                     | var     : int | variable
-008: 12: 6 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
-009: 13: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}) | value
-010: 14: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}, data interface{}) | value
-011: 14: 2 | clone info{1, 2}    *ast.CallExpr                  | void    : () | no value
-012: 14: 8 | info                *ast.Ident                     | type    : main.info | type
-013: 14: 8 | info{1, 2}          *ast.CompositeLit              | value   : main.info | value
-014: 14:13 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
-015: 14:15 | 2                   *ast.BasicLit                  | value   : untyped int = 2 | constant
-016: 15: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}, data interface{}) | value
-017: 15: 2 | clone &info{1, 2}   *ast.CallExpr                  | void    : () | no value
-018: 15: 8 | &info{1, 2}         *ast.UnaryExpr                 | value   : *main.info | value
-019: 15: 9 | info                *ast.Ident                     | type    : main.info | type
-020: 15: 9 | info{1, 2}          *ast.CompositeLit              | value   : main.info | value
-021: 15:14 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
-022: 15:16 | 2                   *ast.BasicLit                  | value   : untyped int = 2 | constant
-023: 19: 2 | say                 *ast.Ident                     | value   : func(msg string, secs ...float64) | value
-024: 19: 2 | say("Hi")           *ast.CallExpr                  | void    : () | no value
-025: 19: 6 | "Hi"                *ast.BasicLit                  | value   : untyped string = "Hi" | constant
+007:  7: 4 | int                 *ast.Ident                     | type    : int | type
+008:  8: 4 | int                 *ast.Ident                     | type    : int | type
+009: 12: 2 | a                   *ast.Ident                     | var     : int | variable
+010: 12: 6 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
+011: 13: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}) | value
+012: 14: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}, data interface{}) | value
+013: 14: 2 | clone info{1, 2}    *ast.CallExpr                  | void    : () | no value
+014: 14: 8 | info                *ast.Ident                     | type    : main.info | type
+015: 14: 8 | info{1, 2}          *ast.CompositeLit              | value   : main.info | value
+016: 14:13 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
+017: 14:15 | 2                   *ast.BasicLit                  | value   : untyped int = 2 | constant
+018: 15: 2 | clone               *ast.Ident                     | value   : func(sprite interface{}, data interface{}) | value
+019: 15: 2 | clone &info{1, 2}   *ast.CallExpr                  | void    : () | no value
+020: 15: 8 | &info{1, 2}         *ast.UnaryExpr                 | value   : *main.info | value
+021: 15: 9 | info                *ast.Ident                     | type    : main.info | type
+022: 15: 9 | info{1, 2}          *ast.CompositeLit              | value   : main.info | value
+023: 15:14 | 1                   *ast.BasicLit                  | value   : untyped int = 1 | constant
+024: 15:16 | 2                   *ast.BasicLit                  | value   : untyped int = 2 | constant
+025: 19: 2 | say                 *ast.Ident                     | value   : func(msg string, secs ...float64) | value
+026: 19: 2 | say("Hi")           *ast.CallExpr                  | void    : () | no value
+027: 19: 6 | "Hi"                *ast.BasicLit                  | value   : untyped string = "Hi" | constant
 == defs ==
 000:  0: 0 | Classfname          | func (*main.Kai).Classfname() string
-001:  0: 0 | this                | var this *main.Kai
-002:  3: 2 | a                   | field a int
-003:  6: 6 | info                | type main.info struct{x int; y int}
-004:  7: 2 | x                   | field x int
-005:  8: 2 | y                   | field y int
-006: 11: 6 | onInit              | func (*main.Kai).onInit()
-007: 18: 6 | onCloned            | func (*main.Kai).onCloned()
+001:  0: 0 | Main                | func (*main.Kai).Main()
+002:  0: 0 | this                | var this *main.Kai
+003:  3: 2 | a                   | field a int
+004:  6: 6 | info                | type main.info struct{x int; y int}
+005:  7: 2 | x                   | field x int
+006:  8: 2 | y                   | field y int
+007: 11: 6 | onInit              | func (*main.Kai).onInit()
+008: 18: 6 | onCloned            | func (*main.Kai).onCloned()
 == uses ==
-000:  0: 0 | Kai                 | type main.Kai struct{github.com/goplus/gop/cl/internal/spx.Sprite; a int}
-001:  0: 0 | string              | type string
-002:  3: 4 | int                 | type int
-003:  7: 4 | int                 | type int
-004:  8: 4 | int                 | type int
-005: 12: 2 | a                   | field a int
-006: 13: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__0(sprite interface{})
-007: 14: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__1(sprite interface{}, data interface{})
-008: 14: 8 | info                | type main.info struct{x int; y int}
-009: 15: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__1(sprite interface{}, data interface{})
-010: 15: 9 | info                | type main.info struct{x int; y int}
-011: 19: 2 | say                 | func (*github.com/goplus/gop/cl/internal/spx.Sprite).Say(msg string, secs ...float64)`)
+000:  0: 0 | Kai                 | type main.Kai struct{github.com/goplus/gop/cl/internal/spx.Sprite; *main.MyGame; a int}
+001:  0: 0 | MyGame              | type main.MyGame struct{*github.com/goplus/gop/cl/internal/spx.MyGame}
+002:  0: 0 | string              | type string
+003:  3: 4 | int                 | type int
+004:  7: 4 | int                 | type int
+005:  8: 4 | int                 | type int
+006: 12: 2 | a                   | field a int
+007: 13: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__0(sprite interface{})
+008: 14: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__1(sprite interface{}, data interface{})
+009: 14: 8 | info                | type main.info struct{x int; y int}
+010: 15: 2 | clone               | func github.com/goplus/gop/cl/internal/spx.Gopt_Sprite_Clone__1(sprite interface{}, data interface{})
+011: 15: 9 | info                | type main.info struct{x int; y int}
+012: 19: 2 | say                 | func (*github.com/goplus/gop/cl/internal/spx.Sprite).Say(msg string, secs ...float64)`)
 }
 
 func TestScopesInfo(t *testing.T) {