Repository: swaggo/swag
Pull request #1824 (2024-06-09 17:43:43)

Added suport for parsing comments inside of function bodies
**Describe the PR**
I have updated `ParseRouterAPIInfo()` to parse API info from comments inside of function bodies if the new CLI flag `ParseFuncBody` is used.

**Relation issue**
Implements idea detailed here: https://github.com/swaggo/swag/issues/1809

**Additional context**
This change is opt-in only as the new CLI flag  `ParseFuncBody` must be used to get the new functionality. This will have no impact on existing users.



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..0cd11fe 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)
diff --git a/parser.go b/parser.go
index 8a47d00..ff40c32 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.
@@ -1016,30 +1019,28 @@ 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
 			}
 		}
 	}
@@ -1047,6 +1048,28 @@ DeclsLoop:
 	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
+		}
+	}
+
+	return nil
+}
+
 func refRouteMethodOp(item *spec.PathItem, method string) (op **spec.Operation) {
 	switch method {
 	case http.MethodGet:


Test patch:
diff --git a/parser_test.go b/parser_test.go
index 0479a75..c92f3db 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
@@ -3395,14 +3395,14 @@ func TestParseFunctionScopedStructRequestResponseJSON(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
@@ -4123,3 +4123,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)
+}