Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions internal/libyaml/dumper.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ package libyaml

import (
"bytes"
"errors"
"io"
"reflect"
)
Expand Down Expand Up @@ -78,12 +77,10 @@ func Dump(in any, opts ...Option) (out []byte, err error) {
// Multi-document mode: in must be a slice
inVal := reflect.ValueOf(in)
if inVal.Kind() != reflect.Slice {
msg := "yaml: WithAllDocuments requires a slice input"
return nil, &LoadErrors{Errors: []*LoadError{{
Stage: ConstructorStage,
Message: msg,
err: errors.New(msg),
}}}
return nil, &DumpError{
Stage: RepresenterStage,
Message: "WithAllDocuments requires a slice input",
}
}

// Dump each element as a separate document
Expand Down Expand Up @@ -139,7 +136,7 @@ func (d *Dumper) Close() (err error) {
// This is used by the legacy Encoder.SetIndent() method.
func (d *Dumper) SetIndent(spaces int) {
if spaces < 0 {
panic("yaml: cannot indent to a negative number of spaces")
failDumpf(SerializerStage, "cannot indent to a negative number of spaces")
}
// Set on serializer's emitter
d.serializer.Emitter.BestIndent = spaces
Expand Down
56 changes: 55 additions & 1 deletion internal/libyaml/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,24 @@ import (
"strings"
)

// Stage identifies the processing stage where an error occurred during YAML loading.
// Stage identifies the processing stage where an error occurred during YAML
// loading or dumping.
type Stage string

const (
// Load stages
ReaderStage Stage = "reader" // Input reading and encoding
ScannerStage Stage = "scanner" // Tokenization
ParserStage Stage = "parser" // Event stream parsing
ComposerStage Stage = "composer" // Node tree construction
ResolverStage Stage = "resolver" // Tag resolution
ConstructorStage Stage = "constructor" // Go value construction

// Dump stages
RepresenterStage Stage = "representer" // Go value to Node tree
SerializerStage Stage = "serializer" // Node tree to events
EmitterStage Stage = "emitter" // Events to YAML bytes
WriterStage Stage = "writer" // Output writing
)

// LoadError represents an error that occurred while loading a YAML document.
Expand Down Expand Up @@ -88,6 +96,52 @@ func NewLoadError(stage Stage, message string, mark Mark, cause error) *LoadErro
}
}

// DumpError represents an error that occurred while dumping a YAML document.
//
// It identifies the processing stage where the error occurred and provides
// an optional underlying cause via Unwrap.
type DumpError struct {
Stage Stage // Processing stage where error occurred
Message string // Error description

// Error chaining
err error // Underlying error (for Unwrap support)
}

// Error returns the error message with stage information.
// Format: "go-yaml dump error in <stage>: <message>"
func (e *DumpError) Error() string {
return fmt.Sprintf("go-yaml dump error in %s: %s", e.Stage, e.Message)
}

// Unwrap returns the underlying error.
func (e *DumpError) Unwrap() error {
return e.err
}

// NewDumpError creates a DumpError with an underlying cause.
// The cause is accessible via Unwrap for use with [errors.Is] and [errors.As].
func NewDumpError(stage Stage, message string, cause error) *DumpError {
return &DumpError{Stage: stage, Message: message, err: cause}
}

// failDump panics with a YAMLError wrapping a DumpError for the given stage.
// If err is exactly a *DumpError it is passed through unchanged to avoid
// double-wrapping (e.g. a user MarshalYAML that returns yaml.NewDumpError).
// Errors that merely wrap a *DumpError are treated as ordinary errors so that
// the outer wrapper's message and context are preserved.
func failDump(stage Stage, err error) {
Comment thread
ingydotnet marked this conversation as resolved.
if de, ok := err.(*DumpError); ok {
panic(&YAMLError{de})
}
panic(&YAMLError{&DumpError{Stage: stage, Message: err.Error(), err: err}})
}

// failDumpf panics with a YAMLError wrapping a formatted DumpError.
func failDumpf(stage Stage, format string, args ...any) {
panic(&YAMLError{&DumpError{Stage: stage, Message: fmt.Sprintf(format, args...)}})
}

// EmitterError represents an error that occurred during emitting.
type EmitterError struct {
Message string
Expand Down
56 changes: 56 additions & 0 deletions internal/libyaml/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
func TestErrors(t *testing.T) {
RunTestCases(t, "errors.yaml", map[string]TestHandler{
"load-error": runLoadErrorTest,
"dump-error": runDumpErrorTest,
"emitter-error": runEmitterYAMLErrorTest,
"writer-error": runWriterYAMLErrorTest,
"load-errors": runLoadErrorsTest,
Expand Down Expand Up @@ -58,6 +59,61 @@ func runLoadErrorTest(t *testing.T, tc TestCase) {
}
}

func runDumpErrorTest(t *testing.T, tc TestCase) {
t.Helper()

errorSpec, ok := tc.From.(map[string]any)
assert.Truef(t, ok, "from should be map[string]any, got %T", tc.From)

err := buildDumpError(t, errorSpec)
if err == nil {
t.Fatal("buildDumpError returned nil")
}
got := err.Error()
Comment thread
ingydotnet marked this conversation as resolved.
want, ok := tc.Want.(string)
assert.Truef(t, ok, "want should be string, got %T", tc.Want)

assert.Equalf(t, want, got, "error message mismatch")

// Verify Stage field if specified; fail if defined but not a string
if stageVal, hasStage := errorSpec["stage"]; hasStage {
stageStr, ok := stageVal.(string)
assert.Truef(t, ok, "stage should be string, got %T", stageVal)
if ok {
assert.Equalf(t, Stage(stageStr), err.Stage, "Stage mismatch")
}
}

// Test Unwrap if specified
if tc.Also == "unwrap" {
unwrapped := err.Unwrap()
if err.err != nil {
assert.NotNilf(t, unwrapped, "Unwrap() should return non-nil when err is set")
assert.Equalf(t, err.err.Error(), unwrapped.Error(), "Unwrap() error message mismatch")
} else {
if unwrapped != nil {
t.Fatalf("Unwrap() should return nil when err is not set, got %v", unwrapped)
}
}
}
}

func buildDumpError(t *testing.T, spec map[string]any) *DumpError {
t.Helper()

err := &DumpError{
Stage: Stage(getString(t, spec, "stage")),
Message: getString(t, spec, "message"),
}

// Add underlying error if specified
if errMsg, ok := spec["err"].(string); ok {
err.err = errors.New(errMsg)
}

return err
}

func runEmitterYAMLErrorTest(t *testing.T, tc TestCase) {
t.Helper()

Expand Down
16 changes: 8 additions & 8 deletions internal/libyaml/representer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ package libyaml

import (
"encoding"
"fmt"
"reflect"
"regexp"
"sort"
Expand Down Expand Up @@ -99,7 +98,7 @@ func (r *Representer) represent(tag string, in reflect.Value) *Node {
case Marshaler:
v, err := value.MarshalYAML()
if err != nil {
Fail(err)
failDump(RepresenterStage, err)
}
if v == nil {
return r.nilv()
Expand All @@ -108,7 +107,7 @@ func (r *Representer) represent(tag string, in reflect.Value) *Node {
case encoding.TextMarshaler:
text, err := value.MarshalText()
if err != nil {
Fail(err)
failDump(RepresenterStage, err)
}
in = reflect.ValueOf(string(text))
case nil:
Expand Down Expand Up @@ -136,7 +135,8 @@ func (r *Representer) represent(tag string, in reflect.Value) *Node {
case reflect.Bool:
return r.boolv(tag, in)
default:
panic("cannot represent type: " + in.Type().String())
failDumpf(RepresenterStage, "cannot represent type: %s", in.Type().String())
return nil // unreachable; failDumpf always panics
}
}

Expand Down Expand Up @@ -172,7 +172,7 @@ func (r *Representer) mapv(tag string, in reflect.Value) *Node {
func (r *Representer) structv(tag string, in reflect.Value) *Node {
sinfo, err := getStructInfo(in.Type())
if err != nil {
panic(err)
failDump(RepresenterStage, err)
}

if tag == "" {
Expand Down Expand Up @@ -210,7 +210,7 @@ func (r *Representer) structv(tag string, in reflect.Value) *Node {
sort.Sort(keys)
for _, k := range keys {
if _, found := sinfo.FieldsMap[k.String()]; found {
panic(fmt.Sprintf("cannot have key %q in inlined map: conflicts with struct field", k.String()))
failDumpf(RepresenterStage, "cannot have key %q in inlined map: conflicts with struct field", k.String())
}
content = append(content, r.represent("", k))
r.flow = false
Expand Down Expand Up @@ -262,10 +262,10 @@ func (r *Representer) stringv(tag string, in reflect.Value) *Node {
switch {
case !utf8.ValidString(s):
if tag == binaryTag {
failf("explicitly tagged !!binary data must be base64-encoded")
failDumpf(RepresenterStage, "explicitly tagged !!binary data must be base64-encoded")
}
if tag != "" {
failf("cannot represent invalid UTF-8 data as %s", shortTag(tag))
failDumpf(RepresenterStage, "cannot represent invalid UTF-8 data as %s", shortTag(tag))
}
// It can't be represented directly as YAML so use a binary tag
// and represent it as base64.
Expand Down
35 changes: 26 additions & 9 deletions internal/libyaml/serializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
package libyaml

import (
"errors"
"fmt"
"io"
"strings"
"unicode/utf8"
Expand Down Expand Up @@ -192,10 +194,10 @@ func (s *Serializer) node(node *Node, tail string) {
if !utf8.ValidString(value) {
stag := shortTag(tag)
if stag == binaryTag {
failf("explicitly tagged !!binary data must be base64-encoded")
failDumpf(SerializerStage, "explicitly tagged !!binary data must be base64-encoded")
}
if stag != "" {
failf("cannot marshal invalid UTF-8 data as %s", stag)
failDumpf(SerializerStage, "cannot marshal invalid UTF-8 data as %s", stag)
}
// It can't be represented directly as YAML so use a binary tag
// and represent it as base64.
Expand All @@ -221,7 +223,7 @@ func (s *Serializer) node(node *Node, tail string) {

s.emitScalar(value, node.Anchor, tag, style, []byte(node.HeadComment), []byte(node.LineComment), []byte(node.FootComment), []byte(tail))
default:
failf("cannot represent node with unknown kind %d", node.Kind)
failDumpf(SerializerStage, "cannot represent node with unknown kind %d", node.Kind)
}
}

Expand All @@ -230,15 +232,30 @@ func (s *Serializer) emit(event Event) {
s.must(s.Emitter.Emit(&event))
}

// must panics if the given error is non-nil.
// must panics if the given error is non-nil, routing to the appropriate stage.
func (s *Serializer) must(err error) {
if err != nil {
msg := err.Error()
if msg == "" {
msg = "unknown problem generating YAML content"
if err == nil {
return
}
var ee EmitterError
if errors.As(err, &ee) {
failDumpf(EmitterStage, "%s", ee.Message)
}
var we WriterError
if errors.As(err, &we) {
// Unwrap to get the original I/O error, stripping the
// "write error: " prefix that WriterError adds internally.
cause := we.Err
if unwrapped := errors.Unwrap(we.Err); unwrapped != nil {
cause = unwrapped
}
failf("%s", msg)
failDump(WriterStage, cause)
}
msg := err.Error()
if msg == "" {
msg = fmt.Sprintf("unknown problem generating YAML content with %T", err)
}
failDumpf(SerializerStage, "%s", msg)
}

// emitScalar emits a scalar event with the given value, anchor, tag, style,
Expand Down
41 changes: 41 additions & 0 deletions internal/libyaml/testdata/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,47 @@
want: 'go-yaml load error in constructor at <unknown position>: cannot construct value'
also: unwrap

# DumpError tests - dump stages

- dump-error:
name: Representer stage error
from:
stage: representer
message: cannot represent type chan int
want: 'go-yaml dump error in representer: cannot represent type chan int'

- dump-error:
name: Representer stage error with cause
from:
stage: representer
message: 'MarshalYAML failed: some error'
err: 'some error'
want: "go-yaml dump error in representer: MarshalYAML failed: some error"
also: unwrap

- dump-error:
name: Serializer stage error
from:
stage: serializer
message: cannot represent node with unknown kind 99
want: 'go-yaml dump error in serializer: cannot represent node with unknown kind 99'

- dump-error:
name: Emitter stage error
from:
stage: emitter
message: expected STREAM-START
want: 'go-yaml dump error in emitter: expected STREAM-START'

- dump-error:
name: Writer stage error with cause
from:
stage: writer
message: write error
err: write error
want: 'go-yaml dump error in writer: write error'
also: unwrap

# EmitterError tests (dump stack)

- emitter-error:
Expand Down
4 changes: 2 additions & 2 deletions node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func TestNodeZeroEncodeDecode(t *testing.T) {
// Kind zero is still unknown, though.
n.Line = 1
_, err = yaml.Marshal(&n)
assert.ErrorMatches(t, "yaml: cannot represent node with unknown kind 0", err)
assert.ErrorMatches(t, "go-yaml dump error in serializer: cannot represent node with unknown kind 0", err)
err = n.Load(&v)
assert.ErrorMatches(t, `go-yaml load error in constructor at L1: cannot construct node with unknown kind: '0'`, err)
}
Expand All @@ -297,7 +297,7 @@ func TestNodeOmitEmpty(t *testing.T) {

v.B.Line = 1
_, err = yaml.Marshal(&v)
assert.ErrorMatches(t, "yaml: cannot represent node with unknown kind 0", err)
assert.ErrorMatches(t, "go-yaml dump error in serializer: cannot represent node with unknown kind 0", err)
}

// NodeInfo represents the information about a YAML node in a test-friendly format
Expand Down
Loading
Loading