Skip to content

Commit 7d2545d

Browse files
authored
Add nicer error handling on template compile errors (#21350)
There are repeated issues reported whereby users are unable to interpret the template errors. This PR adds some (somewhat complex) error handling to the panic recovery for template renderering but hopefully makes the interpretation of the error easier. Reference #21344 Signed-off-by: Andrew Thornton <[email protected]>
1 parent 56aabf3 commit 7d2545d

File tree

3 files changed

+225
-1
lines changed

3 files changed

+225
-1
lines changed

modules/templates/dynamic.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ func GetAsset(name string) ([]byte, error) {
3333
return os.ReadFile(filepath.Join(setting.StaticRootPath, name))
3434
}
3535

36+
// GetAssetFilename returns the filename of the provided asset
37+
func GetAssetFilename(name string) (string, error) {
38+
filename := filepath.Join(setting.CustomPath, name)
39+
_, err := os.Stat(filename)
40+
if err != nil && !os.IsNotExist(err) {
41+
return filename, err
42+
} else if err == nil {
43+
return filename, nil
44+
}
45+
46+
filename = filepath.Join(setting.StaticRootPath, name)
47+
_, err = os.Stat(filename)
48+
return filename, err
49+
}
50+
3651
// walkTemplateFiles calls a callback for each template asset
3752
func walkTemplateFiles(callback func(path, name string, d fs.DirEntry, err error) error) error {
3853
if err := walkAssetDir(filepath.Join(setting.CustomPath, "templates"), true, callback); err != nil && !os.IsNotExist(err) {

modules/templates/htmlrenderer.go

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
package templates
66

77
import (
8+
"bytes"
89
"context"
10+
"fmt"
11+
"regexp"
12+
"strconv"
13+
"strings"
914

1015
"code.gitea.io/gitea/modules/log"
1116
"code.gitea.io/gitea/modules/setting"
@@ -14,7 +19,14 @@ import (
1419
"github.com/unrolled/render"
1520
)
1621

17-
var rendererKey interface{} = "templatesHtmlRendereer"
22+
var (
23+
rendererKey interface{} = "templatesHtmlRenderer"
24+
25+
templateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
26+
notDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): function "(.*)" not defined`)
27+
unexpectedError = regexp.MustCompile(`^template: (.*):([0-9]+): unexpected "(.*)" in operand`)
28+
expectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): expected end; found (.*)`)
29+
)
1830

1931
// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
2032
func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
@@ -32,6 +44,25 @@ func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
3244
}
3345
log.Log(1, log.DEBUG, "Creating "+rendererType+" HTML Renderer")
3446

47+
compilingTemplates := true
48+
defer func() {
49+
if !compilingTemplates {
50+
return
51+
}
52+
53+
panicked := recover()
54+
if panicked == nil {
55+
return
56+
}
57+
58+
// OK try to handle the panic...
59+
err, ok := panicked.(error)
60+
if ok {
61+
handlePanicError(err)
62+
}
63+
log.Fatal("PANIC: Unable to compile templates!\n%v\n\nStacktrace:\n%s", panicked, log.Stack(2))
64+
}()
65+
3566
renderer := render.New(render.Options{
3667
Extensions: []string{".tmpl"},
3768
Directory: "templates",
@@ -42,6 +73,7 @@ func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
4273
IsDevelopment: false,
4374
DisableHTTPErrorRendering: true,
4475
})
76+
compilingTemplates = false
4577
if !setting.IsProd {
4678
watcher.CreateWatcher(ctx, "HTML Templates", &watcher.CreateWatcherOpts{
4779
PathsCallback: walkTemplateFiles,
@@ -50,3 +82,168 @@ func HTMLRenderer(ctx context.Context) (context.Context, *render.Render) {
5082
}
5183
return context.WithValue(ctx, rendererKey, renderer), renderer
5284
}
85+
86+
func handlePanicError(err error) {
87+
wrapFatal(handleNotDefinedPanicError(err))
88+
wrapFatal(handleUnexpected(err))
89+
wrapFatal(handleExpectedEnd(err))
90+
wrapFatal(handleGenericTemplateError(err))
91+
}
92+
93+
func wrapFatal(format string, args []interface{}) {
94+
if format == "" {
95+
return
96+
}
97+
log.FatalWithSkip(1, format, args...)
98+
}
99+
100+
func handleGenericTemplateError(err error) (string, []interface{}) {
101+
groups := templateError.FindStringSubmatch(err.Error())
102+
if len(groups) != 4 {
103+
return "", nil
104+
}
105+
106+
templateName, lineNumberStr, message := groups[1], groups[2], groups[3]
107+
108+
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
109+
if assetErr != nil {
110+
return "", nil
111+
}
112+
113+
lineNumber, _ := strconv.Atoi(lineNumberStr)
114+
115+
line := getLineFromAsset(templateName, lineNumber, "")
116+
117+
return "PANIC: Unable to compile templates!\n%s in template file %s at line %d:\n\n%s\nStacktrace:\n\n%s", []interface{}{message, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
118+
}
119+
120+
func handleNotDefinedPanicError(err error) (string, []interface{}) {
121+
groups := notDefinedError.FindStringSubmatch(err.Error())
122+
if len(groups) != 4 {
123+
return "", nil
124+
}
125+
126+
templateName, lineNumberStr, functionName := groups[1], groups[2], groups[3]
127+
128+
functionName, _ = strconv.Unquote(`"` + functionName + `"`)
129+
130+
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
131+
if assetErr != nil {
132+
return "", nil
133+
}
134+
135+
lineNumber, _ := strconv.Atoi(lineNumberStr)
136+
137+
line := getLineFromAsset(templateName, lineNumber, functionName)
138+
139+
return "PANIC: Unable to compile templates!\nUndefined function %q in template file %s at line %d:\n\n%s", []interface{}{functionName, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
140+
}
141+
142+
func handleUnexpected(err error) (string, []interface{}) {
143+
groups := unexpectedError.FindStringSubmatch(err.Error())
144+
if len(groups) != 4 {
145+
return "", nil
146+
}
147+
148+
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
149+
unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
150+
151+
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
152+
if assetErr != nil {
153+
return "", nil
154+
}
155+
156+
lineNumber, _ := strconv.Atoi(lineNumberStr)
157+
158+
line := getLineFromAsset(templateName, lineNumber, unexpected)
159+
160+
return "PANIC: Unable to compile templates!\nUnexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
161+
}
162+
163+
func handleExpectedEnd(err error) (string, []interface{}) {
164+
groups := expectedEndError.FindStringSubmatch(err.Error())
165+
if len(groups) != 4 {
166+
return "", nil
167+
}
168+
169+
templateName, lineNumberStr, unexpected := groups[1], groups[2], groups[3]
170+
171+
filename, assetErr := GetAssetFilename("templates/" + templateName + ".tmpl")
172+
if assetErr != nil {
173+
return "", nil
174+
}
175+
176+
lineNumber, _ := strconv.Atoi(lineNumberStr)
177+
178+
line := getLineFromAsset(templateName, lineNumber, unexpected)
179+
180+
return "PANIC: Unable to compile templates!\nMissing end with unexpected %q in template file %s at line %d:\n\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset)}
181+
}
182+
183+
const dashSeparator = "----------------------------------------------------------------------\n"
184+
185+
func getLineFromAsset(templateName string, targetLineNum int, target string) string {
186+
bs, err := GetAsset("templates/" + templateName + ".tmpl")
187+
if err != nil {
188+
return fmt.Sprintf("(unable to read template file: %v)", err)
189+
}
190+
191+
sb := &strings.Builder{}
192+
193+
// Write the header
194+
sb.WriteString(dashSeparator)
195+
196+
var lineBs []byte
197+
198+
// Iterate through the lines from the asset file to find the target line
199+
for start, currentLineNum := 0, 1; currentLineNum <= targetLineNum && start < len(bs); currentLineNum++ {
200+
// Find the next new line
201+
end := bytes.IndexByte(bs[start:], '\n')
202+
203+
// adjust the end to be a direct pointer in to []byte
204+
if end < 0 {
205+
end = len(bs)
206+
} else {
207+
end += start
208+
}
209+
210+
// set lineBs to the current line []byte
211+
lineBs = bs[start:end]
212+
213+
// move start to after the current new line position
214+
start = end + 1
215+
216+
// Write 2 preceding lines + the target line
217+
if targetLineNum-currentLineNum < 3 {
218+
_, _ = sb.Write(lineBs)
219+
_ = sb.WriteByte('\n')
220+
}
221+
}
222+
223+
// If there is a provided target to look for in the line add a pointer to it
224+
// e.g. ^^^^^^^
225+
if target != "" {
226+
idx := bytes.Index(lineBs, []byte(target))
227+
228+
if idx >= 0 {
229+
// take the current line and replace preceding text with whitespace (except for tab)
230+
for i := range lineBs[:idx] {
231+
if lineBs[i] != '\t' {
232+
lineBs[i] = ' '
233+
}
234+
}
235+
236+
// write the preceding "space"
237+
_, _ = sb.Write(lineBs[:idx])
238+
239+
// Now write the ^^ pointer
240+
_, _ = sb.WriteString(strings.Repeat("^", len(target)))
241+
_ = sb.WriteByte('\n')
242+
}
243+
}
244+
245+
// Finally write the footer
246+
sb.WriteString(dashSeparator)
247+
248+
return sb.String()
249+
}

modules/templates/static.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ func GlobalModTime(filename string) time.Time {
3131
return timeutil.GetExecutableModTime()
3232
}
3333

34+
// GetAssetFilename returns the filename of the provided asset
35+
func GetAssetFilename(name string) (string, error) {
36+
filename := filepath.Join(setting.CustomPath, name)
37+
_, err := os.Stat(filename)
38+
if err != nil && !os.IsNotExist(err) {
39+
return name, err
40+
} else if err == nil {
41+
return filename, nil
42+
}
43+
return "(builtin) " + name, nil
44+
}
45+
3446
// GetAsset get a special asset, only for chi
3547
func GetAsset(name string) ([]byte, error) {
3648
bs, err := os.ReadFile(filepath.Join(setting.CustomPath, name))

0 commit comments

Comments
 (0)