validate.go
220 lines1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
package application
import (
"fmt"
"io/fs"
"reflect"
"strings"
"text/template/parse"
)
// builtins are text/template's predeclared functions. They are always valid call
// targets, so the validator must not flag them.
var builtins = map[string]bool{
"and": true, "call": true, "html": true, "index": true, "slice": true,
"js": true, "len": true, "not": true, "or": true, "print": true,
"printf": true, "println": true, "urlquery": true,
"eq": true, "ge": true, "gt": true, "le": true, "lt": true, "ne": true,
}
// Validate walks every template — layouts, partials, and view files — and reports
// references that cannot resolve: a method or field on a controller accessor that
// the controller does not have, or a template that failed to parse (for example
// one still calling {{gone.Method}} after its controller was deleted).
//
// It is deliberately conservative. It only reports a reference it can prove is
// broken and stays silent on anything whose type it cannot determine — notably
// raw {{.Field}} access on render data, whose type is not known until execution.
// That makes a non-empty result trustworthy: it means a genuinely broken
// template, which is why New treats it as fatal in production, and why this is
// exported so a test can assert zero issues and gate a bad build in CI too.
func (app *App) Validate() []error {
known := app.knownFuncs()
issues := append([]error(nil), app.parseErrors...)
// Layouts and partials are parsed into the base at boot.
for _, t := range app.base.Templates() {
issues = append(issues, validateTree(app, t.Name(), t.Tree, known)...)
}
// View files are parsed on demand at render time; parse each now so the
// validator sees the templates users actually hit.
for _, name := range app.viewFiles() {
tmpl, err := app.loadView(name)
if err != nil {
issues = append(issues, fmt.Errorf("view %q: %w", name, err))
continue
}
if t := tmpl.Lookup(name); t != nil {
issues = append(issues, validateTree(app, name, t.Tree, known)...)
}
}
return issues
}
// viewFiles lists the standalone view files under views/ — everything that is not
// a layout, partial, or static asset, addressed by its path relative to views/.
func (ve *ViewEngine) viewFiles() []string {
var files []string
fs.WalkDir(ve.fs, "views", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
rel := strings.TrimPrefix(path, "views/")
if strings.HasPrefix(rel, "layouts/") || strings.HasPrefix(rel, "partials/") || strings.HasPrefix(rel, "static/") {
return nil
}
files = append(files, rel)
return nil
})
return files
}
// knownFuncs is every name valid in call position: the template builtins, the
// framework-injected funcs, every user-registered func, and every controller
// (each is a niladic accessor func in a template).
func (app *App) knownFuncs() map[string]bool {
known := map[string]bool{"dict": true, "nonce": true, "frontend_script": true}
for name := range builtins {
known[name] = true
}
for name := range app.funcs {
known[name] = true
}
for name := range app.controllers {
known[name] = true
}
return known
}
// validateTree walks one parse tree and returns the references it can prove broken.
func validateTree(app *App, name string, tree *parse.Tree, known map[string]bool) []error {
if tree == nil || tree.Root == nil {
return nil
}
v := &viewValidator{app: app, known: known, tmpl: name}
v.walk(tree.Root)
return v.issues
}
type viewValidator struct {
app *App
known map[string]bool
tmpl string
issues []error
}
func (v *viewValidator) errf(format string, args ...any) {
v.issues = append(v.issues, fmt.Errorf("template %q: "+format, append([]any{v.tmpl}, args...)...))
}
// walk recurses the parse tree, validating every pipeline it finds.
func (v *viewValidator) walk(node parse.Node) {
switch n := node.(type) {
case *parse.ListNode:
if n == nil {
return
}
for _, child := range n.Nodes {
v.walk(child)
}
case *parse.ActionNode:
v.pipe(n.Pipe)
case *parse.IfNode:
v.pipe(n.Pipe)
v.walk(n.List)
v.walk(n.ElseList)
case *parse.RangeNode:
v.pipe(n.Pipe)
v.walk(n.List)
v.walk(n.ElseList)
case *parse.WithNode:
v.pipe(n.Pipe)
v.walk(n.List)
v.walk(n.ElseList)
case *parse.TemplateNode:
v.pipe(n.Pipe)
}
}
// pipe validates every command in a pipeline.
func (v *viewValidator) pipe(p *parse.PipeNode) {
if p == nil {
return
}
for _, cmd := range p.Cmds {
for _, arg := range cmd.Args {
v.arg(arg)
}
}
}
// arg validates a single command argument. It only ever reports a reference it
// can prove wrong; unknown shapes are left alone.
func (v *viewValidator) arg(node parse.Node) {
switch n := node.(type) {
case *parse.IdentifierNode:
if !v.known[n.Ident] {
v.errf("calls unknown function %q", n.Ident)
}
case *parse.ChainNode:
v.chain(n)
case *parse.PipeNode:
v.pipe(n)
}
}
// chain validates X.Field.Field... where X is a term. It only checks chains
// rooted at a controller accessor — the {{auth.CurrentUser}} pattern — because
// those are the ones whose types are known. Anything else it leaves alone.
func (v *viewValidator) chain(n *parse.ChainNode) {
ident, ok := n.Node.(*parse.IdentifierNode)
if !ok {
return // rooted at a variable, dot, or paren expr — not our concern
}
if !v.known[ident.Ident] {
v.errf("calls unknown function %q", ident.Ident)
return
}
ctrl, ok := v.app.controllers[ident.Ident]
if !ok {
return // a known func, but not a controller — cannot introspect its result
}
t := reflect.TypeOf(ctrl)
for _, field := range n.Field {
next, ok := memberType(t, field)
if !ok {
v.errf("%s has no method or field %q", ident.Ident, field)
return
}
if next == nil {
return // resolved, but result not introspectable — stop checking
}
t = next
}
}
// memberType resolves a method or field named member on type t. It returns the
// result type (for chaining) and whether the member exists. A nil type with ok
// true means "exists, but not worth introspecting further" (a method with no
// usable return, or a non-struct result), which stops chain validation cleanly.
func memberType(t reflect.Type, member string) (reflect.Type, bool) {
if t == nil {
return nil, false
}
if m, ok := t.MethodByName(member); ok {
if m.Type.NumOut() == 0 {
return nil, true
}
return m.Type.Out(0), true
}
st := t
if st.Kind() == reflect.Pointer {
st = st.Elem()
}
if st.Kind() == reflect.Struct {
if f, ok := st.FieldByName(member); ok {
return f.Type, true
}
}
return nil, false
}