Repository: swaggo/swag
Pull request #1830 (2024-06-17 09:57:24)

Fixes Issue 1829
**Describe the PR**
Fixes the issue described in https://github.com/swaggo/swag/issues/1829

**Relation issue**
https://github.com/swaggo/swag/issues/1829

**Additional context**
The previous logic could select the wrong markdown file if one tag was a substring of another. E.g. "objects" and "my-special-objects".


sdghchj: Hi, you have to fix the failed tests，TestParseDescriptionMarkdown and there may be others.

Patch:
diff --git a/README.md b/README.md
index 066ba58..b453c41 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,7 @@ OPTIONS:
    --templateDelims value, --td value     Provide custom delimiters for Go template generation. The format is leftDelim,rightDelim. For example: "[[,]]"
    --collectionFormat value, --cf value   Set default collection format (default: "csv")
    --state value                          Initial state for the state machine (default: ""), @HostState in root file, @State in other files
+   --parseFuncBody                        Parse API info within body of functions in go files, disabled by default (default: false)
    --help, -h                             show help (default: false)
 ```
 
@@ -931,7 +932,7 @@ func GetPosts(w http.ResponseWriter, r *http.Request) {
 	_ = web.GenericNestedResponse[types.Post]{}
 }
 ```
-See [this file](https://github.com/swaggo/swag/blob/master/testdata/generics_nested/api/api.go) for more details 
+See [this file](https://github.com/swaggo/swag/blob/master/testdata/generics_nested/api/api.go) for more details
 and other examples.
 
 ### Change the default Go Template action delimiters
diff --git a/cmd/swag/main.go b/cmd/swag/main.go
index 9848bfd..fd12c7f 100644
--- a/cmd/swag/main.go
+++ b/cmd/swag/main.go
@@ -18,6 +18,7 @@ const (
 	searchDirFlag            = "dir"
 	excludeFlag              = "exclude"
 	generalInfoFlag          = "generalInfo"
+	pipeFlag                 = "pipe"
 	propertyStrategyFlag     = "propertyStrategy"
 	outputFlag               = "output"
 	outputTypesFlag          = "outputTypes"
@@ -41,6 +42,7 @@ const (
 	collectionFormatFlag     = "collectionFormat"
 	packagePrefixFlag        = "packagePrefix"
 	stateFlag                = "state"
+	parseFuncBodyFlag        = "parseFuncBody"
 )
 
 var initFlags = []cli.Flag{
@@ -179,6 +181,11 @@ var initFlags = []cli.Flag{
 		Value: "",
 		Usage: "Set host state for swagger.json",
 	},
+	&cli.BoolFlag{
+		Name: parseFuncBodyFlag,
+		// Value: false,
+		Usage: "Parse API info within body of functions in go files, disabled by default (default: false)",
+	},
 }
 
 func initAction(ctx *cli.Context) error {
@@ -195,11 +202,17 @@ func initAction(ctx *cli.Context) error {
 	if ctx.IsSet(templateDelimsFlag) {
 		delims := strings.Split(ctx.String(templateDelimsFlag), ",")
 		if len(delims) != 2 {
-			return fmt.Errorf("exactly two template delimiters must be provided, comma separated")
+			return fmt.Errorf(
+				"exactly two template delimiters must be provided, comma separated",
+			)
 		} else if delims[0] == delims[1] {
 			return fmt.Errorf("template delimiters must be different")
 		}
-		leftDelim, rightDelim = strings.TrimSpace(delims[0]), strings.TrimSpace(delims[1])
+		leftDelim, rightDelim = strings.TrimSpace(
+			delims[0],
+		), strings.TrimSpace(
+			delims[1],
+		)
 	}
 
 	outputTypes := strings.Split(ctx.String(outputTypesFlag), ",")
@@ -211,9 +224,14 @@ func initAction(ctx *cli.Context) error {
 		logger = log.New(io.Discard, "", log.LstdFlags)
 	}
 
-	collectionFormat := swag.TransToValidCollectionFormat(ctx.String(collectionFormatFlag))
+	collectionFormat := swag.TransToValidCollectionFormat(
+		ctx.String(collectionFormatFlag),
+	)
 	if collectionFormat == "" {
-		return fmt.Errorf("not supported %s collectionFormat", ctx.String(collectionFormat))
+		return fmt.Errorf(
+			"not supported %s collectionFormat",
+			ctx.String(collectionFormat),
+		)
 	}
 
 	var pdv = ctx.Int(parseDependencyLevelFlag)
@@ -249,6 +267,7 @@ func initAction(ctx *cli.Context) error {
 		CollectionFormat:    collectionFormat,
 		PackagePrefix:       ctx.String(packagePrefixFlag),
 		State:               ctx.String(stateFlag),
+		ParseFuncBody:       ctx.Bool(parseFuncBodyFlag),
 	})
 }
 
@@ -269,6 +288,11 @@ func main() {
 			Aliases: []string{"f"},
 			Usage:   "format swag comments",
 			Action: func(c *cli.Context) error {
+
+				if c.Bool(pipeFlag) {
+					return format.New().Run(os.Stdin, os.Stdout)
+				}
+
 				searchDir := c.String(searchDirFlag)
 				excludeDir := c.String(excludeFlag)
 				mainFile := c.String(generalInfoFlag)
@@ -296,6 +320,12 @@ func main() {
 					Value:   "main.go",
 					Usage:   "Go file path in which 'swagger general API Info' is written",
 				},
+				&cli.BoolFlag{
+					Name:    "pipe",
+					Aliases: []string{"p"},
+					Value:   false,
+					Usage:   "Read from stdin, write to stdout.",
+				},
 			},
 		},
 	}
diff --git a/format/format.go b/format/format.go
index 0b9dbfc..01761ed 100644
--- a/format/format.go
+++ b/format/format.go
@@ -3,6 +3,7 @@ package format
 import (
 	"bytes"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
@@ -81,7 +82,8 @@ func (f *Format) visit(path string, fileInfo os.FileInfo, err error) error {
 
 func (f *Format) excludeDir(path string) bool {
 	return f.exclude[path] ||
-		filepath.Base(path)[0] == '.' && len(filepath.Base(path)) > 1 // exclude hidden folders
+		filepath.Base(path)[0] == '.' &&
+			len(filepath.Base(path)) > 1 // exclude hidden folders
 }
 
 func (f *Format) excludeFile(path string) bool {
@@ -127,3 +129,20 @@ func write(path string, contents []byte) error {
 	}
 	return os.Rename(f.Name(), path)
 }
+
+// Run the format on src and write the result to dst.
+func (f *Format) Run(src io.Reader, dst io.Writer) error {
+	contents, err := io.ReadAll(src)
+	if err != nil {
+		return err
+	}
+	result, err := f.formatter.Format("", contents)
+	if err != nil {
+		return err
+	}
+	r := bytes.NewReader(result)
+	if _, err := io.Copy(dst, r); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/gen/gen.go b/gen/gen.go
index 43cf73e..417d51c 100644
--- a/gen/gen.go
+++ b/gen/gen.go
@@ -146,6 +146,9 @@ type Config struct {
 
 	// State set host state
 	State string
+
+	// ParseFuncBody whether swag should parse api info inside of funcs
+	ParseFuncBody bool
 }
 
 // Build builds swagger json file  for given searchDir and mainAPIFile. Returns json.
@@ -213,6 +216,7 @@ func (g *Gen) Build(config *Config) error {
 	p.ParseInternal = config.ParseInternal
 	p.RequiredByDefault = config.RequiredByDefault
 	p.HostState = config.State
+	p.ParseFuncBody = config.ParseFuncBody
 
 	if err := p.ParseAPIMultiSearchDir(searchDirs, config.MainAPIFile, config.ParseDepth); err != nil {
 		return err
diff --git a/packages.go b/packages.go
index 466db86..274fc30 100644
--- a/packages.go
+++ b/packages.go
@@ -93,7 +93,7 @@ func (pkgDefs *PackagesDefinitions) RangeFiles(handle func(info *AstFileInfo) er
 	for _, info := range pkgDefs.files {
 		// ignore package path prefix with 'vendor' or $GOROOT,
 		// because the router info of api will not be included these files.
-		if strings.HasPrefix(info.PackagePath, "vendor") || (runtime.GOROOT() != "" && strings.HasPrefix(info.Path, runtime.GOROOT())) {
+		if strings.HasPrefix(info.PackagePath, "vendor") || (runtime.GOROOT() != "" && strings.HasPrefix(info.Path, runtime.GOROOT()+string(filepath.Separator))) {
 			continue
 		}
 		sortedFiles = append(sortedFiles, info)
@@ -192,6 +192,7 @@ func (pkgDefs *PackagesDefinitions) parseFunctionScopedTypesFromFile(astFile *as
 	for _, astDeclaration := range astFile.Decls {
 		funcDeclaration, ok := astDeclaration.(*ast.FuncDecl)
 		if ok && funcDeclaration.Body != nil {
+			functionScopedTypes := make(map[string]*TypeSpecDef)
 			for _, stmt := range funcDeclaration.Body.List {
 				if declStmt, ok := (stmt).(*ast.DeclStmt); ok {
 					if genDecl, ok := (declStmt.Decl).(*ast.GenDecl); ok && genDecl.Tok == token.TYPE {
@@ -212,12 +213,28 @@ func (pkgDefs *PackagesDefinitions) parseFunctionScopedTypesFromFile(astFile *as
 									}
 								}
 
+								fullName := typeSpecDef.TypeName()
+								if structType, ok := typeSpecDef.TypeSpec.Type.(*ast.StructType); ok {
+									for _, field := range structType.Fields.List {
+										if idt, ok := field.Type.(*ast.Ident); ok && !IsGolangPrimitiveType(idt.Name) {
+											if functype, ok := functionScopedTypes[idt.Name]; ok {
+												idt.Name = functype.TypeName()
+											}
+										}
+										if art, ok := field.Type.(*ast.ArrayType); ok {
+											if idt, ok := art.Elt.(*ast.Ident); ok && !IsGolangPrimitiveType(idt.Name) {
+												if functype, ok := functionScopedTypes[idt.Name]; ok {
+													idt.Name = functype.TypeName()
+												}
+											}
+										}
+									}
+								}
+
 								if pkgDefs.uniqueDefinitions == nil {
 									pkgDefs.uniqueDefinitions = make(map[string]*TypeSpecDef)
 								}
 
-								fullName := typeSpecDef.TypeName()
-
 								anotherTypeDef, ok := pkgDefs.uniqueDefinitions[fullName]
 								if ok {
 									if anotherTypeDef == nil {
@@ -234,6 +251,7 @@ func (pkgDefs *PackagesDefinitions) parseFunctionScopedTypesFromFile(astFile *as
 									}
 								} else {
 									pkgDefs.uniqueDefinitions[fullName] = typeSpecDef
+									functionScopedTypes[typeSpec.Name.Name] = typeSpecDef
 								}
 
 								if pkgDefs.packages[typeSpecDef.PkgPath] == nil {
diff --git a/parser.go b/parser.go
index 8a47d00..2c694be 100644
--- a/parser.go
+++ b/parser.go
@@ -179,6 +179,9 @@ type Parser struct {
 
 	// HostState is the state of the host
 	HostState string
+
+	// ParseFuncBody whether swag should parse api info inside of funcs
+	ParseFuncBody bool
 }
 
 // FieldParserFactory create FieldParser.
@@ -885,6 +888,13 @@ func isGeneralAPIComment(comments []string) bool {
 }
 
 func getMarkdownForTag(tagName string, dirPath string) ([]byte, error) {
+	if tagName == "" {
+		// this happens when parsing the @description.markdown attribute
+		// it will be called properly another time with tagName="api"
+		// so we can safely return an empty byte slice here
+		return make([]byte, 0), nil
+	}
+
 	dirEntries, err := os.ReadDir(dirPath)
 	if err != nil {
 		return nil, err
@@ -897,11 +907,12 @@ func getMarkdownForTag(tagName string, dirPath string) ([]byte, error) {
 
 		fileName := entry.Name()
 
-		if !strings.Contains(fileName, ".md") {
-			continue
+		expectedFileName := tagName
+		if !strings.HasSuffix(tagName, ".md") {
+			expectedFileName = tagName + ".md"
 		}
 
-		if strings.Contains(fileName, tagName) {
+		if fileName == expectedFileName {
 			fullPath := filepath.Join(dirPath, fileName)
 
 			commentInfo, err := os.ReadFile(fullPath)
@@ -1016,31 +1027,51 @@ func matchExtension(extensionToMatch string, comments []*ast.Comment) (match boo
 
 // ParseRouterAPIInfo parses router api info for given astFile.
 func (parser *Parser) ParseRouterAPIInfo(fileInfo *AstFileInfo) error {
-DeclsLoop:
-	for _, astDescription := range fileInfo.File.Decls {
-		if (fileInfo.ParseFlag & ParseOperations) == ParseNone {
-			continue
+	if (fileInfo.ParseFlag & ParseOperations) == ParseNone {
+		return nil
+	}
+
+	// parse File.Comments instead of File.Decls.Doc if ParseFuncBody flag set to "true"
+	if parser.ParseFuncBody {
+		for _, astComments := range fileInfo.File.Comments {
+			if astComments.List != nil {
+				if err := parser.parseRouterAPIInfoComment(astComments.List, fileInfo); err != nil {
+					return err
+				}
+			}
 		}
+
+		return nil
+	}
+
+	for _, astDescription := range fileInfo.File.Decls {
 		astDeclaration, ok := astDescription.(*ast.FuncDecl)
 		if ok && astDeclaration.Doc != nil && astDeclaration.Doc.List != nil {
-			if parser.matchTags(astDeclaration.Doc.List) &&
-				matchExtension(parser.parseExtension, astDeclaration.Doc.List) {
-				// for per 'function' comment, create a new 'Operation' object
-				operation := NewOperation(parser, SetCodeExampleFilesDirectory(parser.codeExampleFilesDir))
-				for _, comment := range astDeclaration.Doc.List {
-					err := operation.ParseComment(comment.Text, fileInfo.File)
-					if err != nil {
-						return fmt.Errorf("ParseComment error in file %s :%+v", fileInfo.Path, err)
-					}
-					if operation.State != "" && operation.State != parser.HostState {
-						continue DeclsLoop
-					}
-				}
-				err := processRouterOperation(parser, operation)
-				if err != nil {
-					return err
-				}
+			if err := parser.parseRouterAPIInfoComment(astDeclaration.Doc.List, fileInfo); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func (parser *Parser) parseRouterAPIInfoComment(comments []*ast.Comment, fileInfo *AstFileInfo) error {
+	if parser.matchTags(comments) && matchExtension(parser.parseExtension, comments) {
+		// for per 'function' comment, create a new 'Operation' object
+		operation := NewOperation(parser, SetCodeExampleFilesDirectory(parser.codeExampleFilesDir))
+		for _, comment := range comments {
+			err := operation.ParseComment(comment.Text, fileInfo.File)
+			if err != nil {
+				return fmt.Errorf("ParseComment error in file %s :%+v", fileInfo.Path, err)
 			}
+			if operation.State != "" && operation.State != parser.HostState {
+				return nil
+			}
+		}
+		err := processRouterOperation(parser, operation)
+		if err != nil {
+			return err
 		}
 	}
 


Test patch:
diff --git a/parser_test.go b/parser_test.go
index 0479a75..c2bc754 100644
--- a/parser_test.go
+++ b/parser_test.go
@@ -3363,14 +3363,14 @@ func TestParseFunctionScopedStructDefinition(t *testing.T) {
 	src := `
 package main
 
-// @Param request body main.Fun.request true "query params" 
+// @Param request body main.Fun.request true "query params"
 // @Success 200 {object} main.Fun.response
 // @Router /test [post]
 func Fun()  {
 	type request struct {
 		Name string
 	}
-	
+
 	type response struct {
 		Name string
 		Child string
@@ -3389,7 +3389,7 @@ func Fun()  {
 	assert.True(t, ok)
 }
 
-func TestParseFunctionScopedStructRequestResponseJSON(t *testing.T) {
+func TestParseFunctionScopedComplexStructDefinition(t *testing.T) {
 	t.Parallel()
 
 	src := `
@@ -3403,6 +3403,49 @@ func Fun()  {
 		Name string
 	}
 	
+	type grandChild struct {
+		Name string
+	}
+
+	type child struct {
+		GrandChild grandChild
+	}
+
+	type response struct {
+		Children 	[]child
+	}
+}
+`
+	p := New()
+	_ = p.packages.ParseFile("api", "api/api.go", src, ParseAll)
+	_, err := p.packages.ParseTypes()
+	assert.NoError(t, err)
+
+	err = p.packages.RangeFiles(p.ParseRouterAPIInfo)
+	assert.NoError(t, err)
+
+	_, ok := p.swagger.Definitions["main.Fun.response"]
+	assert.True(t, ok)
+	_, ok = p.swagger.Definitions["main.Fun.child"]
+	assert.True(t, ok)
+	_, ok = p.swagger.Definitions["main.Fun.grandChild"]
+	assert.True(t, ok)
+}
+
+func TestParseFunctionScopedStructRequestResponseJSON(t *testing.T) {
+	t.Parallel()
+
+	src := `
+package main
+
+// @Param request body main.Fun.request true "query params"
+// @Success 200 {object} main.Fun.response
+// @Router /test [post]
+func Fun()  {
+	type request struct {
+		Name string
+	}
+
 	type response struct {
 		Name string
 		Child string
@@ -3474,6 +3517,130 @@ func Fun()  {
 	assert.Equal(t, expected, string(b))
 }
 
+func TestParseFunctionScopedComplexStructRequestResponseJSON(t *testing.T) {
+	t.Parallel()
+
+	src := `
+package main
+
+type PublicChild struct {
+	Name string
+}	
+
+// @Param request body main.Fun.request true "query params" 
+// @Success 200 {object} main.Fun.response
+// @Router /test [post]
+func Fun()  {
+	type request struct {
+		Name string
+	}
+	
+	type grandChild struct {
+		Name string
+	}
+
+	type child struct {
+		GrandChild grandChild
+	}
+
+	type response struct {
+		Children 	[]child
+	    PublicChild PublicChild
+	}
+}
+`
+	expected := `{
+    "info": {
+        "contact": {}
+    },
+    "paths": {
+        "/test": {
+            "post": {
+                "parameters": [
+                    {
+                        "description": "query params",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/main.Fun.request"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/main.Fun.response"
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "definitions": {
+        "main.Fun.child": {
+            "type": "object",
+            "properties": {
+                "grandChild": {
+                    "$ref": "#/definitions/main.Fun.grandChild"
+                }
+            }
+        },
+        "main.Fun.grandChild": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        },
+        "main.Fun.request": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        },
+        "main.Fun.response": {
+            "type": "object",
+            "properties": {
+                "children": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/main.Fun.child"
+                    }
+                },
+                "publicChild": {
+                    "$ref": "#/definitions/main.PublicChild"
+                }
+            }
+        },
+        "main.PublicChild": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        }
+    }
+}`
+
+	p := New()
+	_ = p.packages.ParseFile("api", "api/api.go", src, ParseAll)
+
+	_, err := p.packages.ParseTypes()
+	assert.NoError(t, err)
+
+	err = p.packages.RangeFiles(p.ParseRouterAPIInfo)
+	assert.NoError(t, err)
+
+	b, _ := json.MarshalIndent(p.swagger, "", "    ")
+	assert.Equal(t, expected, string(b))
+}
+
 func TestPackagesDefinitions_CollectAstFileInit(t *testing.T) {
 	t.Parallel()
 
@@ -4123,3 +4290,66 @@ func TestParser_skipPackageByPrefix(t *testing.T) {
 	assert.False(t, parser.skipPackageByPrefix("github.com/swaggo/swag/cmd"))
 	assert.False(t, parser.skipPackageByPrefix("github.com/swaggo/swag/gen"))
 }
+
+func TestParser_ParseRouterApiInFuncBody(t *testing.T) {
+	t.Parallel()
+
+	src := `
+package test
+
+func Test(){
+    // @Router /api/{id} [get]
+    _ = func() {
+	}
+}
+`
+	p := New()
+	p.ParseFuncBody = true
+	err := p.packages.ParseFile("api", "api/api.go", src, ParseAll)
+	assert.NoError(t, err)
+
+	err = p.packages.RangeFiles(p.ParseRouterAPIInfo)
+	assert.NoError(t, err)
+
+	ps := p.swagger.Paths.Paths
+
+	val, ok := ps["/api/{id}"]
+
+	assert.True(t, ok)
+	assert.NotNil(t, val.Get)
+}
+
+func TestParser_ParseRouterApiInfoInAndOutFuncBody(t *testing.T) {
+	t.Parallel()
+
+	src := `
+package test
+
+// @Router /api/outside [get]
+func otherRoute(){
+}
+
+func Test(){
+    // @Router /api/inside [get]
+    _ = func() {
+	}
+}
+`
+	p := New()
+	p.ParseFuncBody = true
+	err := p.packages.ParseFile("api", "api/api.go", src, ParseAll)
+	assert.NoError(t, err)
+
+	err = p.packages.RangeFiles(p.ParseRouterAPIInfo)
+	assert.NoError(t, err)
+
+	ps := p.swagger.Paths.Paths
+
+	val1, ok := ps["/api/outside"]
+	assert.True(t, ok)
+	assert.NotNil(t, val1.Get)
+
+	val2, ok := ps["/api/inside"]
+	assert.True(t, ok)
+	assert.NotNil(t, val2.Get)
+}
diff --git a/testdata/simple/api/api.go b/testdata/simple/api/api.go
index 85a7fa4..8c32057 100644
--- a/testdata/simple/api/api.go
+++ b/testdata/simple/api/api.go
@@ -138,3 +138,15 @@ func GetPet6FunctionScopedResponse() {
 		Name string
 	}
 }
+
+// @Success 200 {object}  api.GetPet6FunctionScopedComplexResponse.response "ok"
+// @Router /GetPet6FunctionScopedComplexResponse [get]
+func GetPet6FunctionScopedComplexResponse() {
+	type pet struct {
+		Name string
+	}
+
+	type response struct {
+		Pets []pet
+	}
+}
diff --git a/testdata/simple/expected.json b/testdata/simple/expected.json
index b3f6426..ad2adf2 100644
--- a/testdata/simple/expected.json
+++ b/testdata/simple/expected.json
@@ -101,6 +101,18 @@
         }
       }
     },
+    "/GetPet6FunctionScopedComplexResponse": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "ok",
+            "schema": {
+              "$ref": "#/definitions/api.GetPet6FunctionScopedComplexResponse.response"
+            }
+          }
+        }
+      }
+    },
     "/GetPet6FunctionScopedResponse": {
       "get": {
         "responses": {
@@ -401,6 +413,25 @@
     }
   },
   "definitions": {
+    "api.GetPet6FunctionScopedComplexResponse.pet": {
+      "type": "object",
+      "properties": {
+        "Name": {
+          "type": "string"
+        }
+      }
+    },
+    "api.GetPet6FunctionScopedComplexResponse.response": {
+      "type": "object",
+      "properties": {
+        "Pets": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/api.GetPet6FunctionScopedComplexResponse.pet"
+          }
+        }
+      }
+    },
     "api.GetPet6FunctionScopedResponse.response": {
       "type": "object",
       "properties": {