diff --git a/errors.go b/errors.go index f2ac856..5b7f89b 100644 --- a/errors.go +++ b/errors.go @@ -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)^(?.+?)(?:(?:\n\s+┌─ (?.+?):(?\d+):(?\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: (?.+?)\n`) -var stderrWithPathRegex = regexp.MustCompile(`(?s)^error: (?.+?)\n\s+┌─ (?.+?):(?\d+):(?\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 } diff --git a/errors_test.go b/errors_test.go index adacf21..0486ae2 100644 --- a/errors_test.go +++ b/errors_test.go @@ -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 ': 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 ': 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\\: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\\", + 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\\: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\\", + 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\\: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\\: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\\", + Line: 11, + Column: 53, + }, + { + Message: "error: expected expression", + Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\", + 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\\: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\\", + Line: 14, + Column: 9, + }, + }, + }, + "Typst 0.13.0 error without path": { + StdErr: "error: invalid value 'a' for '--pages ': not a valid page number\n\nFor more information, try '--help'.\n", + ExpectedDetails: []typst.ErrorDetails{ + { + Message: "error: invalid value 'a' for '--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) + } + + }) + } +}