|
| 1 | +package cuelite |
| 2 | + |
| 3 | +import ( |
| 4 | + "reflect" |
| 5 | + "slices" |
| 6 | + "strings" |
| 7 | +) |
| 8 | + |
| 9 | +// PathError reports a validation failure tagged with the field path |
| 10 | +// at which it occurred. The path mirrors cuelang.org/go/cue/errors |
| 11 | +// Error.Path() — the dotted route into the data tree where the value |
| 12 | +// failed its constraint (for example []string{"meta", "status"}). A |
| 13 | +// nil path marks an error not associated with a specific leaf. |
| 14 | +// |
| 15 | +// A PathError may wrap the underlying error it was built from — the CUE |
| 16 | +// validation error for a per-leaf failure, or a bottom Value's cause — |
| 17 | +// reachable through Unwrap so errors.Is/As keep working against the |
| 18 | +// original error or a sentinel. Its own message, not the wrapped cause, |
| 19 | +// is the rejection; Errors treats a PathError as a leaf and does not |
| 20 | +// descend into the wrapped error. |
| 21 | +type PathError struct { |
| 22 | + path []string |
| 23 | + msg string |
| 24 | + wrapped error |
| 25 | +} |
| 26 | + |
| 27 | +// newPathError builds a PathError at the given field path with the |
| 28 | +// given message, retaining cause as its wrapped error (nil when there is |
| 29 | +// none) so errors.Is/As against the original CUE error or a sentinel |
| 30 | +// resolve through the returned leaf. A nil or empty path produces an |
| 31 | +// error whose Error() is the bare message, with no path prefix. The |
| 32 | +// message must be path-free: Error() owns the single path prefix, so a |
| 33 | +// message that already carries the path would double it. |
| 34 | +func newPathError(path []string, msg string, cause error) *PathError { |
| 35 | + return &PathError{path: path, msg: msg, wrapped: cause} |
| 36 | +} |
| 37 | + |
| 38 | +// Unwrap returns the underlying error the PathError was built from, or |
| 39 | +// nil when it carries none, so errors.Is/As reach the wrapped CUE error |
| 40 | +// or sentinel. Errors does not follow this link — a PathError is a leaf |
| 41 | +// in the per-field walk — so unwrapping is for errors.Is/As only. |
| 42 | +func (e *PathError) Unwrap() error { |
| 43 | + return e.wrapped |
| 44 | +} |
| 45 | + |
| 46 | +// Path returns the field path the error is tagged with, or nil when |
| 47 | +// the error is not associated with a specific leaf. It mirrors |
| 48 | +// cue/errors Error.Path() so the differential harness can compare |
| 49 | +// in-house and CUE-backed error locations field by field. |
| 50 | +// |
| 51 | +// The returned slice is a fresh copy, never the error's internal slice |
| 52 | +// (matching cue/errors, which clones in Error.Path): a caller that |
| 53 | +// mutates it — the harness collects leaf paths into its own structures — |
| 54 | +// cannot corrupt a later Error() render. slices.Clone(nil) is nil, so an |
| 55 | +// unpathed error still returns nil rather than an empty slice. |
| 56 | +func (e *PathError) Path() []string { |
| 57 | + return slices.Clone(e.path) |
| 58 | +} |
| 59 | + |
| 60 | +// Error renders the message prefixed by the dotted field path, or the |
| 61 | +// bare message when the path is empty. |
| 62 | +func (e *PathError) Error() string { |
| 63 | + if len(e.path) == 0 { |
| 64 | + return e.msg |
| 65 | + } |
| 66 | + return strings.Join(e.path, ".") + ": " + e.msg |
| 67 | +} |
| 68 | + |
| 69 | +// Errors enumerates the per-field failures carried by an error returned |
| 70 | +// from Validate. It is THE way a consumer reads every rejecting leaf, |
| 71 | +// and it does not depend on the concrete shape Validate returns — which |
| 72 | +// Validate's own doc leaves unspecified. Whatever that shape is (today a |
| 73 | +// bare *PathError for a single failing field, an errors.Join of |
| 74 | +// *PathErrors for several, a path-free *PathError for a bottom), Errors |
| 75 | +// flattens it into one slice so callers (the internal/schema validator |
| 76 | +// emitting one MDS020 diagnostic per field, the differential harness |
| 77 | +// comparing every rejected path) iterate uniformly without type-switching |
| 78 | +// on the result. It mirrors cuelang.org/go/cue/errors.Errors. |
| 79 | +// |
| 80 | +// Errors is a full error-tree walk: it descends through both join |
| 81 | +// wrappers (Unwrap() []error) and single wrappers (Unwrap() error), |
| 82 | +// collecting every *PathError leaf in encounter order. A *PathError |
| 83 | +// hidden behind a fmt.Errorf("%w", …) wrapper, or a join nested inside |
| 84 | +// such a wrapper, is therefore reported in full — not truncated to the |
| 85 | +// first leaf an errors.As would stop at. A nil error, or an error tree |
| 86 | +// carrying no *PathError, yields nil — never a non-nil empty slice — so |
| 87 | +// a caller can range over the result unconditionally. |
| 88 | +// |
| 89 | +// This walk underpins the invariant documented on Validate: every |
| 90 | +// non-nil error Validate returns decomposes to at least one *PathError, |
| 91 | +// so a consumer loop over Errors emits at least one diagnostic for any |
| 92 | +// failing value. |
| 93 | +func Errors(err error) []*PathError { |
| 94 | + if err == nil { |
| 95 | + return nil |
| 96 | + } |
| 97 | + var out []*PathError |
| 98 | + return collectPathErrors(err, out, map[error]struct{}{}) |
| 99 | +} |
| 100 | + |
| 101 | +// collectPathErrors appends every *PathError leaf reachable from err to |
| 102 | +// out in encounter order. A node that is itself a *PathError is a leaf |
| 103 | +// and is appended directly (its own message, not its wrapped cause, |
| 104 | +// being the rejection — the walk does NOT descend into a PathError's |
| 105 | +// Unwrap). Otherwise the walk recurses through a join wrapper (Unwrap() |
| 106 | +// []error) or a single wrapper (Unwrap() error). A node that is neither |
| 107 | +// a *PathError nor any wrapper contributes nothing. |
| 108 | +// |
| 109 | +// visited records the nodes already walked, so a node reachable by more |
| 110 | +// than one path — errors.Join sharing a leaf with a %w-wrapper of it — |
| 111 | +// is counted once, and a cyclic Unwrap chain terminates instead of |
| 112 | +// recursing forever. A *PathError leaf is appended before the visited |
| 113 | +// check can dedup it only through its parents, so each distinct leaf |
| 114 | +// pointer still yields one entry. |
| 115 | +// |
| 116 | +// Only a comparable node is memoized: an uncomparable concrete type (a |
| 117 | +// slice- or map-backed error) cannot be a map key — inserting it would |
| 118 | +// panic "hash of unhashable type". Such a node is walked WITHOUT |
| 119 | +// memoization, so a cycle reachable only through it could recurse |
| 120 | +// forever; in practice a cycle needs a comparable self-referencing node |
| 121 | +// (an uncomparable value cannot equal itself by ==, so it cannot close a |
| 122 | +// Go error chain back onto itself), which is still memoized and still |
| 123 | +// terminates. |
| 124 | +func collectPathErrors(err error, out []*PathError, visited map[error]struct{}) []*PathError { |
| 125 | + if err == nil { |
| 126 | + return out |
| 127 | + } |
| 128 | + comparable := reflect.TypeOf(err).Comparable() |
| 129 | + if comparable { |
| 130 | + if _, seen := visited[err]; seen { |
| 131 | + return out |
| 132 | + } |
| 133 | + visited[err] = struct{}{} |
| 134 | + } |
| 135 | + if pe, ok := err.(*PathError); ok { |
| 136 | + return append(out, pe) |
| 137 | + } |
| 138 | + switch w := err.(type) { |
| 139 | + case interface{ Unwrap() []error }: |
| 140 | + for _, leaf := range w.Unwrap() { |
| 141 | + out = collectPathErrors(leaf, out, visited) |
| 142 | + } |
| 143 | + case interface{ Unwrap() error }: |
| 144 | + out = collectPathErrors(w.Unwrap(), out, visited) |
| 145 | + } |
| 146 | + return out |
| 147 | +} |
0 commit comments