├── LICENSE ├── README.md ├── main.go ├── testdata ├── main_test.go └── rules.json └── walker.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Iskander (Alex) Sharipov / Quasilyte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-namecheck 2 | 3 | Source code analyzer that helps you to maintain variable/field naming conventions inside your project. 4 | 5 | ## Quick start / Installation 6 | 7 | To install `go-namecheck` binary under your `$(go env GOPATH)/bin`: 8 | 9 | ```bash 10 | go get -v github.com/quasilyte/go-namecheck 11 | ``` 12 | 13 | If `$GOPATH/bin` is under your system `$PATH`, `go-namecheck` command should be available after that.
14 | This should print the help message: 15 | 16 | ```bash 17 | go-namecheck --help 18 | ``` 19 | 20 | In big teams, same things end up being called differently eventually. 21 | Sometimes you bring inconsistencies on your own. 22 | Suppose it's considered idiomatic to call `string` parameter `s` if 23 | you can't figure a more descriptive name, but sometimes you see `str` 24 | names used by other programmers from your team. 25 | This is where `go-namecheck` can help. 26 | 27 | For a better illustration, suppose we also want to catch regexp 28 | variables that use `re` prefix and propose `RE` suffix instead, 29 | so `var reFoo *regexp.Regexp` becomes `var fooRE *regexp.Regexp`. 30 | 31 | ```json 32 | { 33 | "string": {"param": {"str": "s"}}, 34 | "regexp\\.Regexp": { 35 | "local+global": {"^re[A-Z]\\w*$": "use RE suffix instead of re prefix"} 36 | } 37 | } 38 | ``` 39 | 40 | Rules above implement checks we described. 41 | 42 | First key describes regular expression that matches a type. 43 | For that key there is an object for scopes. 44 | Scope can be one of: 45 | 46 | * `param` - function input params 47 | * `receiver` - method receiver 48 | * `global` - any global constant or variable 49 | * `local` - any local constant or variable 50 | * `field` - struct field 51 | 52 | You can combine several scopes like `param+receiver+local`, etc. 53 | 54 | Inside a scope there is an JSON object that maps "from" => "to" pair. 55 | In the simplest form, it's a simple literal matching that suggests 56 | to replace one name with another, like in `str`=>`s` rule. 57 | Key can also be a regular expression, in this case, the "to" part 58 | does not describe exact substitution, but rather describes 59 | how to make name idiomatic (what to change). 60 | 61 | You start by creating your rules file (or borrowing someone else set). 62 | Then you can run `go-namecheck` like this: 63 | 64 | ```bash 65 | go-namecheck foo.go bar.go mypkg 66 | ``` 67 | 68 | You can also use `std`, `./...` and other conventional targets that are normally 69 | understood by Go tools. 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "go/ast" 8 | "go/token" 9 | "go/types" 10 | "io/ioutil" 11 | "log" 12 | "regexp" 13 | "strings" 14 | 15 | "github.com/go-toolsmith/pkgload" 16 | "golang.org/x/tools/go/packages" 17 | ) 18 | 19 | func main() { 20 | log.SetFlags(0) 21 | 22 | rulesFilename := flag.String("rules", "", 23 | `JSON file with naming convention rules`) 24 | verbose := flag.Bool("v", false, 25 | `turn on additional info message printing`) 26 | debug := flag.Bool("debug", false, 27 | `turn on detailed program execution info printing`) 28 | 29 | flag.Parse() 30 | 31 | targets := flag.Args() 32 | 33 | if *rulesFilename == "" { 34 | log.Fatalf("the -rules argument can't be empty") 35 | } 36 | if len(targets) == 0 { 37 | log.Fatalf("not enought positional args (empty targets list)") 38 | } 39 | 40 | ctxt := &context{ 41 | fset: token.NewFileSet(), 42 | verbose: *verbose, 43 | debug: *debug, 44 | } 45 | parseRules(ctxt, *rulesFilename) 46 | 47 | cfg := &packages.Config{ 48 | Mode: packages.LoadSyntax, 49 | Tests: true, 50 | Fset: ctxt.fset, 51 | } 52 | pkgs, err := packages.Load(cfg, targets...) 53 | if err != nil { 54 | log.Fatalf("load targets: %v", err) 55 | } 56 | 57 | // First pkgs traversal selects external tests and 58 | // packages built for testing. 59 | // If there is no tests for the package, 60 | // we're going to check them during the second traversal 61 | // which visits normal package if only it was 62 | // not checked during the first traversal. 63 | pkgload.VisitUnits(pkgs, func(u *pkgload.Unit) { 64 | if u.ExternalTest != nil { 65 | ctxt.checkPackage(u.ExternalTest) 66 | } 67 | if u.Test != nil { 68 | // Prefer tests to the base package, if present. 69 | ctxt.checkPackage(u.Test) 70 | } else { 71 | ctxt.checkPackage(u.Base) 72 | } 73 | }) 74 | } 75 | 76 | var generatedFileCommentRE = regexp.MustCompile("Code generated .* DO NOT EDIT.") 77 | 78 | type context struct { 79 | checkers struct { 80 | param []*nameChecker 81 | receiver []*nameChecker 82 | global []*nameChecker 83 | local []*nameChecker 84 | field []*nameChecker 85 | } 86 | 87 | fset *token.FileSet 88 | 89 | verbose bool 90 | debug bool 91 | } 92 | 93 | func (ctxt *context) checkPackage(pkg *packages.Package) { 94 | ctxt.infoPrintf("check %s", pkg.ID) 95 | 96 | emptyMatchers := &nameMatcherList{} 97 | 98 | type cacheKey struct { 99 | scopeSpan *[]*nameChecker 100 | typeString string 101 | } 102 | matchersCache := map[cacheKey]*nameMatcherList{} 103 | w := walker{ctxt: ctxt, pkg: pkg} 104 | for _, f := range pkg.Syntax { 105 | isGenerated := len(f.Comments) != 0 && 106 | generatedFileCommentRE.MatchString(f.Comments[0].Text()) 107 | if isGenerated { 108 | continue 109 | } 110 | 111 | w.visit = func(checkers *[]*nameChecker, id *ast.Ident) { 112 | typ := removePointers(pkg.TypesInfo.TypeOf(id)) 113 | typeString := types.TypeString(typ, types.RelativeTo(pkg.Types)) 114 | key := cacheKey{checkers, typeString} 115 | matchers, ok := matchersCache[key] 116 | switch { 117 | case ok && matchers == emptyMatchers: 118 | ctxt.debugPrintf("%s: cache hit (non-interesting)", typeString) 119 | case ok: 120 | ctxt.debugPrintf("%s: cache hit", typeString) 121 | default: 122 | ctxt.debugPrintf("%s: checkers full scan", typeString) 123 | for _, c := range *checkers { 124 | if c.typeRE.MatchString(typeString) { 125 | matchersCache[key] = c.matchers 126 | matchers = c.matchers 127 | break 128 | } 129 | } 130 | if matchers == nil { 131 | ctxt.debugPrintf("%s: mark as non-interesting", typeString) 132 | matchersCache[key] = emptyMatchers 133 | return 134 | } 135 | } 136 | 137 | for _, m := range matchers.list { 138 | if !m.Match(id.Name) { 139 | continue 140 | } 141 | fmt.Printf("%s: %s %s: %s\n", 142 | ctxt.fset.Position(id.Pos()), 143 | id.Name, 144 | typeString, 145 | m.Warning()) 146 | break 147 | } 148 | } 149 | w.walkNames(f) 150 | } 151 | } 152 | 153 | func (ctxt *context) debugPrintf(format string, args ...interface{}) { 154 | if ctxt.debug { 155 | log.Printf("\tdebug: "+format, args...) 156 | } 157 | } 158 | 159 | func (ctxt *context) infoPrintf(format string, args ...interface{}) { 160 | if ctxt.verbose { 161 | log.Printf("\tinfo: "+format, args...) 162 | } 163 | } 164 | 165 | func parseRules(ctxt *context, filename string) error { 166 | var config map[string]map[string]map[string]string 167 | data, err := ioutil.ReadFile(filename) 168 | if err != nil { 169 | log.Fatalf("read -rules JSON file: %v", err) 170 | } 171 | if err := json.Unmarshal(data, &config); err != nil { 172 | log.Fatalf("parse -rules JSON file: %v", err) 173 | } 174 | 175 | for pattern, nameMatcherScopes := range config { 176 | typeRE, err := regexp.Compile(pattern) 177 | if err != nil { 178 | log.Fatalf("decode rules: type regexp %q: %v", pattern, err) 179 | } 180 | 181 | for scopes, nameMatcherProps := range nameMatcherScopes { 182 | var litMatchers []*literalNameMatcher 183 | var reMatchers []*regexpNameMatcher 184 | 185 | for k, v := range nameMatcherProps { 186 | if regexp.QuoteMeta(k) == k { 187 | litMatchers = append(litMatchers, &literalNameMatcher{ 188 | from: k, 189 | warning: fmt.Sprintf("rename to %s", v), 190 | }) 191 | continue 192 | } 193 | re, err := regexp.Compile(k) 194 | if err != nil { 195 | log.Fatalf("decode rules: %q: %q: %v", pattern, k, err) 196 | } 197 | reMatchers = append(reMatchers, ®expNameMatcher{ 198 | re: re, 199 | warning: v, 200 | }) 201 | } 202 | 203 | // For performance reasons, we want literal matchers go first, 204 | // regexp matchers go after them. 205 | var list []nameMatcher 206 | for _, m := range litMatchers { 207 | list = append(list, m) 208 | } 209 | for _, m := range reMatchers { 210 | list = append(list, m) 211 | } 212 | 213 | checker := &nameChecker{ 214 | typeRE: typeRE, 215 | matchers: &nameMatcherList{list: list}, 216 | } 217 | 218 | for _, scope := range strings.Split(scopes, "+") { 219 | switch scope { 220 | case "param": 221 | ctxt.checkers.param = append(ctxt.checkers.param, checker) 222 | case "receiver": 223 | ctxt.checkers.receiver = append(ctxt.checkers.receiver, checker) 224 | case "global": 225 | ctxt.checkers.global = append(ctxt.checkers.global, checker) 226 | case "local": 227 | ctxt.checkers.local = append(ctxt.checkers.local, checker) 228 | case "field": 229 | ctxt.checkers.field = append(ctxt.checkers.field, checker) 230 | default: 231 | log.Fatalf("decode rules: %q: bad scope: %q", pattern, scope) 232 | } 233 | } 234 | } 235 | } 236 | 237 | return nil 238 | } 239 | 240 | type nameChecker struct { 241 | typeRE *regexp.Regexp 242 | matchers *nameMatcherList 243 | } 244 | 245 | type nameMatcherList struct { 246 | list []nameMatcher 247 | } 248 | 249 | type nameMatcher interface { 250 | Match(name string) bool 251 | Warning() string 252 | } 253 | 254 | type literalNameMatcher struct { 255 | from string 256 | warning string 257 | } 258 | 259 | func (m *literalNameMatcher) Match(name string) bool { 260 | return m.from == name 261 | } 262 | 263 | func (m *literalNameMatcher) Warning() string { return m.warning } 264 | 265 | type regexpNameMatcher struct { 266 | re *regexp.Regexp 267 | warning string 268 | } 269 | 270 | func (m *regexpNameMatcher) Match(name string) bool { 271 | return m.re.MatchString(name) 272 | } 273 | 274 | func (m *regexpNameMatcher) Warning() string { return m.warning } 275 | 276 | func removePointers(typ types.Type) types.Type { 277 | if ptr, ok := typ.(*types.Pointer); ok { 278 | return removePointers(ptr.Elem()) 279 | } 280 | return typ 281 | } 282 | -------------------------------------------------------------------------------- /testdata/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestBadParam1(test *testing.T) {} 6 | func TestBadParam2(tst *testing.T) {} 7 | 8 | func BenchmarkBadParam(bench *testing.B) {} 9 | -------------------------------------------------------------------------------- /testdata/rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "^context\\.Context$|^\\w+Context$|^[Cc]ontext$": { 3 | "param+field": {"ctx": "ctxt"} 4 | }, 5 | 6 | "regexp\\.Regexp": { 7 | "local+global": { 8 | "^re[A-Z]\\w*$": "use RE suffix instead of re prefix" 9 | } 10 | }, 11 | 12 | "testing.T": { 13 | "param": { 14 | "tst": "t", 15 | "test": "t" 16 | } 17 | }, 18 | "testing.B": { 19 | "param": { 20 | "t": "b", 21 | "bench": "b" 22 | } 23 | }, 24 | 25 | "^func\\(.*?\\)": { 26 | "param": {"f": "fn"} 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /walker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "go/ast" 5 | "go/token" 6 | 7 | "golang.org/x/tools/go/packages" 8 | ) 9 | 10 | type walker struct { 11 | ctxt *context 12 | pkg *packages.Package 13 | 14 | visit func(*[]*nameChecker, *ast.Ident) 15 | } 16 | 17 | func (w *walker) walkFunc(typ *ast.FuncType, body *ast.BlockStmt) { 18 | w.walkFieldList(&w.ctxt.checkers.param, typ.Params.List) 19 | // TODO(Quasilyte): add results scope and walk them? 20 | if body != nil { 21 | w.walkLocalNames(body) 22 | } 23 | } 24 | 25 | func (w *walker) walkNames(f *ast.File) { 26 | // TODO(Quasilyte): walk function param names 27 | // both in anonymous functions and in interface decls. 28 | 29 | for _, decl := range f.Decls { 30 | switch decl := decl.(type) { 31 | case *ast.FuncDecl: 32 | if decl.Recv != nil { 33 | w.walkFieldList(&w.ctxt.checkers.receiver, decl.Recv.List) 34 | } 35 | w.walkFunc(decl.Type, decl.Body) 36 | 37 | case *ast.GenDecl: 38 | w.walkGenDecl(&w.ctxt.checkers.global, decl) 39 | } 40 | } 41 | } 42 | 43 | func (w *walker) walkFieldList(checkers *[]*nameChecker, fields []*ast.Field) { 44 | for _, field := range fields { 45 | for _, id := range field.Names { 46 | w.visit(checkers, id) 47 | } 48 | } 49 | } 50 | 51 | func (w *walker) walkLocalNames(b *ast.BlockStmt) { 52 | ast.Inspect(b, func(x ast.Node) bool { 53 | switch x := x.(type) { 54 | case *ast.FuncLit: 55 | w.walkFunc(x.Type, x.Body) 56 | return false 57 | 58 | case *ast.AssignStmt: 59 | if x.Tok != token.DEFINE { 60 | return false 61 | } 62 | for _, lhs := range x.Lhs { 63 | id, ok := lhs.(*ast.Ident) 64 | if !ok || w.pkg.TypesInfo.Defs[id] == nil { 65 | continue 66 | } 67 | w.visit(&w.ctxt.checkers.local, id) 68 | } 69 | return false 70 | 71 | case *ast.GenDecl: 72 | w.walkGenDecl(&w.ctxt.checkers.local, x) 73 | return false 74 | } 75 | 76 | return true 77 | }) 78 | } 79 | 80 | func (w *walker) walkGenDecl(checkers *[]*nameChecker, decl *ast.GenDecl) { 81 | switch decl.Tok { 82 | case token.VAR, token.CONST: 83 | for _, spec := range decl.Specs { 84 | spec := spec.(*ast.ValueSpec) 85 | w.walkIdentList(checkers, spec.Names) 86 | } 87 | case token.TYPE: 88 | for _, spec := range decl.Specs { 89 | spec := spec.(*ast.TypeSpec) 90 | w.walkTypeExprNames(spec.Type) 91 | } 92 | } 93 | } 94 | 95 | func (w *walker) walkIdentList(checkers *[]*nameChecker, idents []*ast.Ident) { 96 | for _, id := range idents { 97 | w.visit(checkers, id) 98 | } 99 | } 100 | 101 | func (w *walker) walkTypeExprNames(e ast.Expr) { 102 | n, ok := e.(*ast.StructType) 103 | if !ok { 104 | return 105 | } 106 | for _, field := range n.Fields.List { 107 | if n, ok := field.Type.(*ast.StructType); ok { 108 | // Anonymous struct type. Need to visit its fields. 109 | w.walkTypeExprNames(n) 110 | continue 111 | } 112 | for _, id := range field.Names { 113 | w.visit(&w.ctxt.checkers.field, id) 114 | } 115 | } 116 | } 117 | --------------------------------------------------------------------------------