├── README.md ├── .travis.yml ├── .gitignore ├── appveyor.yml ├── pkg └── tdglib │ ├── environment_test.go │ ├── environment.go │ ├── todogenerator_test.go │ └── todogenerator.go ├── cmd └── tdg │ └── main.go └── LICENSE /README.md: -------------------------------------------------------------------------------- 1 | # Moved to [GitLab](https://gitlab.com/ribtoks/tdg) 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | dist: trusty 3 | 4 | language: go 5 | 6 | os: 7 | - linux 8 | 9 | git: 10 | depth: 3 11 | 12 | before_script: 13 | - go version 14 | - go get 15 | 16 | script: 17 | - go build 18 | - go test -v -covermode=atomic -coverprofile=coverage.txt ./... 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | *.coverage 16 | coverage.txt 17 | 18 | tdg.log 19 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # version format 2 | version: 0.1.{build}-{branch} 3 | 4 | skip_tags: false 5 | 6 | skip_commits: 7 | message: /.*\[ci skip\]/ # Regex for matching commit message 8 | 9 | # clone directory 10 | clone_folder: c:\projects\tdg 11 | 12 | environment: 13 | GOPATH: c:\gopath 14 | 15 | clone_depth: 3 # clone entire repository history if not defined 16 | 17 | before_build: 18 | - go version 19 | - go get github.com/zieckey/goini 20 | - go get github.com/ribtoks/tdg/pkg/tdglib 21 | 22 | build_script: 23 | - go build ./... 24 | 25 | test_script: 26 | - go test -v ./... 27 | -------------------------------------------------------------------------------- /pkg/tdglib/environment_test.go: -------------------------------------------------------------------------------- 1 | package tdglib 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func Equal(a, b []string) bool { 9 | if len(a) != len(b) { 10 | return false 11 | } 12 | for i, v := range a { 13 | if v != b[i] { 14 | return false 15 | } 16 | } 17 | return true 18 | } 19 | 20 | var envTests = []struct { 21 | in []string 22 | out []string 23 | }{ 24 | {[]string{"GIT_DIR=5"}, []string{}}, 25 | {[]string{"GIT_DIR", "TEST=6"}, []string{"TEST=6"}}, 26 | {[]string{"TEST=6", "GIT_DIR"}, []string{"TEST=6"}}, 27 | {[]string{"TEST=6"}, []string{"TEST=6"}}, 28 | } 29 | 30 | func TestSliceWithoutGitDir(t *testing.T) { 31 | for i, tt := range envTests { 32 | t.Run(fmt.Sprintf("test_%v", i), func(t *testing.T) { 33 | without := sliceWithoutGitDir(tt.in) 34 | if !Equal(without, tt.out) { 35 | t.Errorf("got %v, expected %v", without, tt.out) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/tdglib/environment.go: -------------------------------------------------------------------------------- 1 | package tdglib 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | // Environment contains information about git repository 14 | type Environment struct { 15 | root string 16 | branch string 17 | author string 18 | project string 19 | initBranch sync.Once 20 | initAuthor sync.Once 21 | initProject sync.Once 22 | } 23 | 24 | // NewEnvironment creates new instance of Environment struct 25 | func NewEnvironment(root string) *Environment { 26 | absolutePath, err := filepath.Abs(root) 27 | if err != nil { 28 | log.Printf("Error when setting env root: %v", err) 29 | absolutePath = root 30 | } 31 | env := &Environment{ 32 | root: absolutePath, 33 | } 34 | go func() { 35 | log.Printf("Current root is %v", env.root) 36 | log.Printf("Current branch is %v", env.Branch()) 37 | log.Printf("Current author is %v", env.Author()) 38 | log.Printf("Current project is %v", env.Project()) 39 | }() 40 | return env 41 | } 42 | 43 | func sliceWithoutGitDir(slice []string) []string { 44 | newEnv := make([]string, 0, len(slice)) 45 | for _, s := range slice { 46 | if strings.HasPrefix(strings.ToUpper(s), "GIT_DIR") { 47 | continue 48 | } 49 | newEnv = append(newEnv, s) 50 | } 51 | return newEnv 52 | } 53 | 54 | // Run executes a command in the environment's root 55 | func (env *Environment) Run(cmd string, arg ...string) string { 56 | command := exec.Command(cmd, arg...) 57 | // setting working directory here breaks GIT_DIR variable 58 | command.Dir = env.root 59 | // so we need to remove this variable from environment 60 | command.Env = sliceWithoutGitDir(os.Environ()) 61 | 62 | var stdout, stderr bytes.Buffer 63 | command.Stdout = &stdout 64 | command.Stderr = &stderr 65 | 66 | err := command.Run() 67 | if err != nil { 68 | log.Printf("Command run error: %s", err) 69 | log.Printf("Command stderr: %s", string(stderr.Bytes())) 70 | return "" 71 | } 72 | 73 | outStr := string(stdout.Bytes()) 74 | return strings.TrimSpace(outStr) 75 | } 76 | 77 | // Branch returns current git branch 78 | func (env *Environment) Branch() string { 79 | env.initBranch.Do(func() { 80 | env.branch = env.Run("git", "rev-parse", "--abbrev-ref", "HEAD") 81 | }) 82 | return env.branch 83 | } 84 | 85 | // Author returns current git author 86 | func (env *Environment) Author() string { 87 | env.initAuthor.Do(func() { 88 | env.author = env.Run("git", "config", "user.name") 89 | }) 90 | return env.author 91 | } 92 | 93 | // Project returns current git project name 94 | func (env *Environment) Project() string { 95 | env.initProject.Do(func() { 96 | project := env.Run("git", "rev-parse", "--show-toplevel") 97 | env.project = filepath.Base(project) 98 | }) 99 | return env.project 100 | } 101 | -------------------------------------------------------------------------------- /cmd/tdg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/ribtoks/tdg/pkg/tdglib" 15 | ) 16 | 17 | type arrayFlags []string 18 | 19 | func (af *arrayFlags) String() string { 20 | return strings.Join(*af, " ") 21 | } 22 | 23 | func (af *arrayFlags) Set(value string) error { 24 | *af = append(*af, value) 25 | return nil 26 | } 27 | 28 | const ( 29 | appName = "tdg" 30 | ) 31 | 32 | var ( 33 | includePatternsFlag arrayFlags 34 | excludePatternsFlag arrayFlags 35 | srcRootFlag = flag.String("root", "./", "Path to the the root of source code") 36 | helpFlag = flag.Bool("help", false, "Show help") 37 | verboseFlag = flag.Bool("verbose", false, "Output human-readable json") 38 | minWordCountFlag = flag.Int("min-words", 3, "Skip comments with less than minimum words") 39 | minCharsFlag = flag.Int("min-chars", 30, "Include comments with more chars than this") 40 | stdoutFlag = flag.Bool("stdout", false, "Duplicate logs to stdout") 41 | logPathFlag = flag.String("log", "tdg.log", "Path to the logfile") 42 | ) 43 | 44 | func main() { 45 | err := parseFlags() 46 | if err != nil { 47 | flag.PrintDefaults() 48 | log.Fatal(err) 49 | } 50 | 51 | logfile, err := setupLogging() 52 | if err == nil { 53 | defer logfile.Close() 54 | } 55 | 56 | env := tdglib.NewEnvironment(*srcRootFlag) 57 | td := tdglib.NewToDoGenerator(*srcRootFlag, 58 | includePatternsFlag, 59 | excludePatternsFlag, 60 | *minWordCountFlag, 61 | *minCharsFlag) 62 | start := time.Now() 63 | comments, err := td.Generate() 64 | elapsed := time.Since(start) 65 | log.Printf("Generation took %s", elapsed) 66 | 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | result := struct { 72 | Root string `json:"root"` 73 | Branch string `json:"branch"` 74 | Author string `json:"author"` 75 | Project string `json:"project"` 76 | Comments []*tdglib.ToDoComment `json:"comments"` 77 | }{ 78 | Root: td.Root(), 79 | Branch: env.Branch(), 80 | Author: env.Author(), 81 | Project: env.Project(), 82 | Comments: comments, 83 | } 84 | var js []byte 85 | if *verboseFlag { 86 | js, err = json.MarshalIndent(result, "", " ") 87 | } else { 88 | js, err = json.Marshal(result) 89 | } 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | fmt.Println(string(js)) 94 | } 95 | 96 | func parseFlags() error { 97 | flag.Var(&includePatternsFlag, "include", "Include pattern (can be specified multiple times)") 98 | flag.Var(&excludePatternsFlag, "exclude", "Exclude pattern (can be specified multiple times)") 99 | flag.Parse() 100 | if *helpFlag { 101 | flag.PrintDefaults() 102 | os.Exit(0) 103 | } 104 | 105 | srcRoot, err := os.Stat(*srcRootFlag) 106 | if os.IsNotExist(err) { 107 | return err 108 | } 109 | if !srcRoot.IsDir() { 110 | return errors.New("Root path does not point to a directory") 111 | } 112 | return nil 113 | } 114 | 115 | func setupLogging() (f *os.File, err error) { 116 | f, err = os.OpenFile(*logPathFlag, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 117 | if err != nil { 118 | if *stdoutFlag { 119 | fmt.Printf("error opening file: %v", *logPathFlag) 120 | } 121 | return nil, err 122 | } 123 | 124 | if *stdoutFlag { 125 | mw := io.MultiWriter(os.Stdout, f) 126 | log.SetOutput(mw) 127 | } else { 128 | log.SetOutput(f) 129 | } 130 | 131 | log.Println("------------------------------") 132 | log.Printf("%v log started", appName) 133 | 134 | return f, err 135 | } 136 | -------------------------------------------------------------------------------- /pkg/tdglib/todogenerator_test.go: -------------------------------------------------------------------------------- 1 | package tdglib 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "testing" 7 | ) 8 | 9 | const float64EqualityThreshold = 1e-9 10 | 11 | var commentTests = []struct { 12 | in string 13 | out string 14 | }{ 15 | {"//", ""}, 16 | {"#", ""}, 17 | {"% ", ""}, 18 | {"// test contents\t", "test contents"}, 19 | {"# test contents", "test contents"}, 20 | {"% test contents", "test contents"}, 21 | {"/* test contents ", "test contents"}, 22 | {"/** test contents ", "test contents"}, 23 | {"// TODO: test contents", "TODO: test contents"}, 24 | {"//TODO: test contents\t", "TODO: test contents"}, 25 | {"//TODO(author): test contents", "TODO(author): test contents"}, 26 | } 27 | 28 | func TestParseComment(t *testing.T) { 29 | for _, tt := range commentTests { 30 | t.Run(tt.in, func(t *testing.T) { 31 | comment := parseComment(tt.in) 32 | if comment == nil { 33 | t.Errorf("got nil, expected %v", tt.out) 34 | } 35 | if string(comment) != tt.out { 36 | t.Errorf("got %v, expected %v", comment, tt.out) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | var startsWithTests = []struct { 43 | in string 44 | prefix string 45 | out bool 46 | }{ 47 | {"TODO: test code", "TODO", true}, 48 | {"TODO(author): test code", "TODO", true}, 49 | {"TODO: test code", "HACK", false}, 50 | {"ToDo: test code", "TODO", true}, 51 | {"todo: test code", "TODO", true}, 52 | {"BUG: test code", "BUG", true}, 53 | {"BUG:test code", "BUG", true}, 54 | {"BUG(author):test code", "BUG", true}, 55 | } 56 | 57 | func TestStartsWith(t *testing.T) { 58 | for _, tt := range startsWithTests { 59 | t.Run(tt.in, func(t *testing.T) { 60 | if startsWith([]rune(tt.in), []rune(tt.prefix)) != tt.out { 61 | t.Errorf("Test(%v): got %v, expected %v", tt.in, !tt.out, tt.out) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | var todoLineTests = []struct { 68 | in string 69 | ctype string 70 | title string 71 | author string 72 | }{ 73 | {"TODO: test code", "TODO", "test code", ""}, 74 | {"TODO test code", "TODO", "test code", ""}, 75 | {"TODOtest code", "", "", ""}, 76 | {"FIXME: test code", "FIXME", "test code", ""}, 77 | {"BUG: test code", "BUG", "test code", ""}, 78 | {"BUG test code", "BUG", "test code", ""}, 79 | {"HACK: test code", "HACK", "test code", ""}, 80 | {"HACK:test code", "HACK", "test code", ""}, 81 | {"TODO(author): test code", "TODO", "test code", "author"}, 82 | {"HACK(author): test code", "HACK", "test code", "author"}, 83 | {"BUG(author): test code", "BUG", "test code", "author"}, 84 | } 85 | 86 | func TestTodoTitle(t *testing.T) { 87 | for _, tt := range todoLineTests { 88 | t.Run(tt.in, func(t *testing.T) { 89 | ctype, title, author := parseToDoTitle([]rune(tt.in)) 90 | if string(ctype) != tt.ctype { 91 | t.Errorf("Test(%v): got %v, expected %v", tt.in, string(ctype), tt.ctype) 92 | } 93 | if string(title) != tt.title { 94 | t.Errorf("Test(%v): got %v, expected %v", tt.in, string(title), tt.title) 95 | } 96 | if string(author) != tt.author { 97 | t.Errorf("Test(%v): got %v, expected %v", tt.in, string(author), tt.author) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func almostEqual(a, b float64) bool { 104 | return math.Abs(a-b) <= float64EqualityThreshold 105 | } 106 | 107 | var commentConstructorTests = []struct { 108 | in []string 109 | title string 110 | body string 111 | category string 112 | author string 113 | issue int 114 | estimate float64 115 | }{ 116 | {[]string{"issue title"}, "issue title", "", "", "", 0, 0.0}, 117 | {[]string{"issue title", "category=Test"}, "issue title", "", "Test", "", 0, 0.0}, 118 | {[]string{"issue title", "issue=123"}, "issue title", "", "", "", 123, 0.0}, 119 | {[]string{"issue title", "estimate=30m"}, "issue title", "", "", "", 0, 0.5}, 120 | {[]string{"issue title", "estimate=30x"}, "issue title", "estimate=30x", "", "", 0, 0.0}, 121 | {[]string{"issue title", "estimate=30h"}, "issue title", "", "", "", 0, 30}, 122 | {[]string{"issue title", " category=Test issue=123 estimate=60m author=Me "}, "issue title", "", "Test", "Me", 123, 1.0}, 123 | {[]string{"issue title", "issue=123", "third line"}, "issue title", "third line", "", "", 123, 0.0}, 124 | {[]string{"issue title", "second line"}, "issue title", "second line", "", "", 0, 0.0}, 125 | {[]string{"issue title", "second line", "third line"}, "issue title", "second line\nthird line", "", "", 0, 0.0}, 126 | } 127 | 128 | func TestNewComment(t *testing.T) { 129 | for i, tt := range commentConstructorTests { 130 | t.Run(fmt.Sprintf("test_%v", i), func(t *testing.T) { 131 | c := NewComment("/path/", 0, "ctype", "author", tt.in) 132 | if c.Title != tt.title { 133 | t.Errorf("Title error: got %v, expected %v", c.Title, tt.title) 134 | } 135 | if c.Body != tt.body { 136 | t.Errorf("Body error: got %v, expected %v", c.Body, tt.body) 137 | } 138 | if c.Category != tt.category { 139 | t.Errorf("Category error: got %v, expected %v", c.Category, tt.category) 140 | } 141 | if c.Issue != tt.issue { 142 | t.Errorf("Issue error: got %v, expected %v", c.Issue, tt.issue) 143 | } 144 | if !almostEqual(c.Estimate, tt.estimate) { 145 | t.Errorf("Estimate error: got %v, expected %v", c.Estimate, tt.estimate) 146 | } 147 | }) 148 | } 149 | } 150 | 151 | var titleTests = []struct { 152 | in string 153 | out int 154 | }{ 155 | {"a a a", 0}, 156 | {"aaa", 1}, 157 | {"aaa bbb c d", 2}, 158 | {"aa bb cd", 0}, 159 | } 160 | 161 | func TestTitleWords(t *testing.T) { 162 | for _, tt := range titleTests { 163 | t.Run(tt.in, func(t *testing.T) { 164 | wc := countTitleWords(tt.in) 165 | if wc != tt.out { 166 | t.Errorf("got %v, expected %v", wc, tt.out) 167 | } 168 | }) 169 | } 170 | } 171 | 172 | var estimateTests = []struct { 173 | in string 174 | out float64 175 | }{ 176 | {"", 0}, 177 | {"30", 30}, 178 | {"30h", 30}, 179 | {"30m", 0.5}, 180 | {"30x", 0}, 181 | } 182 | 183 | func TestEstimates(t *testing.T) { 184 | for _, tt := range estimateTests { 185 | t.Run(tt.in, func(t *testing.T) { 186 | e, _ := parseEstimate(tt.in) 187 | if e != tt.out { 188 | t.Errorf("got %v, expected %v", e, tt.out) 189 | } 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pkg/tdglib/todogenerator.go: -------------------------------------------------------------------------------- 1 | package tdglib 2 | 3 | import ( 4 | "bufio" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "errors" 8 | "io" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "unicode" 17 | 18 | "github.com/zieckey/goini" 19 | ) 20 | 21 | const ( 22 | estimateEpsilon = 0.01 23 | minTitleWords = 2 24 | ) 25 | 26 | var ( 27 | commentPrefixes = [...]string{"TODO", "FIXME", "BUG", "HACK"} 28 | emptyRunes = [...]rune{} 29 | categoryIniKey = "category" 30 | issueIniKey = "issue" 31 | estimateIniKey = "estimate" 32 | authorIniKey = "author" 33 | errCannotParseIni = errors.New("Cannot parse ini properties") 34 | errCannotParseEstimate = errors.New("Cannot parse time estimate") 35 | ) 36 | 37 | // ToDoComment a task that is parsed from TODO comment 38 | // estimate is in hours 39 | type ToDoComment struct { 40 | Type string `json:"type"` 41 | Title string `json:"title"` 42 | Body string `json:"body"` 43 | File string `json:"file"` 44 | Line int `json:"line"` 45 | Issue int `json:"issue,omitempty"` 46 | Author string `json:"author,omitempty"` 47 | Category string `json:"category,omitempty"` 48 | Estimate float64 `json:"estimate,omitempty"` 49 | } 50 | 51 | // ToDoGenerator is responsible for parsing code base to ToDoComments 52 | type ToDoGenerator struct { 53 | root string 54 | include []*regexp.Regexp 55 | exclude []*regexp.Regexp 56 | commentsWG sync.WaitGroup 57 | comments []*ToDoComment 58 | minWords int 59 | minChars int 60 | addedMap map[string]bool 61 | commentMux sync.Mutex 62 | } 63 | 64 | // NewToDoGenerator creates new generator for a source root 65 | func NewToDoGenerator(root string, include []string, exclude []string, minWords, minChars int) *ToDoGenerator { 66 | log.Printf("Using source code root %v", root) 67 | log.Printf("Using %v include filters", include) 68 | ifilters := make([]*regexp.Regexp, 0, len(include)) 69 | for _, f := range include { 70 | ifilters = append(ifilters, regexp.MustCompile(f)) 71 | } 72 | 73 | log.Printf("Using %v exclude filters", exclude) 74 | efilters := make([]*regexp.Regexp, 0, len(exclude)) 75 | for _, f := range exclude { 76 | efilters = append(efilters, regexp.MustCompile(f)) 77 | } 78 | 79 | absolutePath, err := filepath.Abs(root) 80 | if err != nil { 81 | log.Printf("Error setting generator root: %v", err) 82 | 83 | absolutePath = root 84 | } 85 | 86 | return &ToDoGenerator{ 87 | root: absolutePath, 88 | include: ifilters, 89 | exclude: efilters, 90 | minWords: minWords, 91 | minChars: minChars, 92 | comments: make([]*ToDoComment, 0), 93 | addedMap: make(map[string]bool), 94 | } 95 | } 96 | 97 | func (td *ToDoGenerator) Root() string { 98 | return td.root 99 | } 100 | 101 | func (td *ToDoGenerator) Includes(path string) bool { 102 | anyMatch := false 103 | 104 | for _, f := range td.include { 105 | if f.MatchString(path) { 106 | anyMatch = true 107 | break 108 | } 109 | } 110 | 111 | if !anyMatch && len(td.include) > 0 { 112 | return false 113 | } 114 | 115 | return true 116 | } 117 | 118 | func (td *ToDoGenerator) Excludes(path string) bool { 119 | anyMatch := false 120 | 121 | for _, f := range td.exclude { 122 | if f.MatchString(path) { 123 | anyMatch = true 124 | break 125 | } 126 | } 127 | 128 | return anyMatch 129 | } 130 | 131 | // Generate is an entry point to comment generation 132 | func (td *ToDoGenerator) Generate() ([]*ToDoComment, error) { 133 | matchesCount := 0 134 | totalFiles := 0 135 | err := filepath.Walk(td.root, func(path string, info os.FileInfo, err error) error { 136 | if err != nil { 137 | return err 138 | } 139 | 140 | if !info.Mode().IsRegular() { 141 | return nil 142 | } 143 | 144 | totalFiles++ 145 | 146 | if !td.Includes(path) { 147 | return nil 148 | } 149 | 150 | if td.Excludes(path) { 151 | return nil 152 | } 153 | 154 | anyMatch := false 155 | for _, f := range td.include { 156 | if f.MatchString(path) { 157 | anyMatch = true 158 | break 159 | } 160 | } 161 | if !anyMatch && len(td.include) > 0 { 162 | return nil 163 | } 164 | 165 | matchesCount++ 166 | td.commentsWG.Add(1) 167 | go td.parseFile(path) 168 | 169 | return nil 170 | }) 171 | 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | log.Printf("Scanned files: %v", totalFiles) 177 | log.Printf("Matched files: %v", matchesCount) 178 | td.commentsWG.Wait() 179 | log.Printf("Found comments: %v", len(td.comments)) 180 | 181 | return td.comments, nil 182 | } 183 | 184 | func countTitleWords(s string) int { 185 | words := strings.Fields(s) 186 | count := 0 187 | 188 | for _, w := range words { 189 | if len(w) > minTitleWords { 190 | count++ 191 | } 192 | } 193 | 194 | return count 195 | } 196 | 197 | func (td *ToDoGenerator) addComment(c *ToDoComment) { 198 | defer td.commentsWG.Done() 199 | 200 | h := md5.New() 201 | io.WriteString(h, c.File) 202 | io.WriteString(h, c.Title) 203 | io.WriteString(h, c.Body) 204 | s := hex.EncodeToString(h.Sum(nil)) 205 | 206 | td.commentMux.Lock() 207 | defer td.commentMux.Unlock() 208 | 209 | if _, ok := td.addedMap[s]; ok { 210 | log.Printf("Skipping comment duplicate in %v:%v", c.File, c.Line) 211 | return 212 | } 213 | 214 | if countTitleWords(c.Title) >= td.minWords || len(c.Title) >= td.minChars { 215 | td.addedMap[s] = true 216 | td.comments = append(td.comments, c) 217 | } else { 218 | log.Printf("Ignoring comment in %v:%v", c.File, c.Line) 219 | } 220 | } 221 | 222 | func isCommentRune(r rune) bool { 223 | return r == '/' || 224 | r == '#' || 225 | r == '%' || 226 | r == ';' || 227 | r == '*' 228 | } 229 | 230 | // try to parse comment body from commented line 231 | func parseComment(line string) []rune { 232 | runes := []rune(line) 233 | i := 0 234 | size := len(runes) 235 | // skip prefix whitespace 236 | for i < size && unicode.IsSpace(runes[i]) { 237 | i++ 238 | } 239 | 240 | hasComment := false 241 | // skip comment symbols themselves 242 | for i < size && isCommentRune(runes[i]) { 243 | i++ 244 | hasComment = true 245 | } 246 | 247 | if !hasComment { 248 | return nil 249 | } 250 | // and skip space again 251 | for i < size && unicode.IsSpace(runes[i]) { 252 | i++ 253 | } 254 | 255 | j := size - 1 256 | // skip suffix whitespace 257 | for j > i && unicode.IsSpace(runes[j]) { 258 | j-- 259 | } 260 | 261 | // empty comment 262 | if i >= size || j < 0 || i >= j { 263 | return emptyRunes[:] 264 | } 265 | 266 | return runes[i : j+1] 267 | } 268 | 269 | func startsWith(s, pr []rune) bool { 270 | // do not check length (it's checked above) 271 | for i, p := range pr { 272 | if unicode.ToUpper(s[i]) != p { 273 | return false 274 | } 275 | } 276 | 277 | return true 278 | } 279 | 280 | func parseToDoTitle(line []rune) (ctype, title, author []rune) { 281 | if len(line) == 0 { 282 | return nil, nil, nil 283 | } 284 | 285 | size := len(line) 286 | 287 | for _, pr := range commentPrefixes { 288 | prlen := len(pr) 289 | if size > prlen && startsWith(line, []rune(pr)) { 290 | i := prlen 291 | if unicode.IsLetter(line[i]) { 292 | continue 293 | } 294 | 295 | ctype = []rune(pr)[:prlen] 296 | 297 | if line[i] == '(' { 298 | for i < size && line[i] != ')' { 299 | i++ 300 | } 301 | 302 | author = line[prlen+1 : i] 303 | } 304 | 305 | for i < size && 306 | !unicode.IsSpace(line[i]) && 307 | line[i] != ':' { 308 | i++ 309 | } 310 | 311 | for i < size && (unicode.IsSpace(line[i]) || line[i] == ':') { 312 | i++ 313 | } 314 | 315 | if i < size { 316 | title = line[i:] 317 | return 318 | } 319 | } 320 | } 321 | 322 | return nil, nil, nil 323 | } 324 | 325 | // parseEstimate parses human-readible hours or minutes 326 | // estimate to float64 in hours 327 | func parseEstimate(estimate string) (float64, error) { 328 | if len(estimate) == 0 { 329 | return 0, errCannotParseEstimate 330 | } 331 | var s string 332 | last := rune(estimate[len(estimate)-1]) 333 | if unicode.IsLetter(last) && last != 'm' && last != 'h' { 334 | return 0, errCannotParseEstimate 335 | } 336 | 337 | if unicode.IsLetter(last) { 338 | s = estimate[:len(estimate)-1] 339 | } else { 340 | s = estimate 341 | } 342 | 343 | if f, err := strconv.ParseFloat(s, 64); err == nil { 344 | if last == 'm' { 345 | return f / 60.0, nil 346 | } 347 | return f, nil 348 | } 349 | return 0, errCannotParseEstimate 350 | } 351 | 352 | func (t *ToDoComment) parseIniProperties(line string) error { 353 | if !strings.Contains(line, "=") { 354 | return errCannotParseIni 355 | } 356 | ini := goini.New() 357 | err := ini.Parse([]byte(line), " ", "=") 358 | if err != nil { 359 | return err 360 | } 361 | if v, ok := ini.Get(categoryIniKey); ok { 362 | t.Category = v 363 | } 364 | if v, ok := ini.Get(authorIniKey); ok { 365 | if len(t.Author) == 0 { 366 | t.Author = v 367 | } 368 | } 369 | if v, ok := ini.Get(issueIniKey); ok { 370 | if i, err := strconv.Atoi(v); err == nil { 371 | t.Issue = i 372 | } 373 | } 374 | if v, ok := ini.Get(estimateIniKey); ok { 375 | if f, err := parseEstimate(v); err == nil { 376 | t.Estimate = f 377 | } 378 | } 379 | 380 | if len(t.Category) == 0 && 381 | t.Issue == 0 && 382 | t.Estimate < estimateEpsilon { 383 | return errCannotParseIni 384 | } 385 | return nil 386 | } 387 | 388 | // NewComment creates new task from parsed comment lines 389 | func NewComment(path string, lineNumber int, ctype, author string, body []string) *ToDoComment { 390 | if body == nil || len(body) == 0 { 391 | return nil 392 | } 393 | 394 | t := &ToDoComment{ 395 | Type: ctype, 396 | Title: body[0], 397 | File: path, 398 | Line: lineNumber, 399 | Author: author, 400 | } 401 | 402 | if len(body) > 1 { 403 | var commentBody string 404 | if err := t.parseIniProperties(body[1]); err == nil { 405 | commentBody = strings.Join(body[2:], "\n") 406 | } else { 407 | commentBody = strings.Join(body[1:], "\n") 408 | } 409 | t.Body = strings.TrimSpace(commentBody) 410 | } 411 | 412 | return t 413 | } 414 | 415 | func (td *ToDoGenerator) accountComment(path string, lineNumber int, ctype, author string, body []string) { 416 | 417 | relativePath, err := filepath.Rel(td.root, path) 418 | if err != nil { 419 | relativePath = path 420 | } 421 | c := NewComment(relativePath, lineNumber, ctype, author, body) 422 | if c != nil { 423 | td.commentsWG.Add(1) 424 | go td.addComment(c) 425 | } 426 | } 427 | 428 | func (td *ToDoGenerator) parseFile(path string) { 429 | defer td.commentsWG.Done() 430 | f, err := os.Open(path) 431 | if err != nil { 432 | log.Print(err) 433 | return 434 | } 435 | defer f.Close() 436 | scanner := bufio.NewScanner(f) 437 | var todo []string 438 | var lastType string 439 | var lastAuthor string 440 | var lastStart int 441 | lineNumber := 0 442 | for scanner.Scan() { 443 | line := scanner.Text() 444 | lineNumber++ 445 | if c := parseComment(line); c != nil { 446 | // current comment is new TODO-like commment 447 | if ctype, title, author := parseToDoTitle(c); title != nil { 448 | // do we need to finalize previous 449 | if lastType != "" { 450 | td.accountComment(path, lastStart+1, lastType, lastAuthor, todo) 451 | } 452 | // construct new one 453 | lastAuthor = string(author) 454 | lastType = string(ctype) 455 | lastStart = lineNumber - 1 456 | todo = make([]string, 0) 457 | todo = append(todo, string(title)) 458 | } else if lastType != "" { 459 | // continue consecutive comment line 460 | todo = append(todo, string(c)) 461 | } 462 | } else { 463 | // not a comment anymore: finalize 464 | if lastType != "" { 465 | td.accountComment(path, lastStart+1, lastType, lastAuthor, todo) 466 | lastType = "" 467 | } 468 | } 469 | } 470 | // detect todo item at the end of the file 471 | if lastType != "" { 472 | td.accountComment(path, lastStart+1, lastType, lastAuthor, todo) 473 | } 474 | } 475 | --------------------------------------------------------------------------------