From fb83b4d97887543ac7808a29707d4805096dec1a Mon Sep 17 00:00:00 2001 From: Atte Kojo Date: Sat, 16 Apr 2022 20:41:47 +0300 Subject: [PATCH 1/3] chore: rename formater -> formatter --- format/format.go | 7 ++++-- formatter.go | 26 ++++------------------- formatter_test.go | 54 +++++++++++++++++++++-------------------------- 3 files changed, 33 insertions(+), 54 deletions(-) diff --git a/format/format.go b/format/format.go index e881d9d04..03d4d02ae 100644 --- a/format/format.go +++ b/format/format.go @@ -25,6 +25,9 @@ type Config struct { func (f *Fmt) Build(config *Config) error { log.Println("Formating code.... ") - - return swag.NewFormatter().FormatAPI(config.SearchDir, config.Excludes, config.MainFile) + formatter := swag.NewFormatter() + if err := formatter.FormatAPI(config.SearchDir, config.Excludes, config.MainFile); err != nil { + return err + } + return nil } diff --git a/formatter.go b/formatter.go index 0b14e99d8..dc5c62494 100644 --- a/formatter.go +++ b/formatter.go @@ -20,7 +20,7 @@ import ( const splitTag = "&*" -// Formatter implements a formater for Go source files. +// Formatter implements a formatter for Go source files. type Formatter struct { // debugging output goes here debug Debugger @@ -31,31 +31,13 @@ type Formatter struct { mainFile string } -// Formater creates a new formatter. -type Formater struct { - *Formatter -} - -// NewFormater Deprecated: Use NewFormatter instead. -func NewFormater() *Formater { - formatter := Formater{ - Formatter: NewFormatter(), - } - - formatter.debug.Printf("warining: NewFormater is deprecated. use NewFormatter instead") - - return &formatter -} - -// NewFormatter create a new formater instance. +// NewFormatter create a new formatter instance. func NewFormatter() *Formatter { - formatter := Formatter{ - mainFile: "", + formatter := &Formatter{ debug: log.New(os.Stdout, "", log.LstdFlags), excludes: make(map[string]struct{}), } - - return &formatter + return formatter } // FormatAPI format the swag comment. diff --git a/formatter_test.go b/formatter_test.go index b29110536..0f3c06825 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/agiledragon/gomonkey/v2" - "github.com/otiai10/copy" "github.com/stretchr/testify/assert" ) @@ -29,19 +28,12 @@ const ( MainFile = "main.go" ) -func TestNewFormater(t *testing.T) { - formatterTimeMachine() - formater := NewFormater() - - assert.NotEmpty(t, formater.Formatter) -} - func TestFormatter_FormatAPI(t *testing.T) { t.Run("Format Test", func(t *testing.T) { formatterTimeMachine() formatter := NewFormatter() - assert.NoError(t, formatter.FormatAPI(SearchDir, Excludes, MainFile)) - + err := formatter.FormatAPI(SearchDir, Excludes, MainFile) + assert.NoError(t, err) parsedFile, err := ioutil.ReadFile("./testdata/format_test/api/api.go") assert.NoError(t, err) @@ -55,17 +47,18 @@ func TestFormatter_FormatAPI(t *testing.T) { mainFile, err := ioutil.ReadFile("./testdata/format_dst/main.go") assert.NoError(t, err) assert.Equal(t, parsedMainFile, mainFile) - formatterTimeMachine() }) t.Run("TestWrongSearchDir", func(t *testing.T) { t.Parallel() - assert.Error(t, NewFormatter().FormatAPI("/dir_not_have", "", "")) + formatter := NewFormatter() + err := formatter.FormatAPI("/dir_not_have", "", "") + assert.Error(t, err) }) t.Run("TestWithMonkeyFilepathAbs", func(t *testing.T) { - formater := NewFormatter() + formatter := NewFormatter() errFilePath := fmt.Errorf("file path error ") patches := gomonkey.ApplyFunc(filepath.Abs, func(_ string) (string, error) { @@ -73,12 +66,13 @@ func TestFormatter_FormatAPI(t *testing.T) { }) defer patches.Reset() - assert.Equal(t, formater.FormatAPI(SearchDir, Excludes, MainFile), errFilePath) + err := formatter.FormatAPI(SearchDir, Excludes, MainFile) + assert.Equal(t, err, errFilePath) formatterTimeMachine() }) t.Run("TestWithMonkeyFormatMain", func(t *testing.T) { - formater := NewFormatter() + formatter := NewFormatter() var s *Formatter errFormatMain := fmt.Errorf("main format error ") @@ -87,12 +81,13 @@ func TestFormatter_FormatAPI(t *testing.T) { }) defer patches.Reset() - assert.Equal(t, formater.FormatAPI(SearchDir, Excludes, MainFile), errFormatMain) + err := formatter.FormatAPI(SearchDir, Excludes, MainFile) + assert.Equal(t, err, errFormatMain) formatterTimeMachine() }) t.Run("TestWithMonkeyFormatFile", func(t *testing.T) { - formater := NewFormatter() + formatter := NewFormatter() var s *Formatter errFormatFile := fmt.Errorf("file format error ") @@ -101,7 +96,8 @@ func TestFormatter_FormatAPI(t *testing.T) { }) defer patches.Reset() - assert.Equal(t, formater.FormatAPI(SearchDir, Excludes, MainFile), fmt.Errorf("ParseFile error:%s", errFormatFile)) + err := formatter.FormatAPI(SearchDir, Excludes, MainFile) + assert.Equal(t, err, fmt.Errorf("ParseFile error:%s", errFormatFile)) formatterTimeMachine() }) } @@ -109,8 +105,8 @@ func TestFormatter_FormatAPI(t *testing.T) { func TestFormatter_FormatMain(t *testing.T) { t.Run("TestWrongMainPath", func(t *testing.T) { t.Parallel() - formater := NewFormatter() - err := formater.FormatMain("/dir_not_have/main.go") + formatter := NewFormatter() + err := formatter.FormatMain("/dir_not_have/main.go") assert.Error(t, err) }) } @@ -118,8 +114,8 @@ func TestFormatter_FormatMain(t *testing.T) { func TestFormatter_FormatFile(t *testing.T) { t.Run("TestWrongFilePath", func(t *testing.T) { t.Parallel() - formater := NewFormatter() - err := formater.FormatFile("/dir_not_have/api.go") + formatter := NewFormatter() + err := formatter.FormatFile("/dir_not_have/api.go") assert.Error(t, err) }) } @@ -137,17 +133,15 @@ func Test_writeFormattedComments(t *testing.T) { }) } -func TestFormater_visit(t *testing.T) { - formater := NewFormatter() +func TestFormatter_visit(t *testing.T) { + formatter := NewFormatter() - err := formater.visit("./testdata/test_test.go", &mockFS{}, nil) + err := formatter.visit("./testdata/test_test.go", &mockFS{}, nil) assert.NoError(t, err) - - err = formater.visit("/testdata/api.md", &mockFS{}, nil) + err = formatter.visit("/testdata/api.md", &mockFS{}, nil) assert.NoError(t, err) - - formater.mainFile = "main.go" - err = formater.visit("/testdata/main.go", &mockFS{}, nil) + formatter.mainFile = "main.go" + err = formatter.visit("/testdata/main.go", &mockFS{}, nil) assert.NoError(t, err) } From 974202dffd3347621d28bca301cc72efd236f7ad Mon Sep 17 00:00:00 2001 From: Atte Kojo Date: Tue, 19 Apr 2022 18:59:55 +0300 Subject: [PATCH 2/3] chore: move format tree walking code to format/ Move directory tree walking code from Formatter to format/ package that handles `fmt` subcommand. --- Makefile | 8 +- format/format.go | 74 ++++++++++++++--- format/format_test.go | 118 +++++++++++++++++++++++++++ formatter.go | 169 ++++---------------------------------- formatter_test.go | 184 ------------------------------------------ go.mod | 2 - go.sum | 18 ----- 7 files changed, 202 insertions(+), 371 deletions(-) create mode 100644 format/format_test.go diff --git a/Makefile b/Makefile index fa679d87d..d1a981711 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ GOPATH:=$(shell $(GOCMD) env GOPATH) u := $(if $(update),-u) BINARY_NAME:=swag -PACKAGES:=$(shell $(GOLIST) github.com/swaggo/swag github.com/swaggo/swag/cmd/swag github.com/swaggo/swag/gen) +PACKAGES:=$(shell $(GOLIST) github.com/swaggo/swag github.com/swaggo/swag/cmd/swag github.com/swaggo/swag/gen github.com/swaggo/swag/format) GOFILES:=$(shell find . -name "*.go" -type f) export GO111MODULE := on @@ -63,9 +63,9 @@ deps: $(GOGET) golang.org/x/tools/go/loader .PHONY: devel-deps -devel-deps: +devel-deps: GO111MODULE=off $(GOGET) -v -u \ - golang.org/x/lint/golint + golang.org/x/lint/golint .PHONY: lint lint: devel-deps @@ -91,4 +91,4 @@ fmt-check: .PHONY: view-covered view-covered: $(GOTEST) -coverprofile=cover.out $(TARGET) - $(GOCMD) tool cover -html=cover.out \ No newline at end of file + $(GOCMD) tool cover -html=cover.out diff --git a/format/format.go b/format/format.go index 03d4d02ae..98f780217 100644 --- a/format/format.go +++ b/format/format.go @@ -1,18 +1,32 @@ package format import ( - "log" + "fmt" + "os" + "path/filepath" + "strings" "github.com/swaggo/swag" ) -type Fmt struct { +// Format implements `fmt` command for formatting swag comments in Go source +// files. +type Format struct { + formatter *swag.Formatter + + // exclude exclude dirs and files in SearchDir + exclude map[string]bool } -func New() *Fmt { - return &Fmt{} +// New creates a new Format instance +func New() *Format { + return &Format{ + exclude: map[string]bool{}, + formatter: swag.NewFormatter(), + } } +// Config specifies configuration for a format run type Config struct { // SearchDir the swag would be parse SearchDir string @@ -20,14 +34,56 @@ type Config struct { // excludes dirs and files in SearchDir,comma separated Excludes string + // MainFile (DEPRECATED) MainFile string } -func (f *Fmt) Build(config *Config) error { - log.Println("Formating code.... ") - formatter := swag.NewFormatter() - if err := formatter.FormatAPI(config.SearchDir, config.Excludes, config.MainFile); err != nil { - return err +var defaultExcludes = []string{"docs", "vendor"} + +// Build runs formatter according to configuration in config +func (f *Format) Build(config *Config) error { + searchDirs := strings.Split(config.SearchDir, ",") + for _, searchDir := range searchDirs { + if _, err := os.Stat(searchDir); os.IsNotExist(err) { + return fmt.Errorf("fmt: %w", err) + } + for _, d := range defaultExcludes { + f.exclude[filepath.Join(searchDir, d)] = true + } + } + for _, fi := range strings.Split(config.Excludes, ",") { + if fi = strings.TrimSpace(fi); fi != "" { + f.exclude[filepath.Clean(fi)] = true + } + } + for _, searchDir := range searchDirs { + err := filepath.Walk(searchDir, f.visit) + if err != nil { + return err + } + } + return nil +} + +func (f *Format) visit(path string, fileInfo os.FileInfo, err error) error { + if fileInfo.IsDir() { + return f.skipDir(path, fileInfo) + } + if f.exclude[path] || + strings.HasSuffix(strings.ToLower(path), "_test.go") || + filepath.Ext(path) != ".go" { + return nil + } + if err := f.formatter.Format(path); err != nil { + return fmt.Errorf("fmt: %w", err) + } + return nil +} + +func (f *Format) skipDir(path string, info os.FileInfo) error { + if f.exclude[path] || + len(info.Name()) > 1 && info.Name()[0] == '.' { // exclude hidden folders + return filepath.SkipDir } return nil } diff --git a/format/format_test.go b/format/format_test.go new file mode 100644 index 000000000..3403c0dc8 --- /dev/null +++ b/format/format_test.go @@ -0,0 +1,118 @@ +package format + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormat_Format(t *testing.T) { + fx := setup(t) + assert.NoError(t, New().Build(&Config{SearchDir: fx.basedir})) + assert.True(t, fx.isFormatted("main.go")) + assert.True(t, fx.isFormatted("api/api.go")) +} + +func TestFormat_ExcludeDir(t *testing.T) { + fx := setup(t) + assert.NoError(t, New().Build(&Config{ + SearchDir: fx.basedir, + Excludes: filepath.Join(fx.basedir, "api"), + })) + assert.False(t, fx.isFormatted("api/api.go")) +} + +func TestFormat_ExcludeFile(t *testing.T) { + fx := setup(t) + assert.NoError(t, New().Build(&Config{ + SearchDir: fx.basedir, + Excludes: filepath.Join(fx.basedir, "main.go"), + })) + assert.False(t, fx.isFormatted("main.go")) +} + +func TestFormat_DefaultExcludes(t *testing.T) { + fx := setup(t) + assert.NoError(t, New().Build(&Config{SearchDir: fx.basedir})) + assert.False(t, fx.isFormatted("api/api_test.go")) + assert.False(t, fx.isFormatted("docs/docs.go")) +} + +func TestFormat_ParseError(t *testing.T) { + fx := setup(t) + ioutil.WriteFile(filepath.Join(fx.basedir, "parse_error.go"), []byte(`package main + func invalid() {`), 0644) + assert.Error(t, New().Build(&Config{SearchDir: fx.basedir})) +} + +func TestFormat_InvalidSearchDir(t *testing.T) { + formatter := New() + assert.Error(t, formatter.Build(&Config{SearchDir: "no_such_dir"})) +} + +type fixture struct { + t *testing.T + basedir string +} + +func setup(t *testing.T) *fixture { + fx := &fixture{ + t: t, + basedir: t.TempDir(), + } + for filename, contents := range testFiles { + fullpath := filepath.Join(fx.basedir, filepath.Clean(filename)) + if err := os.MkdirAll(filepath.Dir(fullpath), 0755); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(fullpath, contents, 0644); err != nil { + t.Fatal(err) + } + } + return fx +} + +func (fx *fixture) isFormatted(file string) bool { + contents, err := ioutil.ReadFile(filepath.Join(fx.basedir, filepath.Clean(file))) + if err != nil { + fx.t.Fatal(err) + } + return !bytes.Equal(testFiles[file], contents) +} + +var testFiles = map[string][]byte{ + "api/api.go": []byte(`package api + + import "net/http" + + // @Summary Add a new pet to the store + // @Description get string by ID + func GetStringByInt(w http.ResponseWriter, r *http.Request) { + //write your code + }`), + "api/api_test.go": []byte(`package api + // @Summary API Test + // @Description Should not be formatted + func TestApi(t *testing.T) {}`), + "docs/docs.go": []byte(`package docs + // @Summary Documentation package + // @Description Should not be formatted`), + "main.go": []byte(`package main + + import ( + "net/http" + + "github.com/swaggo/swag/format/testdata/api" + ) + + // @title Swagger Example API + // @version 1.0 + func main() { + http.HandleFunc("/testapi/get-string-by-int/", api.GetStringByInt) + }`), + "README.md": []byte(`# Format test`), +} diff --git a/formatter.go b/formatter.go index dc5c62494..d9d7abe74 100644 --- a/formatter.go +++ b/formatter.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" "text/tabwriter" ) @@ -24,106 +23,22 @@ const splitTag = "&*" type Formatter struct { // debugging output goes here debug Debugger - - // excludes excludes dirs and files in SearchDir - excludes map[string]struct{} - - mainFile string } // NewFormatter create a new formatter instance. func NewFormatter() *Formatter { formatter := &Formatter{ - debug: log.New(os.Stdout, "", log.LstdFlags), - excludes: make(map[string]struct{}), + debug: log.New(os.Stdout, "", log.LstdFlags), } return formatter } -// FormatAPI format the swag comment. -func (f *Formatter) FormatAPI(searchDir, excludeDir, mainFile string) error { - searchDirs := strings.Split(searchDir, ",") - for _, searchDir := range searchDirs { - if _, err := os.Stat(searchDir); os.IsNotExist(err) { - return fmt.Errorf("dir: %s does not exist", searchDir) - } - } - - for _, fi := range strings.Split(excludeDir, ",") { - fi = strings.TrimSpace(fi) - if fi != "" { - fi = filepath.Clean(fi) - f.excludes[fi] = struct{}{} - } - } - - // parse main.go - absMainAPIFilePath, err := filepath.Abs(filepath.Join(searchDirs[0], mainFile)) - if err != nil { - return err - } - - err = f.FormatMain(absMainAPIFilePath) - if err != nil { - return err - } - - f.mainFile = mainFile - - err = f.formatMultiSearchDir(searchDirs) - if err != nil { - return err - } - - return nil -} - -func (f *Formatter) formatMultiSearchDir(searchDirs []string) error { - for _, searchDir := range searchDirs { - f.debug.Printf("Format API Info, search dir:%s", searchDir) - - err := filepath.Walk(searchDir, f.visit) - if err != nil { - return err - } - } - - return nil -} - -func (f *Formatter) visit(path string, fileInfo os.FileInfo, err error) error { - if err := walkWith(f.excludes, false)(path, fileInfo); err != nil { - return err - } else if fileInfo.IsDir() { - // skip if file is folder - return nil - } - - if strings.HasSuffix(strings.ToLower(path), "_test.go") || filepath.Ext(path) != ".go" { - // skip if file not has suffix "*.go" - return nil - } - - if strings.HasSuffix(strings.ToLower(path), f.mainFile) { - // skip main file - return nil - } - - err = f.FormatFile(path) - if err != nil { - return fmt.Errorf("ParseFile error:%+v", err) - } - - return nil -} - -// FormatMain format the main.go comment. -func (f *Formatter) FormatMain(mainFilepath string) error { +// Format swag comments in given file. +func (f *Formatter) Format(filepath string) error { fileSet := token.NewFileSet() - - astFile, err := goparser.ParseFile(fileSet, mainFilepath, nil, goparser.ParseComments) + astFile, err := goparser.ParseFile(fileSet, filepath, nil, goparser.ParseComments) if err != nil { - return fmt.Errorf("cannot format file, err: %w path : %s ", err, mainFilepath) + return err } var ( @@ -138,31 +53,6 @@ func (f *Formatter) FormatMain(mainFilepath string) error { } } - return writeFormattedComments(mainFilepath, formatedComments, oldCommentsMap) -} - -// FormatFile format the swag comment in go function. -func (f *Formatter) FormatFile(filepath string) error { - fileSet := token.NewFileSet() - - astFile, err := goparser.ParseFile(fileSet, filepath, nil, goparser.ParseComments) - if err != nil { - return fmt.Errorf("cannot format file, err: %w path : %s ", err, filepath) - } - - var ( - formatedComments = bytes.Buffer{} - // CommentCache - oldCommentsMap = make(map[string]string) - ) - - for _, astDescription := range astFile.Decls { - astDeclaration, ok := astDescription.(*ast.FuncDecl) - if ok && astDeclaration.Doc != nil && astDeclaration.Doc.List != nil { - formatFuncDoc(astDeclaration.Doc.List, &formatedComments, oldCommentsMap) - } - } - return writeFormattedComments(filepath, formatedComments, oldCommentsMap) } @@ -186,8 +76,7 @@ func writeFormattedComments(filepath string, formatedComments bytes.Buffer, oldC } } } - - return writeBack(filepath, []byte(replaceSrc), srcBytes) + return writeBack(filepath, []byte(replaceSrc)) } func formatFuncDoc(commentList []*ast.Comment, formattedComments io.Writer, oldCommentsMap map[string]string) { @@ -323,48 +212,20 @@ func isBlankComment(comment string) bool { return len(strings.TrimSpace(comment)) == 0 } -// writeBack write to file. -func writeBack(filepath string, src, old []byte) error { - // make a temporary backup before overwriting original - backupName, err := backupFile(filepath+".", old, 0644) +func writeBack(filename string, src []byte) error { + f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)) if err != nil { return err } - - err = ioutil.WriteFile(filepath, src, 0644) - if err != nil { - _ = os.Rename(backupName, filepath) - + defer os.Remove(f.Name()) + if _, err := f.Write(src); err != nil { return err } - - _ = os.Remove(backupName) - - return nil -} - -const chmodSupported = runtime.GOOS != "windows" - -// backupFile writes data to a new file named filename with permissions perm, -// with Date: Thu, 28 Apr 2022 17:37:51 +0300 Subject: [PATCH 3/3] chore: Move file format handling code to format/format.go - Handle file open/read/update in format/format.go - Only handle formatting file contents in formatter.go - Write tests against formatter public API only - Clean up formatter code --- format/format.go | 51 ++++-- format/format_test.go | 13 ++ formatter.go | 235 ++++++++------------------ formatter_test.go | 374 ++++++++++++++++++++---------------------- 4 files changed, 297 insertions(+), 376 deletions(-) diff --git a/format/format.go b/format/format.go index 98f780217..9421e0605 100644 --- a/format/format.go +++ b/format/format.go @@ -2,6 +2,7 @@ package format import ( "fmt" + "io/ioutil" "os" "path/filepath" "strings" @@ -66,24 +67,52 @@ func (f *Format) Build(config *Config) error { } func (f *Format) visit(path string, fileInfo os.FileInfo, err error) error { - if fileInfo.IsDir() { - return f.skipDir(path, fileInfo) + if fileInfo.IsDir() && f.excludeDir(path) { + return filepath.SkipDir } - if f.exclude[path] || - strings.HasSuffix(strings.ToLower(path), "_test.go") || - filepath.Ext(path) != ".go" { + if f.excludeFile(path) { return nil } - if err := f.formatter.Format(path); err != nil { + if err := f.format(path); err != nil { return fmt.Errorf("fmt: %w", err) } return nil } -func (f *Format) skipDir(path string, info os.FileInfo) error { - if f.exclude[path] || - len(info.Name()) > 1 && info.Name()[0] == '.' { // exclude hidden folders - return filepath.SkipDir +func (f *Format) excludeDir(path string) bool { + return f.exclude[path] || + filepath.Base(path)[0] == '.' && len(filepath.Base(path)) > 1 // exclude hidden folders +} + +func (f *Format) excludeFile(path string) bool { + return f.exclude[path] || + strings.HasSuffix(strings.ToLower(path), "_test.go") || + filepath.Ext(path) != ".go" +} + +func (f *Format) format(path string) error { + contents, err := ioutil.ReadFile(path) + if err != nil { + return err } - return nil + formatted, err := f.formatter.Format(path, contents) + if err != nil { + return err + } + return write(path, formatted) +} + +func write(path string, contents []byte) error { + f, err := ioutil.TempFile(filepath.Split(path)) + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.Write(contents); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) } diff --git a/format/format_test.go b/format/format_test.go index 3403c0dc8..508111e2b 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -49,6 +49,19 @@ func TestFormat_ParseError(t *testing.T) { assert.Error(t, New().Build(&Config{SearchDir: fx.basedir})) } +func TestFormat_ReadError(t *testing.T) { + fx := setup(t) + os.Chmod(filepath.Join(fx.basedir, "main.go"), 0) + assert.Error(t, New().Build(&Config{SearchDir: fx.basedir})) +} + +func TestFormat_WriteError(t *testing.T) { + fx := setup(t) + os.Chmod(fx.basedir, 0555) + assert.Error(t, New().Build(&Config{SearchDir: fx.basedir})) + os.Chmod(fx.basedir, 0755) +} + func TestFormat_InvalidSearchDir(t *testing.T) { formatter := New() assert.Error(t, formatter.Build(&Config{SearchDir: "no_such_dir"})) diff --git a/formatter.go b/formatter.go index d9d7abe74..ca3e24c0f 100644 --- a/formatter.go +++ b/formatter.go @@ -8,10 +8,8 @@ import ( goparser "go/parser" "go/token" "io" - "io/ioutil" "log" "os" - "path/filepath" "regexp" "strings" "text/tabwriter" @@ -19,6 +17,22 @@ import ( const splitTag = "&*" +// Check of @Param @Success @Failure @Response @Header +var specialTagForSplit = map[string]bool{ + paramAttr: true, + successAttr: true, + failureAttr: true, + responseAttr: true, + headerAttr: true, +} + +var skipChar = map[byte]byte{ + '"': '"', + '(': ')', + '{': '}', + '[': ']', +} + // Formatter implements a formatter for Go source files. type Formatter struct { // debugging output goes here @@ -33,199 +47,86 @@ func NewFormatter() *Formatter { return formatter } -// Format swag comments in given file. -func (f *Formatter) Format(filepath string) error { +// Format formats swag comments in contents. It uses fileName to report errors +// that happen during parsing of contents. +func (f *Formatter) Format(fileName string, contents []byte) ([]byte, error) { fileSet := token.NewFileSet() - astFile, err := goparser.ParseFile(fileSet, filepath, nil, goparser.ParseComments) + ast, err := goparser.ParseFile(fileSet, fileName, contents, goparser.ParseComments) if err != nil { - return err + return nil, err } + formattedComments := bytes.Buffer{} + oldComments := map[string]string{} - var ( - formatedComments = bytes.Buffer{} - // CommentCache - oldCommentsMap = make(map[string]string) - ) - - if astFile.Comments != nil { - for _, comment := range astFile.Comments { - formatFuncDoc(comment.List, &formatedComments, oldCommentsMap) + if ast.Comments != nil { + for _, comment := range ast.Comments { + formatFuncDoc(comment.List, &formattedComments, oldComments) } } - - return writeFormattedComments(filepath, formatedComments, oldCommentsMap) + return formatComments(fileName, contents, formattedComments.Bytes(), oldComments), nil } -func writeFormattedComments(filepath string, formatedComments bytes.Buffer, oldCommentsMap map[string]string) error { - // Replace the file - // Read the file - srcBytes, err := ioutil.ReadFile(filepath) - if err != nil { - return fmt.Errorf("cannot open file, err: %w path : %s ", err, filepath) - } - - replaceSrc, newComments := string(srcBytes), strings.Split(formatedComments.String(), "\n") - - for _, e := range newComments { - commentSplit := strings.Split(e, splitTag) - if len(commentSplit) == 2 { - commentHash, commentContent := commentSplit[0], commentSplit[1] - - if !isBlankComment(commentContent) { - replaceSrc = strings.Replace(replaceSrc, oldCommentsMap[commentHash], commentContent, 1) - } +func formatComments(fileName string, contents []byte, formattedComments []byte, oldComments map[string]string) []byte { + for _, comment := range bytes.Split(formattedComments, []byte("\n")) { + splits := bytes.SplitN(comment, []byte(splitTag), 2) + if len(splits) == 2 { + hash, line := splits[0], splits[1] + contents = bytes.Replace(contents, []byte(oldComments[string(hash)]), line, 1) } } - return writeBack(filepath, []byte(replaceSrc)) + return contents } func formatFuncDoc(commentList []*ast.Comment, formattedComments io.Writer, oldCommentsMap map[string]string) { - tabWriter := tabwriter.NewWriter(formattedComments, 0, 0, 2, ' ', 0) + w := tabwriter.NewWriter(formattedComments, 0, 0, 2, ' ', 0) for _, comment := range commentList { - commentLine := comment.Text - if isSwagComment(commentLine) || isBlankComment(commentLine) { - cmd5 := fmt.Sprintf("%x", md5.Sum([]byte(commentLine))) - - // Find the separator and replace to \t - c := separatorFinder(commentLine, '\t') - oldCommentsMap[cmd5] = commentLine - + text := comment.Text + if attr, body, found := swagComment(text); found { + cmd5 := fmt.Sprintf("%x", md5.Sum([]byte(text))) + oldCommentsMap[cmd5] = text + + formatted := "// " + attr + if body != "" { + formatted += "\t" + splitComment2(attr, body) + } // md5 + splitTag + srcCommentLine // eg. xxx&*@Description get struct array - _, _ = fmt.Fprintln(tabWriter, cmd5+splitTag+c) - } - } - // format by tabWriter - _ = tabWriter.Flush() -} - -func separatorFinder(comment string, replacer byte) string { - commentBytes, commentLine := []byte(comment), strings.TrimSpace(strings.TrimLeft(comment, "/")) - - if len(commentLine) == 0 { - return "" - } - - attribute := strings.Fields(commentLine)[0] - attrLen := strings.Index(comment, attribute) + len(attribute) - attribute = strings.ToLower(attribute) - - var ( - length = attrLen - - // Check of @Param @Success @Failure @Response @Header. - specialTagForSplit = map[string]byte{ - paramAttr: 1, - successAttr: 1, - failureAttr: 1, - responseAttr: 1, - headerAttr: 1, + _, _ = fmt.Fprintln(w, cmd5+splitTag+formatted) } - ) - - _, ok := specialTagForSplit[attribute] - if ok { - return splitSpecialTags(commentBytes, length, replacer) - } - - for length < len(commentBytes) && commentBytes[length] == ' ' { - length++ - } - - if length >= len(commentBytes) { - return comment } - - commentBytes = replaceRange(commentBytes, attrLen, length, replacer) - - return string(commentBytes) + // format by tabwriter + _ = w.Flush() } -func splitSpecialTags(commentBytes []byte, length int, rp byte) string { - var ( - skipFlag bool - skipChar = map[byte]byte{ - '"': 1, - '(': 1, - '{': 1, - '[': 1, - } - - skipCharEnd = map[byte]byte{ - '"': 1, - ')': 1, - '}': 1, - ']': 1, - } - ) - - for ; length < len(commentBytes); length++ { - if !skipFlag && commentBytes[length] == ' ' { - j := length - for j < len(commentBytes) && commentBytes[j] == ' ' { - j++ +func splitComment2(attr, body string) string { + if specialTagForSplit[strings.ToLower(attr)] { + for i := 0; i < len(body); i++ { + if skipEnd, ok := skipChar[body[i]]; ok { + if skipLen := strings.IndexByte(body[i+1:], skipEnd); skipLen > 0 { + i += skipLen + } + } else if body[i] == ' ' { + j := i + for ; j < len(body) && body[j] == ' '; j++ { + } + body = replaceRange(body, i, j, "\t") } - - commentBytes = replaceRange(commentBytes, length, j, rp) - } - - _, found := skipChar[commentBytes[length]] - if found && !skipFlag { - skipFlag = true - - continue } - - _, found = skipCharEnd[commentBytes[length]] - if found && skipFlag { - skipFlag = false - } - } - - return string(commentBytes) -} - -func replaceRange(s []byte, start, end int, new byte) []byte { - if start > end || end < 1 { - return s } - - if end > len(s) { - end = len(s) - } - - s = append(s[:start], s[end-1:]...) - - s[start] = new - - return s + return body } -var swagCommentExpression = regexp.MustCompile("@[A-z]+") - -func isSwagComment(comment string) bool { - return swagCommentExpression.MatchString(strings.ToLower(comment)) +func replaceRange(s string, start, end int, new string) string { + return s[:start] + new + s[end:] } -func isBlankComment(comment string) bool { - return len(strings.TrimSpace(comment)) == 0 -} +var swagCommentLineExpression = regexp.MustCompile(`^\/\/\s+(@[\S.]+)\s*(.*)`) -func writeBack(filename string, src []byte) error { - f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)) - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err := f.Write(src); err != nil { - return err - } - if err := f.Close(); err != nil { - return err - } - if err := os.Rename(f.Name(), filename); err != nil { - return err +func swagComment(comment string) (string, string, bool) { + matches := swagCommentLineExpression.FindStringSubmatch(comment) + if matches == nil { + return "", "", false } - return nil + return matches[1], matches[2], true } diff --git a/formatter_test.go b/formatter_test.go index 972e6778f..8cc585663 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -1,7 +1,6 @@ package swag import ( - "reflect" "testing" "github.com/stretchr/testify/assert" @@ -13,207 +12,186 @@ const ( MainFile = "main.go" ) -func Test_isBlankComment(t *testing.T) { - type args struct { - comment string - } - - tests := []struct { - name string - args args - want bool - }{ - { - name: "test1", - args: args{ - comment: " ", - }, - want: true, - }, - { - name: "test2", - args: args{ - comment: " A", - }, - want: false, - }, - { - name: "test3", - args: args{ - comment: " \t", - }, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isBlankComment(tt.args.comment) - if got != tt.want { - t.Errorf("isBlankComment() = %v, want %v", got, tt.want) - } - }) - } +func testFormat(t *testing.T, filename, contents, want string) { + got, err := NewFormatter().Format(filename, []byte(contents)) + assert.NoError(t, err) + assert.Equal(t, want, string(got)) } -func Test_isSwagComment(t *testing.T) { - type args struct { - comment string - } - - tests := []struct { - name string - args args - want bool - }{ - { - name: "test1", - args: args{ - comment: "@Param some_id ", - }, - want: true, - }, - { - name: "test2", - args: args{ - comment: "@ ", - }, - want: false, - }, - { - name: "test3", - args: args{ - comment: "@Success {object} ", - }, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isSwagComment(tt.args.comment) - if got != tt.want { - t.Errorf("isSwagComment() = %v, want %v", got, tt.want) - } - }) - } +func Test_FormatMain(t *testing.T) { + contents := `package main + // @title Swagger Example API + // @version 1.0 + // @description This is a sample server Petstore server. + // @termsOfService http://swagger.io/terms/ + + // @contact.name API Support + // @contact.url http://www.swagger.io/support + // @contact.email support@swagger.io + + // @license.name Apache 2.0 + // @license.url http://www.apache.org/licenses/LICENSE-2.0.html + + // @host petstore.swagger.io + // @BasePath /v2 + + // @securityDefinitions.basic BasicAuth + + // @securityDefinitions.apikey ApiKeyAuth + // @in header + // @name Authorization + + // @securitydefinitions.oauth2.application OAuth2Application + // @tokenUrl https://example.com/oauth/token + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.implicit OAuth2Implicit + // @authorizationurl https://example.com/oauth/authorize + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.password OAuth2Password + // @tokenUrl https://example.com/oauth/token + // @scope.read Grants read access + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.accessCode OAuth2AccessCode + // @tokenUrl https://example.com/oauth/token + // @authorizationurl https://example.com/oauth/authorize + // @scope.admin Grants read and write access to administrative information + func main() {}` + + want := `package main + // @title Swagger Example API + // @version 1.0 + // @description This is a sample server Petstore server. + // @termsOfService http://swagger.io/terms/ + + // @contact.name API Support + // @contact.url http://www.swagger.io/support + // @contact.email support@swagger.io + + // @license.name Apache 2.0 + // @license.url http://www.apache.org/licenses/LICENSE-2.0.html + + // @host petstore.swagger.io + // @BasePath /v2 + + // @securityDefinitions.basic BasicAuth + + // @securityDefinitions.apikey ApiKeyAuth + // @in header + // @name Authorization + + // @securitydefinitions.oauth2.application OAuth2Application + // @tokenUrl https://example.com/oauth/token + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.implicit OAuth2Implicit + // @authorizationurl https://example.com/oauth/authorize + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.password OAuth2Password + // @tokenUrl https://example.com/oauth/token + // @scope.read Grants read access + // @scope.write Grants write access + // @scope.admin Grants read and write access to administrative information + + // @securitydefinitions.oauth2.accessCode OAuth2AccessCode + // @tokenUrl https://example.com/oauth/token + // @authorizationurl https://example.com/oauth/authorize + // @scope.admin Grants read and write access to administrative information + func main() {}` + + testFormat(t, "main.go", contents, want) +} + +func Test_FormatApi(t *testing.T) { + contents := `package api + + import "net/http" + + // @Summary Add a new pet to the store + // @Description get string by ID + // @ID get-string-by-int + // @Accept json + // @Produce json + // @Param some_id path int true "Some ID" Format(int64) + // @Param some_id body web.Pet true "Some ID" + // @Success 200 {string} string "ok" + // @Failure 400 {object} web.APIError "We need ID!!" + // @Failure 404 {object} web.APIError "Can not find ID" + // @Router /testapi/get-string-by-int/{some_id} [get] + func GetStringByInt(w http.ResponseWriter, r *http.Request) {}` + + want := `package api + + import "net/http" + + // @Summary Add a new pet to the store + // @Description get string by ID + // @ID get-string-by-int + // @Accept json + // @Produce json + // @Param some_id path int true "Some ID" Format(int64) + // @Param some_id body web.Pet true "Some ID" + // @Success 200 {string} string "ok" + // @Failure 400 {object} web.APIError "We need ID!!" + // @Failure 404 {object} web.APIError "Can not find ID" + // @Router /testapi/get-string-by-int/{some_id} [get] + func GetStringByInt(w http.ResponseWriter, r *http.Request) {}` + + testFormat(t, "api.go", contents, want) } -func Test_replaceRange(t *testing.T) { - type args struct { - s []byte - start int - end int - new byte - } - - tests := []struct { - name string - args args - want []byte - }{ - { - name: "test_replaceSuccess", - args: args{ - s: []byte("// @ID get-ids"), - start: 6, - end: 8, - new: '\t', - }, - want: []byte("// @ID\tget-ids"), - }, - { - name: "test1_replaceFail", - args: args{ - s: []byte("// @ID A pet"), - start: 6, - end: 8, - new: '\t', - }, - want: []byte("// @ID\tA pet"), - }, - { - name: "test1_replaceFail2", - args: args{ - s: []byte("// @ID "), - start: 6, - end: 12, - new: '\t', - }, - want: []byte("// @ID\t"), - }, - { - name: "test1_replaceFail3", - args: args{ - s: []byte("// @ID "), - start: 2, - end: 1, - new: '\t', - }, - want: []byte("// @ID "), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := replaceRange(tt.args.s, tt.args.start, tt.args.end, tt.args.new); !reflect.DeepEqual(got, tt.want) { - t.Errorf("replaceRange() = %v, want %v", got, tt.want) - } - }) - } +func Test_NonSwagComment(t *testing.T) { + contents := `package api + // @Summary Add a new pet to the store + // @Description get string by ID + // @ID get-string-by-int + // @ Accept json + // This is not a @swag comment` + want := `package api + // @Summary Add a new pet to the store + // @Description get string by ID + // @ID get-string-by-int + // @ Accept json + // This is not a @swag comment` + + testFormat(t, "non_swag.go", contents, want) } -func Test_separatorFinder(t *testing.T) { - type args struct { - comment string - } - - tests := []struct { - name string - args args - want string - }{ - { - name: "test1", - args: args{ - comment: `// @Param some_id query int "some id data" Enums(1, 2, 3)`, - }, - want: `// @Param|some_id|query|int|"some id data"|Enums(1, 2, 3)`, - }, - { - name: "test2", - args: args{ - comment: `// @Summary A pet store. `, - }, - want: `// @Summary|A pet store. `, - }, - { - name: "test3", - args: args{ - comment: `// @Summary `, - }, - want: `// @Summary `, - }, - { - name: "test4", - args: args{ - comment: `// @Failure 400 {object} web.APIError{data=web.D ,data2=web.D2} "We need ID!!"`, - }, - want: `// @Failure|400|{object}|web.APIError{data=web.D ,data2=web.D2}|"We need ID!!"`, - }, - { - name: "test5", - args: args{ - comment: `// `, - }, - want: ``, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := separatorFinder(tt.args.comment, '|') - assert.Equal(t, got, tt.want) - }) - } +func Test_EmptyComment(t *testing.T) { + contents := `package empty + // @Summary Add a new pet to the store + // @Description ` + want := `package empty + // @Summary Add a new pet to the store + // @Description` + + testFormat(t, "empty.go", contents, want) +} + +func Test_AlignAttribute(t *testing.T) { + contents := `package align + // @Summary Add a new pet to the store + // @Description Description` + want := `package align + // @Summary Add a new pet to the store + // @Description Description` + + testFormat(t, "align.go", contents, want) + +} + +func Test_SyntaxError(t *testing.T) { + contents := []byte(`package invalid + func invalid() {`) + + _, err := NewFormatter().Format("invalid.go", contents) + assert.Error(t, err) }