diff --git a/internal/libyaml/dumper.go b/internal/libyaml/dumper.go index a7796314..b65e81d7 100644 --- a/internal/libyaml/dumper.go +++ b/internal/libyaml/dumper.go @@ -11,7 +11,6 @@ package libyaml import ( "bytes" - "errors" "io" "reflect" ) @@ -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 @@ -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 diff --git a/internal/libyaml/errors.go b/internal/libyaml/errors.go index 21939a33..a33cc776 100644 --- a/internal/libyaml/errors.go +++ b/internal/libyaml/errors.go @@ -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. @@ -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 : " +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) { + 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 diff --git a/internal/libyaml/errors_test.go b/internal/libyaml/errors_test.go index 96d84d48..da8e9452 100644 --- a/internal/libyaml/errors_test.go +++ b/internal/libyaml/errors_test.go @@ -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, @@ -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() + 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() diff --git a/internal/libyaml/representer.go b/internal/libyaml/representer.go index 810750bc..c9f1b21f 100644 --- a/internal/libyaml/representer.go +++ b/internal/libyaml/representer.go @@ -9,7 +9,6 @@ package libyaml import ( "encoding" - "fmt" "reflect" "regexp" "sort" @@ -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() @@ -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: @@ -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 } } @@ -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 == "" { @@ -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 @@ -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. diff --git a/internal/libyaml/serializer.go b/internal/libyaml/serializer.go index ca725d63..4914dce0 100644 --- a/internal/libyaml/serializer.go +++ b/internal/libyaml/serializer.go @@ -8,6 +8,8 @@ package libyaml import ( + "errors" + "fmt" "io" "strings" "unicode/utf8" @@ -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. @@ -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) } } @@ -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, diff --git a/internal/libyaml/testdata/errors.yaml b/internal/libyaml/testdata/errors.yaml index c06b97d9..85e7683f 100644 --- a/internal/libyaml/testdata/errors.yaml +++ b/internal/libyaml/testdata/errors.yaml @@ -134,6 +134,47 @@ want: 'go-yaml load error in constructor at : 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: diff --git a/node_test.go b/node_test.go index 36c9af11..8875132e 100644 --- a/node_test.go +++ b/node_test.go @@ -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) } @@ -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 diff --git a/yaml.go b/yaml.go index 2d4c37ee..b003171d 100644 --- a/yaml.go +++ b/yaml.go @@ -462,17 +462,25 @@ const ( EncodingUTF16BE = libyaml.UTF16BE_ENCODING ) -// 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 = libyaml.Stage // Stage constants for YAML processing pipeline. const ( + // Load stages ReaderStage = libyaml.ReaderStage // Input reading and encoding ScannerStage = libyaml.ScannerStage // Tokenization ParserStage = libyaml.ParserStage // Event stream parsing ComposerStage = libyaml.ComposerStage // Node tree construction ResolverStage = libyaml.ResolverStage // Tag resolution ConstructorStage = libyaml.ConstructorStage // Go value construction + + // Dump stages + RepresenterStage = libyaml.RepresenterStage // Go value to Node tree + SerializerStage = libyaml.SerializerStage // Node tree to events + EmitterStage = libyaml.EmitterStage // Events to YAML bytes + WriterStage = libyaml.WriterStage // Output writing ) // Mark represents a position in the YAML document. @@ -491,6 +499,12 @@ type ( // It contains multiple *[LoadError] instances with details about each error. LoadErrors = libyaml.LoadErrors + // 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. + DumpError = libyaml.DumpError + // TypeError is a legacy error type retained for compatibility. // // Deprecated: Use [LoadErrors] instead. @@ -503,6 +517,10 @@ type ( // The cause is accessible via Unwrap for use with [errors.Is] and [errors.As]. var NewLoadError = libyaml.NewLoadError +// NewDumpError creates a DumpError with an underlying cause error. +// The cause is accessible via Unwrap for use with [errors.Is] and [errors.As]. +var NewDumpError = libyaml.NewDumpError + // LineBreak represents the line ending style for YAML output. type LineBreak = libyaml.LineBreak diff --git a/yaml_test.go b/yaml_test.go index 19772da9..b6f889bf 100644 --- a/yaml_test.go +++ b/yaml_test.go @@ -2446,7 +2446,10 @@ func TestEncoderMultipleDocuments(t *testing.T) { func TestEncoderWriteError(t *testing.T) { enc := yaml.NewEncoder(errorWriter{}) err := enc.Encode(map[string]string{"a": "b"}) - assert.ErrorMatches(t, `yaml: write error: some write error`, err) // Data not flushed yet + assert.ErrorMatches(t, `go-yaml dump error in writer: some write error`, err) + var dumpErr *yaml.DumpError + assert.True(t, errors.As(err, &dumpErr)) + assert.Equal(t, yaml.WriterStage, dumpErr.Stage) } type errorWriter struct{} @@ -2458,31 +2461,32 @@ func (errorWriter) Write([]byte) (int, error) { var marshalErrorTests = []struct { value any error string - panic string + stage yaml.Stage }{{ value: &struct { B int inlineB `yaml:",inline"` }{1, inlineB{2, inlineC{3}}}, //nolint:dupword // struct is duplicated here as the first one is the struct and the second is the name of the inline struct - panic: `duplicated key 'b' in struct struct \{ B int; .*`, + error: `go-yaml dump error in representer: duplicated key 'b' in struct struct \{ B int; .*`, + stage: yaml.RepresenterStage, }, { value: &struct { A int B map[string]int `yaml:",inline"` }{1, map[string]int{"a": 2}}, - panic: `cannot have key "a" in inlined map: conflicts with struct field`, + error: `go-yaml dump error in representer: cannot have key "a" in inlined map: conflicts with struct field`, + stage: yaml.RepresenterStage, }} func TestMarshalErrors(t *testing.T) { for _, item := range marshalErrorTests { - t.Run(item.panic, func(t *testing.T) { - if item.panic != "" { - assert.PanicMatches(t, item.panic, func() { yaml.Marshal(item.value) }) - } else { - _, err := yaml.Marshal(item.value) - assert.ErrorMatches(t, item.error, err) - } + t.Run(item.error, func(t *testing.T) { + _, err := yaml.Marshal(item.value) + assert.ErrorMatches(t, item.error, err) + var dumpErr *yaml.DumpError + assert.True(t, errors.As(err, &dumpErr)) + assert.Equal(t, item.stage, dumpErr.Stage) }) } } @@ -2558,7 +2562,7 @@ func (ft *failingMarshaler) MarshalYAML() (any, error) { func TestMarshalerError(t *testing.T) { _, err := yaml.Marshal(&failingMarshaler{}) - assert.ErrorIs(t, errFailing, err) + assert.ErrorIs(t, err, errFailing) } func TestSetIndent(t *testing.T) {