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.
// https://opensource.org/licenses/MIT
@ -8,14 +8,26 @@ package typst
import (
"regexp"
"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 {
Inner error
Raw string // The raw output from stderr.
Message string // The parsed error message.
Raw string // The raw output from stderr.
// Raw output parsed into errors and warnings.
Details []ErrorDetails
}
func (e *Error) Error() string {
@ -26,72 +38,43 @@ func (e *Error) Unwrap() error {
return e.Inner
}
// ErrorWithPath represents a typst error that also contains information about its origin (filepath, line and column).
type ErrorWithPath struct {
Inner error
var stderrRegex = regexp.MustCompile(`(?s)^(?<error>.+?)(?:(?:\n\s+┌─ (?<path>.+?):(?<line>\d+):(?<column>\d+)\n)|(?:$))`)
Raw string // The raw error string as returned by the executable.
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.
// ParseStderr will parse the given stderr output and return a typst.Error.
func ParseStderr(stderr string, inner error) error {
if parsed := stderrWithPathRegex.FindStringSubmatch(stderr); parsed != nil {
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,
err := Error{
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.
// https://opensource.org/licenses/MIT
@ -11,6 +11,7 @@ import (
"testing"
"github.com/Dadido3/go-typst"
"github.com/google/go-cmp/cmp"
)
func TestErrors0(t *testing.T) {
@ -35,22 +36,26 @@ func TestErrors1(t *testing.T) {
if err := cli.Compile(r, &w, nil); err == nil {
t.Fatalf("Expected error, but got nil")
} else {
var errWithPath *typst.ErrorWithPath
if errors.As(err, &errWithPath) {
if errWithPath.Message != "assertion failed: Test" {
t.Errorf("Expected error with error message %q, got %q", "assertion failed: Test", errWithPath.Message)
var errTypst *typst.Error
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)
}
/*if errWithPath.Path != "" {
t.Errorf("Expected error to point to path %q, got path %q", "", errWithPath.Path)
details := errTypst.Details[0]
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 {
t.Errorf("Expected error to point at line %d, got line %d", 3, errWithPath.Line)
if details.Line != 3 {
t.Errorf("Expected error to point at line %d, got line %d", 3, details.Line)
}
if errWithPath.Column != 1 {
t.Errorf("Expected error to point at column %d, got column %d", 1, errWithPath.Column)
if details.Column != 1 {
t.Errorf("Expected error to point at column %d, got column %d", 1, details.Column)
}
} 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 {
var errTypst *typst.Error
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.
// The expected message should be similar to: invalid value 'a' for '--pages <PAGES>': not a valid page number.
if errTypst.Message == "" {
t.Errorf("Expected error message, got %q", errTypst.Message)
// The expected message should be similar to: error: invalid value 'a' for '--pages <PAGES>': not a valid page number.
if details.Message == "" {
t.Errorf("Expected error message, got %q", details.Message)
}
} else {
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)
}
})
}
}