v0.2.0 92ac699

mocks.go RawHistoryFolder

1package analyzer
2
3import (
4	"go/ast"
5	"go/parser"
6	"go/token"
7	"go/types"
8	"os"
9	"path/filepath"
10	"sort"
11	"strconv"
12	"strings"
13	"sync"
14
15	"github.com/bnema/hexcheck/config"
16	"github.com/bnema/hexcheck/internal/glob"
17)
18
19type portInterface struct {
20	name  string
21	iface *types.Interface
22}
23
24func (r runner) checkMissingMocks(file *ast.File, filePath string, current config.Match) {
25	if current.Role != config.RolePorts {
26		return
27	}
28	ast.Inspect(file, func(n ast.Node) bool {
29		ts, ok := n.(*ast.TypeSpec)
30		if !ok {
31			return true
32		}
33		if _, ok := ts.Type.(*ast.InterfaceType); !ok {
34			return true
35		}
36		if !r.generatedMockExists(ts.Name.Name) {
37			r.report(ts.Name.Pos(), "missing-generated-mock-for-port", filePath, "port interface %s has no generated mock matching configured paths and name patterns", ts.Name.Name)
38		}
39		return true
40	})
41}
42
43func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
44	if !strings.HasSuffix(filePath, "_test.go") {
45		return
46	}
47
48	if current.Role == config.RoleUsecase || current.Role == config.RoleCore {
49		for _, imp := range file.Imports {
50			importPath, _ := strconv.Unquote(imp.Path.Value)
51			if match, ok := r.cfg.ComponentForPath(r.relImportPath(importPath)); ok && match.Role == config.RoleAdapter {
52				r.report(imp.Pos(), "prefer-generated-mocks", filePath, "test imports concrete adapter %s; prefer generated mocks for port dependencies", importPath)
53			}
54		}
55	}
56
57	portInterfaces := r.importedPortInterfaces(file)
58	if len(portInterfaces) == 0 {
59		return
60	}
61
62	ast.Inspect(file, func(n ast.Node) bool {
63		ts, ok := n.(*ast.TypeSpec)
64		if !ok {
65			return true
66		}
67
68		if !isTestDoubleName(ts.Name.Name) {
69			return true
70		}
71		obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName)
72		if !ok || obj.Type() == nil {
73			return true
74		}
75		for _, port := range portInterfaces {
76			if !r.generatedMockExists(port.name) {
77				continue
78			}
79			if types.Implements(obj.Type(), port.iface) || types.Implements(types.NewPointer(obj.Type()), port.iface) {
80				r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s with a generated mock available", ts.Name.Name, port.name)
81				break
82			}
83		}
84		return true
85	})
86}
87
88func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
89	var out []portInterface
90	for _, imp := range file.Imports {
91		pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName)
92		if !ok || pkgName.Imported() == nil {
93			continue
94		}
95		if match, ok := r.cfg.ComponentForPath(r.relImportPath(pkgName.Imported().Path())); !ok || match.Role != config.RolePorts {
96			continue
97		}
98		scope := pkgName.Imported().Scope()
99		for _, name := range scope.Names() {
100			obj, ok := scope.Lookup(name).(*types.TypeName)
101			if !ok {
102				continue
103			}
104			iface, ok := obj.Type().Underlying().(*types.Interface)
105			if ok {
106				out = append(out, portInterface{name: name, iface: iface.Complete()})
107			}
108		}
109	}
110	return out
111}
112
113var mockIndexCache sync.Map
114
115func (r runner) generatedMockExists(interfaceName string) bool {
116	if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
117		return false
118	}
119	index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
120	for _, name := range r.expectedMockNames(interfaceName) {
121		if index[name] {
122			return true
123		}
124	}
125	return false
126}
127
128func cachedMockIndex(root string, patterns []string) map[string]bool {
129	key := mockIndexKey(root, patterns)
130	if cached, ok := mockIndexCache.Load(key); ok {
131		return cached.(map[string]bool)
132	}
133	index := buildMockIndex(root, patterns)
134	actual, _ := mockIndexCache.LoadOrStore(key, index)
135	return actual.(map[string]bool)
136}
137
138func mockIndexKey(root string, patterns []string) string {
139	patterns = append([]string(nil), patterns...)
140	sort.Strings(patterns)
141	return root + "\x00" + strings.Join(patterns, "\x00")
142}
143
144func buildMockIndex(root string, patterns []string) map[string]bool {
145	index := map[string]bool{}
146	_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
147		if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") {
148			return nil
149		}
150		rel, err := filepath.Rel(root, path)
151		if err != nil {
152			return nil
153		}
154		rel = filepath.ToSlash(rel)
155		if !matchesAnyGlob(patterns, rel) {
156			return nil
157		}
158		for _, name := range declaredTypes(path) {
159			index[name] = true
160		}
161		return nil
162	})
163	return index
164}
165
166func declaredTypes(path string) []string {
167	file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
168	if err != nil {
169		return nil
170	}
171	var names []string
172	for _, decl := range file.Decls {
173		gen, ok := decl.(*ast.GenDecl)
174		if !ok || gen.Tok != token.TYPE {
175			continue
176		}
177		for _, spec := range gen.Specs {
178			ts, ok := spec.(*ast.TypeSpec)
179			if ok {
180				names = append(names, ts.Name.Name)
181			}
182		}
183	}
184	return names
185}
186
187func (r runner) expectedMockNames(interfaceName string) []string {
188	patterns := r.cfg.Mocking.GeneratedMockNamePatterns
189	if len(patterns) == 0 {
190		patterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
191	}
192	out := make([]string, 0, len(patterns))
193	for _, pattern := range patterns {
194		out = append(out, strings.ReplaceAll(pattern, "{{Interface}}", interfaceName))
195	}
196	return out
197}
198
199func matchesAnyGlob(patterns []string, path string) bool {
200	for _, pattern := range patterns {
201		if glob.Match(pattern, path) {
202			return true
203		}
204	}
205	return false
206}
207
208func isTestDoubleName(name string) bool {
209	return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub")
210}
211
212func hasWordPrefix(name, prefix string) bool {
213	if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214		return true
215	}
216	if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217		return false
218	}
219	return startsCamelWord(name[len(prefix):])
220}
221
222func hasWordSuffix(name, suffix string) bool {
223	if len(name) < len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) {
224		return false
225	}
226	prefix := name[:len(name)-len(suffix)]
227	return prefix == "" || strings.HasSuffix(prefix, "_")
228}
229
230func startsCamelWord(s string) bool {
231	if s == "" {
232		return true
233	}
234	r := rune(s[0])
235	return r == '_' || (r >= 'A' && r <= 'Z')
236}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close