Simplify and improve stderr parsing

- Allow multiple errors and warnings
- Remove ErrorWithPath, which is now replaced by Error
- Simplify parsing, allow multiple errors
- Add more tests for stderr parsing
This commit is contained in:
David Vogel 2025-02-24 22:32:27 +01:00
parent 188f5c36cb
commit 648c449890
2 changed files with 166 additions and 83 deletions

119
errors.go
View File

@ -1,4 +1,4 @@
// Copyright (c) 2024 David Vogel // Copyright (c) 2024-2025 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -8,14 +8,26 @@ package typst
import ( import (
"regexp" "regexp"
"strconv" "strconv"
"strings"
) )
// Error represents a generic typst error. // ErrorDetails contains the details of a typst.Error.
type ErrorDetails struct {
Message string // The parsed error message.
Path string // Path of the typst file where the error is located in. Zero value means that there is no further information.
Line int // Line number of the error. Zero value means that there is no further information.
Column int // Column of the error. Zero value means that there is no further information.
}
// Error represents a typst error.
// This can contain multiple sub-errors or sub-warnings.
type Error struct { type Error struct {
Inner error Inner error
Raw string // The raw output from stderr. Raw string // The raw output from stderr.
Message string // The parsed error message.
// Raw output parsed into errors and warnings.
Details []ErrorDetails
} }
func (e *Error) Error() string { func (e *Error) Error() string {
@ -26,72 +38,43 @@ func (e *Error) Unwrap() error {
return e.Inner return e.Inner
} }
// ErrorWithPath represents a typst error that also contains information about its origin (filepath, line and column). var stderrRegex = regexp.MustCompile(`(?s)^(?<error>.+?)(?:(?:\n\s+┌─ (?<path>.+?):(?<line>\d+):(?<column>\d+)\n)|(?:$))`)
type ErrorWithPath struct {
Inner error
Raw string // The raw error string as returned by the executable. // ParseStderr will parse the given stderr output and return a typst.Error.
Message string // Error message from typst.
Path string // Path of the typst file where the error is located in.
Line int // Line number of the error.
Column int // Column of the error.
}
func (e *ErrorWithPath) Error() string {
return e.Raw
}
func (e *ErrorWithPath) Unwrap() error {
return e.Inner
}
var stderrRegex = regexp.MustCompile(`(?s)^error: (?<error>.+?)\n`)
var stderrWithPathRegex = regexp.MustCompile(`(?s)^error: (?<error>.+?)\n\s+┌─ (?<path>.+?):(?<line>\d+):(?<column>\d+)\n`)
// ParseStderr will parse the given stderr output and return a suitable error object.
// Depending on the stderr message, this will return either a typst.Error or a typst.ErrorWithPath error.
func ParseStderr(stderr string, inner error) error { func ParseStderr(stderr string, inner error) error {
if parsed := stderrWithPathRegex.FindStringSubmatch(stderr); parsed != nil { err := Error{
err := ErrorWithPath{
Raw: stderr,
Inner: inner,
}
if i := stderrWithPathRegex.SubexpIndex("error"); i > 0 && i < len(parsed) {
err.Message = parsed[i]
}
if i := stderrWithPathRegex.SubexpIndex("path"); i > 0 && i < len(parsed) {
err.Path = parsed[i]
}
if i := stderrWithPathRegex.SubexpIndex("line"); i > 0 && i < len(parsed) {
line, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Line = int(line)
}
if i := stderrWithPathRegex.SubexpIndex("column"); i > 0 && i < len(parsed) {
column, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Column = int(column)
}
return &err
}
if parsed := stderrRegex.FindStringSubmatch(stderr); parsed != nil {
err := Error{
Raw: stderr,
Inner: inner,
}
if i := stderrRegex.SubexpIndex("error"); i > 0 && i < len(parsed) {
err.Message = parsed[i]
}
return &err
}
// Fall back to the raw error message.
return &Error{
Raw: stderr,
Inner: inner, Inner: inner,
Raw: stderr,
} }
// Get all "blocks" ending with double new lines.
parts := strings.Split(stderr, "\n\n")
parts = parts[:len(parts)-1]
for _, part := range parts {
if parsed := stderrRegex.FindStringSubmatch(part); parsed != nil {
var details ErrorDetails
if i := stderrRegex.SubexpIndex("error"); i > 0 && i < len(parsed) && parsed[i] != "" {
details.Message = parsed[i]
}
if i := stderrRegex.SubexpIndex("path"); i > 0 && i < len(parsed) && parsed[i] != "" {
details.Path = parsed[i]
}
if i := stderrRegex.SubexpIndex("line"); i > 0 && i < len(parsed) && parsed[i] != "" {
if line, err := strconv.ParseInt(parsed[i], 10, 0); err == nil {
details.Line = int(line)
}
}
if i := stderrRegex.SubexpIndex("column"); i > 0 && i < len(parsed) && parsed[i] != "" {
if column, err := strconv.ParseInt(parsed[i], 10, 0); err == nil {
details.Column = int(column)
}
}
err.Details = append(err.Details, details)
}
}
return &err
} }

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024 David Vogel // Copyright (c) 2024-2025 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -11,6 +11,7 @@ import (
"testing" "testing"
"github.com/Dadido3/go-typst" "github.com/Dadido3/go-typst"
"github.com/google/go-cmp/cmp"
) )
func TestErrors0(t *testing.T) { func TestErrors0(t *testing.T) {
@ -35,22 +36,26 @@ func TestErrors1(t *testing.T) {
if err := cli.Compile(r, &w, nil); err == nil { if err := cli.Compile(r, &w, nil); err == nil {
t.Fatalf("Expected error, but got nil") t.Fatalf("Expected error, but got nil")
} else { } else {
var errWithPath *typst.ErrorWithPath var errTypst *typst.Error
if errors.As(err, &errWithPath) { if errors.As(err, &errTypst) {
if errWithPath.Message != "assertion failed: Test" { if len(errTypst.Details) != 1 {
t.Errorf("Expected error with error message %q, got %q", "assertion failed: Test", errWithPath.Message) t.Fatalf("Expected error doesn't contain the expected number of detail entries. Got %v, want %v", len(errTypst.Details), 1)
} }
/*if errWithPath.Path != "" { details := errTypst.Details[0]
t.Errorf("Expected error to point to path %q, got path %q", "", errWithPath.Path) if details.Message != "error: assertion failed: Test" {
t.Errorf("Expected error with error message %q, got %q", "error: assertion failed: Test", details.Message)
}
/*if details.Path != "" {
t.Errorf("Expected error to point to path %q, got path %q", "", details.Path)
}*/ }*/
if errWithPath.Line != 3 { if details.Line != 3 {
t.Errorf("Expected error to point at line %d, got line %d", 3, errWithPath.Line) t.Errorf("Expected error to point at line %d, got line %d", 3, details.Line)
} }
if errWithPath.Column != 1 { if details.Column != 1 {
t.Errorf("Expected error to point at column %d, got column %d", 1, errWithPath.Column) t.Errorf("Expected error to point at column %d, got column %d", 1, details.Column)
} }
} else { } else {
t.Errorf("Expected error type %T, got %T: %v", errWithPath, err, err) t.Errorf("Expected error type %T, got %T: %v", errTypst, err, err)
} }
} }
} }
@ -70,13 +75,108 @@ func TestErrors2(t *testing.T) {
} else { } else {
var errTypst *typst.Error var errTypst *typst.Error
if errors.As(err, &errTypst) { if errors.As(err, &errTypst) {
if len(errTypst.Details) != 1 {
t.Fatalf("Expected error doesn't contain the expected number of detail entries. Got %v, want %v", len(errTypst.Details), 1)
}
details := errTypst.Details[0]
// Don't check the specific error message, as that may change over time. // Don't check the specific error message, as that may change over time.
// The expected message should be similar to: invalid value 'a' for '--pages <PAGES>': not a valid page number. // The expected message should be similar to: error: invalid value 'a' for '--pages <PAGES>': not a valid page number.
if errTypst.Message == "" { if details.Message == "" {
t.Errorf("Expected error message, got %q", errTypst.Message) t.Errorf("Expected error message, got %q", details.Message)
} }
} else { } else {
t.Errorf("Expected error type %T, got %T: %v", errTypst, err, err) t.Errorf("Expected error type %T, got %T: %v", errTypst, err, err)
} }
} }
} }
func TestErrorParsing(t *testing.T) {
var tests = map[string]struct {
StdErr string // The original and raw stderr message.
ExpectedDetails []typst.ErrorDetails // Expected parsed result.
}{
"Typst 0.13.0 HTML warning + error": {
StdErr: "warning: html export is under active development and incomplete\n = hint: its behaviour may change at any time\n = hint: do not rely on this feature for production use cases\n = hint: see https://github.com/typst/typst/issues/5512 for more information\n\nerror: page configuration is not allowed inside of containers\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:1:1\n │\n1 │ #set page(width: 100mm, height: auto, margin: 5mm)\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "warning: html export is under active development and incomplete\n = hint: its behaviour may change at any time\n = hint: do not rely on this feature for production use cases\n = hint: see https://github.com/typst/typst/issues/5512 for more information",
},
{
Message: "error: page configuration is not allowed inside of containers",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 1,
Column: 1,
},
},
},
"Typst 0.13.0 error with path": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:12:34\n │\n12 │ - Test coverage of most features.#\n │ ^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 12,
Column: 34,
},
},
},
"Typst 0.13.0 multiple errors with paths": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:11:53\n │\n11 │ - Uses stdio; No temporary files need to be created.#\n │ ^\n\nerror: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:12:34\n │\n12 │ - Test coverage of most features.#\n │ ^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 11,
Column: 53,
},
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 12,
Column: 34,
},
},
},
"Typst 0.13.0 stacked errors with paths": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\test.typ:1:4\n │\n1 │ hey#\n │ ^\n\nhelp: error occurred while importing this module\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:14:9\n │\n14 │ #include \"test.typ\"\n │ ^^^^^^^^^^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\test.typ",
Line: 1,
Column: 4,
},
{
Message: "help: error occurred while importing this module",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 14,
Column: 9,
},
},
},
"Typst 0.13.0 error without path": {
StdErr: "error: invalid value 'a' for '--pages <PAGES>': not a valid page number\n\nFor more information, try '--help'.\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: invalid value 'a' for '--pages <PAGES>': not a valid page number",
},
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
result := typst.ParseStderr(tt.StdErr, nil)
var typstError *typst.Error
if errors.As(result, &typstError) {
if !cmp.Equal(typstError.Details, tt.ExpectedDetails) {
t.Errorf("Parsed details don't match expected details: %s", cmp.Diff(tt.ExpectedDetails, typstError.Details))
}
} else {
t.Errorf("Parsed error is not of type %T", typstError)
}
})
}
}