Repository: zeromicro/go-zero (go)

Issue:
There is an error after upgrading goctl to 1.6.1
goctl升级到1.6.1之后，生成api代码时报错：

duplicate declaration: game.api 236:13 duplicate handler expression

game.api
   ```go
   
// 隐藏不相关的代码

// ------------------------------------
// Services
// ------------------------------------
//--------------------------------用户收藏游戏表--------------------------------
@server (
	prefix:     v1/game
	group:      game_like
	jwt:        Auth
	middleware: CheckAuth
)
service game-api {
	@handler Find
	get /game_like (GetGameLikeRequest) returns (GetGameLikeResponse)

	@handler Show
	get /game_like/:id (IDRequest) returns (GameLikeInfoResponse)

	@handler Add
	post /game_like (AddGameLikeRequest)

	@handler Update
	put /game_like/:id (UpdateGameLikeRequest)

	@handler Delete
	delete /game_like/:id (IDRequest)
}

//--------------------------------用户--------------------------------
@server (
	prefix:     v1/game
	group:      user
	jwt:        Auth
	middleware: CheckAuth
)
service game-api {
	@handler Find
	get /user (GetUserRequest) returns (GetUserResponse)

	@handler Show
	get /user/:id (IDRequest) returns (UserInfoResponse)

	@handler Add
	post /user (AddUserRequest)

	@handler Update
	put /user/:id (UpdateUserRequest)

	@handler Delete
	delete /user/:id (IDRequest)
}

   ```

执行goctl创建api代码

```
goctl api go --api game.api --dir .

```

提示错误
duplicate declaration: game.api 236:13 duplicate handler expression
duplicate declaration: game.api 239:13 duplicate handler expression
duplicate declaration: game.api 242:13 duplicate handler expression
duplicate declaration: game.api 245:13 duplicate handler expression
duplicate declaration: game.api 248:13 duplicate handler expression

虽然Find方法是重复的，但是他们的group不一样，生成的代码在不同的目录，因此这里其实没有问题的

并且在 goctl 以前的版本中运行没有问题

现在的版本goctl 1.6.1   go 1.20

谢谢！


Pull request description:
fix(goctl)/new parser
1. fix #3833 
2. fix parser error in case root path `/`

Patch:
diff --git a/tools/goctl/pkg/parser/api/parser/api.go b/tools/goctl/pkg/parser/api/parser/api.go
index 10b5a1a4..6fc5584b 100644
--- a/tools/goctl/pkg/parser/api/parser/api.go
+++ b/tools/goctl/pkg/parser/api/parser/api.go
@@ -12,6 +12,11 @@ import (
 	"github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/token"
 )
 
+const (
+	atServerGroupKey  = "group:"
+	atServerPrefixKey = "prefix:"
+)
+
 // API is the parsed api file.
 type API struct {
 	Filename      string
@@ -127,10 +132,13 @@ func (api *API) checkServiceStmt() error {
 		} else {
 			serviceName[name] = name
 		}
-		var group = api.getAtServerValue(v.AtServerStmt, "prefix")
+		var (
+			prefix = api.getAtServerValue(v.AtServerStmt, atServerPrefixKey)
+			group  = api.getAtServerValue(v.AtServerStmt, atServerGroupKey)
+		)
 		for _, item := range v.Routes {
-			handlerChecker.check(item.AtHandler.Name)
-			path := fmt.Sprintf("[%s]:%s", group, item.Route.Format(""))
+			handlerChecker.checkNodeWithPrefix(group, item.AtHandler.Name)
+			path := fmt.Sprintf("[%s]:%s", prefix, item.Route.Format(""))
 			pathChecker.check(ast.NewTokenNode(token.Token{
 				Text:     path,
 				Position: item.Route.Pos(),
diff --git a/tools/goctl/pkg/parser/api/parser/filter.go b/tools/goctl/pkg/parser/api/parser/filter.go
index 166ce07e..322260a0 100644
--- a/tools/goctl/pkg/parser/api/parser/filter.go
+++ b/tools/goctl/pkg/parser/api/parser/filter.go
@@ -1,6 +1,8 @@
 package parser
 
 import (
+	"fmt"
+
 	"github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/ast"
 	"github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/placeholder"
 )
@@ -21,6 +23,17 @@ func (b *filterBuilder) check(nodes ...*ast.TokenNode) {
 	}
 }
 
+func (b *filterBuilder) checkNodeWithPrefix(prefix string, nodes ...*ast.TokenNode) {
+	for _, node := range nodes {
+		joinText:=fmt.Sprintf("%s/%s",prefix,node.Token.Text)
+		if _, ok := b.m[joinText]; ok {
+			b.errorManager.add(ast.DuplicateStmtError(node.Pos(), "duplicate "+b.checkExprName))
+		} else {
+			b.m[joinText] = placeholder.PlaceHolder
+		}
+	}
+}
+
 func (b *filterBuilder) error() error {
 	return b.errorManager.error()
 }
diff --git a/tools/goctl/pkg/parser/api/parser/parser.go b/tools/goctl/pkg/parser/api/parser/parser.go
index 5876697e..5cbc2943 100644
--- a/tools/goctl/pkg/parser/api/parser/parser.go
+++ b/tools/goctl/pkg/parser/api/parser/parser.go
@@ -385,6 +385,9 @@ func (p *Parser) parsePathExpr() *ast.PathExpr {
 		}
 
 		values = append(values, p.curTok)
+		if p.peekTokenIs(token.LPAREN, token.Returns, token.AT_DOC, token.AT_HANDLER, token.SEMICOLON, token.RBRACE){
+			break
+		}
 		if p.notExpectPeekTokenGotComment(p.curTokenNode().PeekFirstLeadingComment(), token.COLON, token.IDENT, token.INT) {
 			return nil
 		}
diff --git a/tools/goctl/pkg/parser/api/scanner/scanner.go b/tools/goctl/pkg/parser/api/scanner/scanner.go
index 87767c17..0b86a79d 100644
--- a/tools/goctl/pkg/parser/api/scanner/scanner.go
+++ b/tools/goctl/pkg/parser/api/scanner/scanner.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 
 	"github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/token"
+	"github.com/zeromicro/go-zero/tools/goctl/util/pathx"
 )
 
 const (
@@ -650,7 +651,7 @@ func NewScanner(filename string, src interface{}) (*Scanner, error) {
 }
 
 func readData(filename string, src interface{}) ([]byte, error) {
-	if strings.HasSuffix(filename, ".api") {
+	if strings.HasSuffix(filename, ".api") &&pathx.FileExists(filename){
 		data, err := os.ReadFile(filename)
 		if err != nil {
 			return nil, err


Test patch:
diff --git a/tools/goctl/model/sql/gen/gen_test.go b/tools/goctl/model/sql/gen/gen_test.go
index 676e044c..721a7e38 100644
--- a/tools/goctl/model/sql/gen/gen_test.go
+++ b/tools/goctl/model/sql/gen/gen_test.go
@@ -192,7 +192,10 @@ func Test_genPublicModel(t *testing.T) {
 	code, err := g.genModelCustom(*tables[0], false)
 	assert.NoError(t, err)
 	assert.True(t, strings.Contains(code, "package model"))
-	assert.True(t, strings.Contains(code, "TestUserModel interface {\n\t\ttestUserModel\n\t}\n"))
+	assert.True(t, strings.Contains(code, `	TestUserModel interface {
+		testUserModel
+		withSession(session sqlx.Session) TestUserModel
+	}`))
 	assert.True(t, strings.Contains(code, "customTestUserModel struct {\n\t\t*defaultTestUserModel\n\t}\n"))
 	assert.True(t, strings.Contains(code, "func NewTestUserModel(conn sqlx.SqlConn) TestUserModel {"))
 }
diff --git a/tools/goctl/pkg/parser/api/parser/parser_test.go b/tools/goctl/pkg/parser/api/parser/parser_test.go
index 9fac7a5f..c37b03a7 100644
--- a/tools/goctl/pkg/parser/api/parser/parser_test.go
+++ b/tools/goctl/pkg/parser/api/parser/parser_test.go
@@ -521,6 +521,19 @@ func TestParser_Parse_service(t *testing.T) {
 				LBrace: ast.NewTokenNode(token.Token{Type: token.LBRACE, Text: "{"}),
 				RBrace: ast.NewTokenNode(token.Token{Type: token.RBRACE, Text: "}"}),
 				Routes: []*ast.ServiceItemStmt{
+					{
+						AtHandler: &ast.AtHandlerStmt{
+							AtHandler: ast.NewTokenNode(token.Token{Type: token.AT_HANDLER, Text: "@handler"}),
+							Name:      ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "root"}),
+						},
+						Route: &ast.RouteStmt{
+							Method: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "get"}),
+							Path: &ast.PathExpr{Value: ast.NewTokenNode(token.Token{
+								Type: token.PATH,
+								Text: "/",
+							})},
+						},
+					},
 					{
 						AtHandler: &ast.AtHandlerStmt{
 							AtHandler: ast.NewTokenNode(token.Token{Type: token.AT_HANDLER, Text: "@handler"}),
@@ -557,6 +570,93 @@ func TestParser_Parse_service(t *testing.T) {
 				LBrace: ast.NewTokenNode(token.Token{Type: token.LBRACE, Text: "{"}),
 				RBrace: ast.NewTokenNode(token.Token{Type: token.RBRACE, Text: "}"}),
 				Routes: []*ast.ServiceItemStmt{
+					{
+						AtDoc: &ast.AtDocLiteralStmt{
+							AtDoc: ast.NewTokenNode(token.Token{Type: token.AT_DOC, Text: "@doc"}),
+							Value: ast.NewTokenNode(token.Token{Type: token.STRING, Text: `"bar"`}),
+						},
+						AtHandler: &ast.AtHandlerStmt{
+							AtHandler: ast.NewTokenNode(token.Token{Type: token.AT_HANDLER, Text: "@handler"}),
+							Name:      ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "root"}),
+						},
+						Route: &ast.RouteStmt{
+							Method: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "get"}),
+							Path: &ast.PathExpr{
+								Value: ast.NewTokenNode(token.Token{
+									Type: token.PATH,
+									Text: "/",
+								}),
+							},
+							Request: &ast.BodyStmt{
+								LParen: ast.NewTokenNode(token.Token{Type: token.LPAREN, Text: "("}),
+								Body: &ast.BodyExpr{
+									Value: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "Foo"}),
+								},
+								RParen: ast.NewTokenNode(token.Token{Type: token.RPAREN, Text: ")"}),
+							},
+						},
+					},
+					{
+						AtDoc: &ast.AtDocLiteralStmt{
+							AtDoc: ast.NewTokenNode(token.Token{Type: token.AT_DOC, Text: "@doc"}),
+							Value: ast.NewTokenNode(token.Token{Type: token.STRING, Text: `"bar"`}),
+						},
+						AtHandler: &ast.AtHandlerStmt{
+							AtHandler: ast.NewTokenNode(token.Token{Type: token.AT_HANDLER, Text: "@handler"}),
+							Name:      ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "root2"}),
+						},
+						Route: &ast.RouteStmt{
+							Method: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "get"}),
+							Path: &ast.PathExpr{
+								Value: ast.NewTokenNode(token.Token{
+									Type: token.PATH,
+									Text: "/",
+								}),
+							},
+							Returns: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "returns"}),
+							Response: &ast.BodyStmt{
+								LParen: ast.NewTokenNode(token.Token{Type: token.LPAREN, Text: "("}),
+								Body: &ast.BodyExpr{
+									Value: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "Foo"}),
+								},
+								RParen: ast.NewTokenNode(token.Token{Type: token.RPAREN, Text: ")"}),
+							},
+						},
+					},
+					{
+						AtDoc: &ast.AtDocLiteralStmt{
+							AtDoc: ast.NewTokenNode(token.Token{Type: token.AT_DOC, Text: "@doc"}),
+							Value: ast.NewTokenNode(token.Token{Type: token.STRING, Text: `"bar"`}),
+						},
+						AtHandler: &ast.AtHandlerStmt{
+							AtHandler: ast.NewTokenNode(token.Token{Type: token.AT_HANDLER, Text: "@handler"}),
+							Name:      ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "root3"}),
+						},
+						Route: &ast.RouteStmt{
+							Method: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "get"}),
+							Path: &ast.PathExpr{
+								Value: ast.NewTokenNode(token.Token{
+									Type: token.PATH,
+									Text: "/",
+								}),
+							},
+							Request: &ast.BodyStmt{
+								LParen: ast.NewTokenNode(token.Token{Type: token.LPAREN, Text: "("}),
+								Body: &ast.BodyExpr{
+									Value: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "Foo"}),
+								},
+								RParen: ast.NewTokenNode(token.Token{Type: token.RPAREN, Text: ")"}),
+							},
+							Returns: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "returns"}),
+							Response: &ast.BodyStmt{
+								LParen: ast.NewTokenNode(token.Token{Type: token.LPAREN, Text: "("}),
+								Body: &ast.BodyExpr{
+									Value: ast.NewTokenNode(token.Token{Type: token.IDENT, Text: "Bar"}),
+								},
+								RParen: ast.NewTokenNode(token.Token{Type: token.RPAREN, Text: ")"}),
+							},
+						},
+					},
 					{
 						AtDoc: &ast.AtDocLiteralStmt{
 							AtDoc: ast.NewTokenNode(token.Token{Type: token.AT_DOC, Text: "@doc"}),
diff --git a/tools/goctl/pkg/parser/api/parser/testdata/example.api b/tools/goctl/pkg/parser/api/parser/testdata/example.api
index 2a1f8d3c..97ff1dd8 100644
--- a/tools/goctl/pkg/parser/api/parser/testdata/example.api
+++ b/tools/goctl/pkg/parser/api/parser/testdata/example.api
@@ -93,6 +93,12 @@ type (
 	NestDemoResp2 {
 *Nest `json:"nest"`
 	}
+	RootReq{
+
+	}
+	RootResp{
+
+	}
 )
 
 @server (
@@ -124,6 +130,23 @@ service example {
 	prefix:     /v1/v2
 	timeout:    100ms
 )
+service example {
+	@doc (
+		desc: "path demo"
+	)
+	@handler postPath
+	post /example/path (PostPathReq) returns (PostPathResp)
+
+	@handler root
+	post / (RootReq) returns (RootResp)
+}
+
+@server (
+	group:      path2
+	middleware: Path
+	prefix:     /v1/v3
+	timeout:    100ms
+)
 service example {
 	@doc (
 		desc: "path demo"
diff --git a/tools/goctl/pkg/parser/api/parser/testdata/service_test.api b/tools/goctl/pkg/parser/api/parser/testdata/service_test.api
index f967e731..7d9323d3 100644
--- a/tools/goctl/pkg/parser/api/parser/testdata/service_test.api
+++ b/tools/goctl/pkg/parser/api/parser/testdata/service_test.api
@@ -1,4 +1,7 @@
 service foo {
+    @handler root
+    get /
+
     @handler bar
     get /ping
 
@@ -7,6 +10,18 @@ service foo {
 }
 
 service bar {
+    @doc "bar"
+    @handler root
+    get / (Foo)
+
+    @doc "bar"
+    @handler root2
+    get / returns (Foo)
+
+    @doc "bar"
+    @handler root3
+    get / (Foo) returns (Bar)
+
     @doc "bar"
     @handler foo
     get /foo/:bar (Foo)