v0.4.0 21d1792

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

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