// Copyright 2024 The Hugo Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable lfmtaw or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package herrors import ( "encoding/json" "errors" "fmt" "io" "path/filepath" "github.com/bep/godartsass/v2" "github.com/bep/golibsass/libsass/libsasserrors" "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/text" "github.com/pelletier/go-toml/v2" "github.com/spf13/afero" "github.com/tdewolff/parse/v2" ) // FileError represents an error when handling a file: Parsing a config file, // execute a template etc. type FileError interface { error // ErrorContext holds some context information about the error. ErrorContext() *ErrorContext text.Positioner // UpdatePosition updates the position of the error. UpdatePosition(pos text.Position) FileError // UpdateContent updates the error with a new ErrorContext from the content of the file. UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError // SetFilename sets the filename of the error. SetFilename(filename string) FileError } // Unwrapper can unwrap errors created with fmt.Errorf. type Unwrapper interface { Unwrap() error } var ( _ FileError = (*fileError)(nil) _ Unwrapper = (*fileError)(nil) ) func (fe *fileError) SetFilename(filename string) FileError { fe.position.Filename = filename return fe } func (fe *fileError) UpdatePosition(pos text.Position) FileError { oldFilename := fe.Position().Filename if pos.Filename != "" && fe.fileType == "" { _, fe.fileType = paths.FileAndExtNoDelimiter(filepath.Clean(pos.Filename)) } if pos.Filename == "" { pos.Filename = oldFilename } fe.position = pos return fe } func (fe *fileError) UpdateContent(r io.Reader, linematcher LineMatcherFn) FileError { if linematcher == nil { linematcher = SimpleLineMatcher } var ( posle = fe.position ectx *ErrorContext ) if posle.LineNumber <= 1 && posle.Offset > 0 { // Try to locate the line number from the content if offset is set. ectx = locateError(r, fe, func(m LineMatcher) int { if posle.Offset >= m.Offset && posle.Offset < m.Offset+len(m.Line) { lno := posle.LineNumber - m.Position.LineNumber + m.LineNumber m.Position = text.Position{LineNumber: lno} return linematcher(m) } return -1 }) } else { ectx = locateError(r, fe, linematcher) } if ectx.ChromaLexer == "" { if fe.fileType != "" { ectx.ChromaLexer = chromaLexerFromType(fe.fileType) } else { ectx.ChromaLexer = chromaLexerFromFilename(fe.Position().Filename) } } fe.errorContext = ectx if ectx.Position.LineNumber > 0 { fe.position.LineNumber = ectx.Position.LineNumber } if ectx.Position.ColumnNumber > 0 { fe.position.ColumnNumber = ectx.Position.ColumnNumber } return fe } type fileError struct { position text.Position errorContext *ErrorContext fileType string cause error } func (e *fileError) ErrorContext() *ErrorContext { return e.errorContext } // Position returns the text position of this error. func (e fileError) Position() text.Position { return e.position } func (e *fileError) Error() string { return fmt.Sprintf("%s: %s", e.position, e.causeString()) } func (e *fileError) causeString() string { if e.cause == nil { return "" } switch v := e.cause.(type) { // Avoid repeating the file info in the error message. case godartsass.SassError: return v.Message case libsasserrors.Error: return v.Message default: return v.Error() } } func (e *fileError) Unwrap() error { return e.cause } // NewFileError creates a new FileError that wraps err. // It will try to extract the filename and line number from err. func NewFileError(err error) FileError { // Filetype is used to determine the Chroma lexer to use. fileType, pos := extractFileTypePos(err) return &fileError{cause: err, fileType: fileType, position: pos} } // NewFileErrorFromName creates a new FileError that wraps err. // The value for name should identify the file, the best // being the full filename to the file on disk. func NewFileErrorFromName(err error, name string) FileError { // Filetype is used to determine the Chroma lexer to use. fileType, pos := extractFileTypePos(err) pos.Filename = name if fileType == "" { _, fileType = paths.FileAndExtNoDelimiter(filepath.Clean(name)) } return &fileError{cause: err, fileType: fileType, position: pos} } // NewFileErrorFromPos will use the filename and line number from pos to create a new FileError, wrapping err. func NewFileErrorFromPos(err error, pos text.Position) FileError { // Filetype is used to determine the Chroma lexer to use. fileType, _ := extractFileTypePos(err) if fileType == "" { _, fileType = paths.FileAndExtNoDelimiter(filepath.Clean(pos.Filename)) } return &fileError{cause: err, fileType: fileType, position: pos} } func NewFileErrorFromFileInErr(err error, fs afero.Fs, linematcher LineMatcherFn) FileError { fe := NewFileError(err) pos := fe.Position() if pos.Filename == "" { return fe } f, realFilename, err2 := openFile(pos.Filename, fs) if err2 != nil { return fe } pos.Filename = realFilename defer f.Close() return fe.UpdateContent(f, linematcher) } func NewFileErrorFromFileInPos(err error, pos text.Position, fs afero.Fs, linematcher LineMatcherFn) FileError { if err == nil { panic("err is nil") } f, realFilename, err2 := openFile(pos.Filename, fs) if err2 != nil { return NewFileErrorFromPos(err, pos) } pos.Filename = realFilename defer f.Close() return NewFileErrorFromPos(err, pos).UpdateContent(f, linematcher) } // NewFileErrorFromFile is a convenience method to create a new FileError from a file. func NewFileErrorFromFile(err error, filename string, fs afero.Fs, linematcher LineMatcherFn) FileError { if err == nil { panic("err is nil") } f, realFilename, err2 := openFile(filename, fs) if err2 != nil { return NewFileErrorFromName(err, realFilename) } defer f.Close() return NewFileErrorFromName(err, realFilename).UpdateContent(f, linematcher) } func openFile(filename string, fs afero.Fs) (afero.File, string, error) { realFilename := filename // We want the most specific filename possible in the error message. fi, err2 := fs.Stat(filename) if err2 == nil { if s, ok := fi.(interface { Filename() string }); ok { realFilename = s.Filename() } } f, err2 := fs.Open(filename) if err2 != nil { return nil, realFilename, err2 } return f, realFilename, nil } // Cause returns the underlying error or itself if it does not implement Unwrap. func Cause(err error) error { if u := errors.Unwrap(err); u != nil { return u } return err } func extractFileTypePos(err error) (string, text.Position) { err = Cause(err) var fileType string // LibSass, DartSass if pos := extractPosition(err); pos.LineNumber > 0 || pos.Offset > 0 { _, fileType = paths.FileAndExtNoDelimiter(pos.Filename) return fileType, pos } // Default to line 1 col 1 if we don't find any better. pos := text.Position{ Offset: -1, LineNumber: 1, ColumnNumber: 1, } // JSON errors. offset, typ := extractOffsetAndType(err) if fileType == "" { fileType = typ } if offset >= 0 { pos.Offset = offset } // The error type from the minifier contains line number and column number. if line, col := extractLineNumberAndColumnNumber(err); line >= 0 { pos.LineNumber = line pos.ColumnNumber = col return fileType, pos } // Look in the error message for the line number. for _, handle := range lineNumberExtractors { lno, col := handle(err) if lno > 0 { pos.ColumnNumber = col pos.LineNumber = lno break } } if fileType == "" && pos.Filename != "" { _, fileType = paths.FileAndExtNoDelimiter(pos.Filename) } return fileType, pos } // UnwrapFileError tries to unwrap a FileError from err. // It returns nil if this is not possible. func UnwrapFileError(err error) FileError { for err != nil { switch v := err.(type) { case FileError: return v default: err = errors.Unwrap(err) } } return nil } // UnwrapFileErrors tries to unwrap all FileError. func UnwrapFileErrors(err error) []FileError { var errs []FileError for err != nil { if v, ok := err.(FileError); ok { errs = append(errs, v) } err = errors.Unwrap(err) } return errs } // UnwrapFileErrorsWithErrorContext tries to unwrap all FileError in err that has an ErrorContext. func UnwrapFileErrorsWithErrorContext(err error) []FileError { var errs []FileError for err != nil { if v, ok := err.(FileError); ok && v.ErrorContext() != nil { errs = append(errs, v) } err = errors.Unwrap(err) } return errs } func extractOffsetAndType(e error) (int, string) { switch v := e.(type) { case *json.UnmarshalTypeError: return int(v.Offset), "json" case *json.SyntaxError: return int(v.Offset), "json" default: return -1, "" } } func extractLineNumberAndColumnNumber(e error) (int, int) { switch v := e.(type) { case *parse.Error: return v.Line, v.Column case *toml.DecodeError: return v.Position() } return -1, -1 } func extractPosition(e error) (pos text.Position) { switch v := e.(type) { case godartsass.SassError: span := v.Span start := span.Start filename, _ := paths.UrlStringToFilename(span.Url) pos.Filename = filename pos.Offset = start.Offset pos.ColumnNumber = start.Column case libsasserrors.Error: pos.Filename = v.File pos.LineNumber = v.Line pos.ColumnNumber = v.Column } return } // TextSegmentError is an error with a text segment attached. type TextSegmentError struct { Segment string Err error } func (e TextSegmentError) Unwrap() error { return e.Err } func (e TextSegmentError) Error() string { return e.Err.Error() }