1// Copyright 2018 The Hugo Authors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14// Package herrors contains common Hugo errors and error related utilities.
15package herrors
16
17import (
18	"io"
19	"io/ioutil"
20	"path/filepath"
21	"strings"
22
23	"github.com/gohugoio/hugo/common/text"
24
25	"github.com/spf13/afero"
26)
27
28// LineMatcher contains the elements used to match an error to a line
29type LineMatcher struct {
30	Position text.Position
31	Error    error
32
33	LineNumber int
34	Offset     int
35	Line       string
36}
37
38// LineMatcherFn is used to match a line with an error.
39type LineMatcherFn func(m LineMatcher) bool
40
41// SimpleLineMatcher simply matches by line number.
42var SimpleLineMatcher = func(m LineMatcher) bool {
43	return m.Position.LineNumber == m.LineNumber
44}
45
46var _ text.Positioner = ErrorContext{}
47
48// ErrorContext contains contextual information about an error. This will
49// typically be the lines surrounding some problem in a file.
50type ErrorContext struct {
51
52	// If a match will contain the matched line and up to 2 lines before and after.
53	// Will be empty if no match.
54	Lines []string
55
56	// The position of the error in the Lines above. 0 based.
57	LinesPos int
58
59	position text.Position
60
61	// The lexer to use for syntax highlighting.
62	// https://gohugo.io/content-management/syntax-highlighting/#list-of-chroma-highlighting-languages
63	ChromaLexer string
64}
65
66// Position returns the text position of this error.
67func (e ErrorContext) Position() text.Position {
68	return e.position
69}
70
71var _ causer = (*ErrorWithFileContext)(nil)
72
73// ErrorWithFileContext is an error with some additional file context related
74// to that error.
75type ErrorWithFileContext struct {
76	cause error
77	ErrorContext
78}
79
80func (e *ErrorWithFileContext) Error() string {
81	pos := e.Position()
82	if pos.IsValid() {
83		return pos.String() + ": " + e.cause.Error()
84	}
85	return e.cause.Error()
86}
87
88func (e *ErrorWithFileContext) Cause() error {
89	return e.cause
90}
91
92// WithFileContextForFile will try to add a file context with lines matching the given matcher.
93// If no match could be found, the original error is returned with false as the second return value.
94func WithFileContextForFile(e error, realFilename, filename string, fs afero.Fs, matcher LineMatcherFn) (error, bool) {
95	f, err := fs.Open(filename)
96	if err != nil {
97		return e, false
98	}
99	defer f.Close()
100	return WithFileContext(e, realFilename, f, matcher)
101}
102
103// WithFileContextForFileDefault tries to add file context using the default line matcher.
104func WithFileContextForFileDefault(err error, filename string, fs afero.Fs) error {
105	err, _ = WithFileContextForFile(
106		err,
107		filename,
108		filename,
109		fs,
110		SimpleLineMatcher)
111	return err
112}
113
114// WithFileContextForFile will try to add a file context with lines matching the given matcher.
115// If no match could be found, the original error is returned with false as the second return value.
116func WithFileContext(e error, realFilename string, r io.Reader, matcher LineMatcherFn) (error, bool) {
117	if e == nil {
118		panic("error missing")
119	}
120	le := UnwrapFileError(e)
121
122	if le == nil {
123		var ok bool
124		if le, ok = ToFileError("", e).(FileError); !ok {
125			return e, false
126		}
127	}
128
129	var errCtx ErrorContext
130
131	posle := le.Position()
132
133	if posle.Offset != -1 {
134		errCtx = locateError(r, le, func(m LineMatcher) bool {
135			if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) {
136				lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber
137				m.Position = text.Position{LineNumber: lno}
138			}
139			return matcher(m)
140		})
141	} else {
142		errCtx = locateError(r, le, matcher)
143	}
144
145	pos := &errCtx.position
146
147	if pos.LineNumber == -1 {
148		return e, false
149	}
150
151	pos.Filename = realFilename
152
153	if le.Type() != "" {
154		errCtx.ChromaLexer = chromaLexerFromType(le.Type())
155	} else {
156		errCtx.ChromaLexer = chromaLexerFromFilename(realFilename)
157	}
158
159	return &ErrorWithFileContext{cause: e, ErrorContext: errCtx}, true
160}
161
162// UnwrapErrorWithFileContext tries to unwrap an ErrorWithFileContext from err.
163// It returns nil if this is not possible.
164func UnwrapErrorWithFileContext(err error) *ErrorWithFileContext {
165	for err != nil {
166		switch v := err.(type) {
167		case *ErrorWithFileContext:
168			return v
169		case causer:
170			err = v.Cause()
171		default:
172			return nil
173		}
174	}
175	return nil
176}
177
178func chromaLexerFromType(fileType string) string {
179	switch fileType {
180	case "html", "htm":
181		return "go-html-template"
182	}
183	return fileType
184}
185
186func extNoDelimiter(filename string) string {
187	return strings.TrimPrefix(filepath.Ext(filename), ".")
188}
189
190func chromaLexerFromFilename(filename string) string {
191	if strings.Contains(filename, "layouts") {
192		return "go-html-template"
193	}
194
195	ext := extNoDelimiter(filename)
196	return chromaLexerFromType(ext)
197}
198
199func locateErrorInString(src string, matcher LineMatcherFn) ErrorContext {
200	return locateError(strings.NewReader(src), &fileError{}, matcher)
201}
202
203func locateError(r io.Reader, le FileError, matches LineMatcherFn) ErrorContext {
204	if le == nil {
205		panic("must provide an error")
206	}
207
208	errCtx := ErrorContext{position: text.Position{LineNumber: -1, ColumnNumber: 1, Offset: -1}, LinesPos: -1}
209
210	b, err := ioutil.ReadAll(r)
211	if err != nil {
212		return errCtx
213	}
214
215	pos := &errCtx.position
216	lepos := le.Position()
217
218	lines := strings.Split(string(b), "\n")
219
220	if lepos.ColumnNumber >= 0 {
221		pos.ColumnNumber = lepos.ColumnNumber
222	}
223
224	lineNo := 0
225	posBytes := 0
226
227	for li, line := range lines {
228		lineNo = li + 1
229		m := LineMatcher{
230			Position:   le.Position(),
231			Error:      le,
232			LineNumber: lineNo,
233			Offset:     posBytes,
234			Line:       line,
235		}
236		if errCtx.LinesPos == -1 && matches(m) {
237			pos.LineNumber = lineNo
238			break
239		}
240
241		posBytes += len(line)
242	}
243
244	if pos.LineNumber != -1 {
245		low := pos.LineNumber - 3
246		if low < 0 {
247			low = 0
248		}
249
250		if pos.LineNumber > 2 {
251			errCtx.LinesPos = 2
252		} else {
253			errCtx.LinesPos = pos.LineNumber - 1
254		}
255
256		high := pos.LineNumber + 2
257		if high > len(lines) {
258			high = len(lines)
259		}
260
261		errCtx.Lines = lines[low:high]
262
263	}
264
265	return errCtx
266}
267