diff --git a/internal/cuetxtar/astcmp.go b/internal/cuetxtar/astcmp.go index 8424960ab..de98b462a 100644 --- a/internal/cuetxtar/astcmp.go +++ b/internal/cuetxtar/astcmp.go @@ -15,6 +15,7 @@ package cuetxtar import ( + "cmp" "fmt" "strings" @@ -287,6 +288,8 @@ func (c *cmpCtx) cmpStruct(path cue.Path, s *ast.StructLit, val cue.Value) error checkOrder := false allFinal := false hasEmbed := false + hasEmbedBottom := false // expected struct has embedded _|_ (lenient mode) + var embedErrCheck *errArgs // @test(err, ...) at struct top level (applies to BaseValue) seenShareIDs := make(map[string]bool) for _, d := range s.Elts { @@ -301,6 +304,12 @@ func (c *cmpCtx) cmpStruct(path cue.Path, s *ast.StructLit, val cue.Value) error checkOrder = true case "final": allFinal = true + case "err": + // Top-level @test(err, ...) refers to the error + // carried by the struct's BaseValue (paired with an + // embedded _|_ on the same struct). + embedErrCheck = cmp.Or(pa.errArgs, &errArgs{}) + embedErrCheck.srcAttrText = d.Text } } } @@ -324,10 +333,7 @@ func (c *cmpCtx) cmpStruct(path cue.Path, s *ast.StructLit, val cue.Value) error case "ignore": isIgnore = true case "err": - errCk = pa.errArgs - if errCk == nil { - errCk = &errArgs{} // bare @test(err) - } + errCk = cmp.Or(pa.errArgs, &errArgs{}) // bare @test(err) → empty errArgs errCk.srcAttrText = a.Text case "shareID": // The first field with a given shareID name runs the eq check @@ -396,6 +402,11 @@ func (c *cmpCtx) cmpStruct(path cue.Path, s *ast.StructLit, val cue.Value) error } case *ast.EmbedDecl: hasEmbed = true + // The parser emits `_|_` (token.BOTTOM) as *ast.BottomLit + // unconditionally, so a single type assertion suffices. + if _, ok := d.Expr.(*ast.BottomLit); ok { + hasEmbedBottom = true + } if err := c.cmpEmbedExpr(path, d.Expr, val); err != nil { return err } @@ -429,6 +440,25 @@ func (c *cmpCtx) cmpStruct(path cue.Path, s *ast.StructLit, val cue.Value) error } } + // Check struct-level @test(err, ...) (paired with embedded _|_) against + // the BaseValue's error. + if embedErrCheck != nil { + if err := c.cmpErr(path, val, embedErrCheck); err != nil { + return err + } + } + + // Lenient mode: when the expected struct has an embedded _|_, the + // struct itself is being asserted as erroneous. The error itself + // (and any @test(err, ...) annotation) is what matters; the other + // expected decls (fields, patterns, lets, attributes) are + // informational and may diverge across evaluation orderings. Skip + // them so test assertions are robust to where exactly within the + // erroneous subtree the error surfaces. + if hasEmbedBottom { + return nil + } + // Compare regular fields (including definitions, optional, required, hidden). seen := make(map[cue.Selector]bool, len(fields)) for _, ef := range fields { diff --git a/internal/cuetxtar/inline_format.go b/internal/cuetxtar/inline_format.go index 683600ae6..f27e74776 100644 --- a/internal/cuetxtar/inline_format.go +++ b/internal/cuetxtar/inline_format.go @@ -129,6 +129,13 @@ func (r *inlineRunner) formatValue(v cue.Value, srcFileName string) string { var b strings.Builder w := &eqWriter{r: r, srcFileName: srcFileName, opCtx: value.OpContext(v)} w.writeValue(&b, v, "\t") + // When the root value itself collapses to a bare _|_ (e.g. a struct + // whose BaseValue is *adt.Bottom), there is no enclosing writeStruct + // to attach the @test(err, ...) annotation. Add it here so the + // rendered output remains informative at any nesting depth. + if isLeafError(v) { + w.writeErrAnnotation(&b, v) + } return b.String() } @@ -158,6 +165,15 @@ func (w *eqWriter) writeValue(b *strings.Builder, v cue.Value, nestedIndent stri case *adt.Conjunction: w.writeConjunction(b, bv) return + case *adt.Bottom: + // A struct-level error: emit just _|_ and skip the (possibly + // successfully-evaluated) child arcs. The error itself is what + // matters; the children may diverge across orderings without + // changing the meaning of the test. Caller's isLeafError check + // appends the @test(err, ...) annotation. + _ = bv + b.WriteString("_|_") + return } // Use struct emission if the kind is struct OR if there are arcs — the @@ -293,6 +309,11 @@ func isLeafError(v cue.Value) bool { } tv := v.Core() vx := tv.V.DerefValue() + // BaseValue=Bottom: writeValue emits bare _|_ regardless of arcs, + // so the annotation belongs at this level too. + if _, isBot := vx.BaseValue.(*adt.Bottom); isBot { + return true + } k := v.IncompleteKind() return !((k == cue.StructKind || len(vx.Arcs) > 0) && k != cue.ListKind) } @@ -302,6 +323,18 @@ func isLeafError(v cue.Value) bool { // filled immediately from the actual error so the annotation is stable after a // single CUE_UPDATE=1 pass. func (w *eqWriter) writeErrAnnotation(b *strings.Builder, v cue.Value) { + if v.Core().V == nil || v.Core().V.DerefValue().Bottom() == nil { + return + } + b.WriteString(" @test(") + w.writeErrAnnotationBody(b, v) + b.WriteByte(')') +} + +// writeErrAnnotationBody writes the contents of an err annotation body — +// "err, code=..., contains=..., pos=[...]" — without surrounding "@test(...)". +// Caller is responsible for the @test( ... ) framing. v must be a Bottom. +func (w *eqWriter) writeErrAnnotationBody(b *strings.Builder, v cue.Value) { tv := v.Core() if tv.V == nil { return @@ -310,7 +343,7 @@ func (w *eqWriter) writeErrAnnotation(b *strings.Builder, v cue.Value) { if bot == nil { return } - b.WriteString(" @test(err, code=") + b.WriteString("err, code=") b.WriteString(bot.Code.String()) if bot.Err != nil { fmt.Fprintf(b, ", contains=%q", bot.Err.Error()) @@ -324,7 +357,7 @@ func (w *eqWriter) writeErrAnnotation(b *strings.Builder, v cue.Value) { } b.WriteString(w.formatPos(p)) } - b.WriteString("])") + b.WriteString("]") } // writeStruct emits a struct using _foo$pkg notation for hidden-field labels. @@ -341,7 +374,9 @@ func (w *eqWriter) writeStruct(b *strings.Builder, vx *adt.Vertex, nestedIndent // If BaseValue is a concrete scalar or constraint (not a struct/list // marker), it represents an embedded expression, e.g. {4} or {>=5}. // *StructMarker and *ListMarker do not implement adt.Value (they lack - // Concreteness()), so this check correctly skips them. + // Concreteness()), so this check correctly skips them. Bottoms are + // handled by writeValue's early-return path, so they never reach + // writeStruct. if embVal, ok := vx.BaseValue.(adt.Value); ok { if nestedIndent != "" { b.WriteString("\n" + nestedIndent) diff --git a/internal/cuetxtar/inline_format_test.go b/internal/cuetxtar/inline_format_test.go new file mode 100644 index 000000000..deab08d70 --- /dev/null +++ b/internal/cuetxtar/inline_format_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cuetxtar + +import ( + "strings" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/internal/core/adt" +) + +// TestFormatStructLevelError verifies that a struct whose BaseValue is a +// *adt.Bottom — i.e. the struct as a whole is erroneous — is rendered as a +// bare "_|_ @test(err, ...)" rather than "{_|_, …surviving fields…}". +// +// The successfully-evaluated child arcs of an erroneous struct often diverge +// across evaluator-ordering shifts even when the error itself is the same; +// collapsing to the bare error keeps the inline output focused on the +// semantically meaningful part and makes test assertions stable. +// +// The annotation must accompany the bare _|_ so the error message is visible +// at any nesting depth (formatValue adds it for top-level errors; writeStruct +// adds it for field-level errors). +func TestFormatStructLevelError(t *testing.T) { + ctx := cuecontext.New() + + // A definition with a structural cycle: #T's bar references #T itself. + // This produces a vertex whose BaseValue is *adt.Bottom while its arcs + // (foo, bar) remain populated from the partially-evaluated body. + v := ctx.CompileString(` +#T: { + foo: 1 + bar: #T +} +`) + tv := v.LookupPath(cue.ParsePath("#T")) + if tv.Err() == nil { + t.Fatal("expected #T to be an error (structural cycle)") + } + + // Sanity-check the vertex shape we are exercising: BaseValue=Bottom AND + // Arcs are populated. Without this combination we would not be testing + // the collapse path. + core := tv.Core().V.DerefValue() + if _, ok := core.BaseValue.(*adt.Bottom); !ok { + t.Fatalf("expected BaseValue=*adt.Bottom; got %T", core.BaseValue) + } + if len(core.Arcs) == 0 { + t.Fatal("expected #T to have child arcs (the partially-evaluated body)") + } + + r := &inlineRunner{} + got := r.formatValue(tv, "") + + // Output must be a bare error, not a struct literal containing the + // embedded _|_ alongside foo and bar. + if strings.Contains(got, "{") { + t.Errorf("expected struct collapsed to bare _|_; got brace in:\n%s", got) + } + // Output must be a single-line bare error: no multi-line struct body. + if strings.Count(got, "\n") > 0 { + t.Errorf("expected single-line collapsed output; got:\n%s", got) + } + // Surviving child fields must not leak. Their declarations would appear + // as `foo: 1` / `bar: …`; checking for the value side excludes the + // "#T.bar: structural cycle" substring in the error message. + if strings.Contains(got, "foo: 1") || strings.Contains(got, "bar: #T") { + t.Errorf("surviving child fields leaked into output; got:\n%s", got) + } + if !strings.HasPrefix(got, "_|_ @test(err,") { + t.Errorf("expected output to start with `_|_ @test(err,`; got:\n%s", got) + } + if !strings.Contains(got, "code=structural_cycle") { + t.Errorf("expected code=structural_cycle in annotation; got:\n%s", got) + } +} + +// TestFormatStructLevelErrorPropagates verifies the collapse fires through +// error propagation: a parent struct that contains a sub-vertex with +// BaseValue=*adt.Bottom inherits the error (cue.Value.Err walks children), +// so the parent collapses to a bare _|_ as well — surviving sibling fields +// (`good: "ok"`) are intentionally dropped from the rendered output. This +// matches the lenient comparison in cmpStruct, which skips unrelated fields +// when an embedded _|_ is present in the expected struct. +func TestFormatStructLevelErrorPropagates(t *testing.T) { + ctx := cuecontext.New() + v := ctx.CompileString(` +#T: { + foo: 1 + bar: #T +} +container: { + good: "ok" + bad: #T +} +`) + c := v.LookupPath(cue.ParsePath("container")) + if c.Err() == nil { + t.Fatal("expected container.Err() != nil from #T's structural cycle propagating up") + } + + r := &inlineRunner{} + got := r.formatValue(c, "") + + if strings.Contains(got, "{") { + t.Errorf("expected container collapsed to bare _|_; got brace in:\n%s", got) + } + if strings.Contains(got, "good") || strings.Contains(got, "bad:") { + t.Errorf("expected sibling fields suppressed by collapse; got:\n%s", got) + } + if !strings.HasPrefix(got, "_|_ @test(err,") { + t.Errorf("expected output to start with `_|_ @test(err,`; got:\n%s", got) + } +} diff --git a/internal/cuetxtar/testdata/inline/eq_struct_embed_err.txtar b/internal/cuetxtar/testdata/inline/eq_struct_embed_err.txtar new file mode 100644 index 000000000..4627fb990 --- /dev/null +++ b/internal/cuetxtar/testdata/inline/eq_struct_embed_err.txtar @@ -0,0 +1,34 @@ +# Tests that an @test(eq, {...}) body containing both an embedded `_|_` and a +# top-level `@test(err, ...)` attribute is treated as a struct-level error +# assertion: the embedded `_|_` requires the actual value to carry an error, +# the @test(err, ...) attribute checks the error's code/contains/pos against +# that error, and any other declarations in the expected struct are +# informational (lenient mode — they may diverge across orderings without +# changing the meaning of the test). +-- in/test.cue -- +y: { + a: 1 + b: 1 & 2 +} @test(eq, { + _|_ + @test(err, code=eval, contains="conflicting values 2 and 1") + informationalField: 999 + another: "anything" +}) +-- out/status.txt -- +update: output passes run +force: identical to update +-- out/update/out/errors.txt -- +[eval] y.b: conflicting values 2 and 1: + ./test.cue:3:5 + ./test.cue:3:9 +-- out/update/test.cue -- +y: { + a: 1 + b: 1 & 2 +} @test(eq, { + _|_ + @test(err, code=eval, contains="conflicting values 2 and 1") + informationalField: 999 + another: "anything" +}) diff --git a/internal/cuetxtar/testdata/inline/eq_struct_top_err.txtar b/internal/cuetxtar/testdata/inline/eq_struct_top_err.txtar new file mode 100644 index 000000000..e74e7b660 --- /dev/null +++ b/internal/cuetxtar/testdata/inline/eq_struct_top_err.txtar @@ -0,0 +1,32 @@ +# Tests that an @test(eq, {...}) body containing an embedded `_|_` paired +# with a top-level `@test(err, ...)` attribute checks the actual value's +# error against the annotation. This exercises the cmpStruct path that +# treats a top-level @test(err, ...) inside an eq body as the error check +# for the struct's BaseValue (rather than for any specific field). +-- in/test.cue -- +y: { + a: 1 + b: 1 & 2 +} @test(eq, { + _|_ + @test(err, code=eval, contains="conflicting values 2 and 1") + a: 1 + b: _|_ +}) +-- out/status.txt -- +update: output passes run +force: identical to update +-- out/update/test.cue -- +y: { + a: 1 + b: 1 & 2 +} @test(eq, { + _|_ + @test(err, code=eval, contains="conflicting values 2 and 1") + a: 1 + b: _|_ +}) +-- out/update/out/errors.txt -- +[eval] y.b: conflicting values 2 and 1: + ./test.cue:3:5 + ./test.cue:3:9