Skip to content

Commit 9b04b5e

Browse files
committed
Add nicer error handling on template compile errors
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 go-gitea#21344 Signed-off-by: Andrew Thornton <[email protected]>
1 parent 2d2cf58 commit 9b04b5e

File tree

3 files changed

+197
-1
lines changed

3 files changed

+197
-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: 171 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: %v\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,141 @@ 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.Fatal(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 due to: %s in template file %s at line %d:\n%s\nStacktrace:\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 due to undefined function %q in template file %s at line %d:\n%s\nStacktrace:\n%s", []interface{}{functionName, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
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 due to unexpected %q in template file %s at line %d:\n%s\nStacktrace:\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
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 due to missing end with unexpected %q in template file %s at line %d:\n%s\nStacktrace:\n%s", []interface{}{unexpected, filename, lineNumber, log.NewColoredValue(line, log.Reset), log.Stack(2)}
181+
}
182+
183+
func getLineFromAsset(templateName string, lineNumber int, functionName string) string {
184+
bs, err := GetAsset("templates/" + templateName + ".tmpl")
185+
if err != nil {
186+
return fmt.Sprintf("(unable to read template file: %v)", err)
187+
}
188+
189+
sb := &strings.Builder{}
190+
start := 0
191+
var lineBs []byte
192+
for i := 0; i < lineNumber && start < len(bs); i++ {
193+
end := bytes.IndexByte(bs[start:], '\n')
194+
if end < 0 {
195+
end = len(bs)
196+
} else {
197+
end += start
198+
}
199+
lineBs = bs[start:end]
200+
if lineNumber-i < 4 {
201+
_, _ = sb.Write(lineBs)
202+
_ = sb.WriteByte('\n')
203+
}
204+
start = end + 1
205+
}
206+
207+
if functionName != "" {
208+
idx := strings.Index(string(lineBs), functionName)
209+
for i := range lineBs[:idx] {
210+
if lineBs[i] != '\t' {
211+
lineBs[i] = ' '
212+
}
213+
}
214+
_, _ = sb.Write(lineBs[:idx])
215+
if idx >= 0 {
216+
_, _ = sb.WriteString(strings.Repeat("^", len(functionName)))
217+
}
218+
_ = sb.WriteByte('\n')
219+
}
220+
221+
return strings.Repeat("-", 70) + "\n" + sb.String() + strings.Repeat("-", 70)
222+
}

modules/templates/static.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ 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+
_, err := os.Stat(filepath.Join(setting.CustomPath, name))
37+
if err != nil && !os.IsNotExist(err) {
38+
return name, err
39+
} else if err == nil {
40+
return filepath.Join(setting.CustomPath, name), nil
41+
}
42+
return "(builtin) " + name, nil
43+
}
44+
3445
// GetAsset get a special asset, only for chi
3546
func GetAsset(name string) ([]byte, error) {
3647
bs, err := os.ReadFile(filepath.Join(setting.CustomPath, name))

0 commit comments

Comments
 (0)