this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

internal/cuetxtar: support suberr=(...) in @test(err, ...)

Add support for asserting on individual sub-errors of a multi-error
(failed disjunction) value using repeated suberr=(...) groups in the
@test(err, ...) directive:

x: ... @test(err, code=eval, contains="empty disjunction",
suberr=(pos=[-1:5 0:5 0:14], contains="conflicting values"),
suberr=(pos=[-1:13 0:15], contains="not allowed"))

Matching is two-pass and order-independent: specs with non-empty pos=
are matched first (pass 1); remaining specs are matched by contains=
(pass 2). The disjunction-header entry prepended by CUE is skipped.

CUE_UPDATE=1 fills in empty pos=[] placeholders in suberr groups.
When pos= is wrong but contains= matches, position diffs are shown
instead of the generic "no sub-error matched" message.

New helpers: posSpecsMatch, positionsFromSingleError, matchesErrSpec,
checkSubErrors, enqueueSubErrPosWrites, replaceSuberrPos,
isDisjunctionHeader, posUpdate.

Also: refactor checkErrPositions to use posSpecsMatch.

Convert issue3330 to use suberr=(...) annotations and add
TestInlineRunner_SubErrors to exercise the new matching.

Signed-off-by: Marcel van Lohuizen <mpvl@gmail.com>
Change-Id: I3f1902c0f09ad668c2fc923d8f6abe4682865ca8
Reviewed-on: https://cue.gerrithub.io/c/cue-lang/cue/+/1234697
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>

+442 -308
+31 -249
cue/testdata/eval/issue3330.txtar
··· 1 - #todo:inline: medium — comprehension; may need @test(final) for incomplete fields 2 1 -- in.cue -- 3 2 import "list" 4 3 4 + // issue3330: out[0] is a list element that shares #A's vertex. 5 5 issue3330: { 6 6 #A: { 7 7 let empty = {} 8 - 8 + 9 9 // Reference to empty is within the definition that defines it. Closedness 10 10 // thus does not trigger. 11 11 field: null | { n: int } 12 12 field: empty & { n: 3 } 13 - } 13 + } @test(eq, {field: {n: 3}}) @test(shareID=A) 14 14 15 - out: list.Concat([[#A]]) 15 + out: list.Concat([[#A]]) @test(eq, [{field: {n: 3}}]) @test(shareID=A, at=0) 16 16 } 17 17 18 + // eliminated: ensures a definition used only for the empty struct triggers the 19 + // disjunct-elimination code path that caused the original panic in issue3330. 18 20 eliminated: { 19 - // This test case ensures a definition is only used for the empty struct. 20 - // This ensures that the elimination of disjuncts is triggered, ensuring 21 - // the code path that caused the panic in issue3330 is triggered even when 22 - // the closedness bug that triggered it indirectly is fixed. 23 21 #empty: {} 24 22 x: null | { n: 3 } 25 - x: #empty & { n: 3 } 26 - out: len(x) 23 + x: #empty & { n: 3 } @test(err, code=eval, contains="empty disjunction", 24 + suberr=(pos=[-1:5 0:5 0:14], contains="conflicting values"), 25 + suberr=(pos=[-1:13 0:15], contains="not allowed")) 26 + out: len(x) @test(err, code=eval) 27 27 } 28 28 29 + // simplified: different take showing the bug is triggered after a definition 30 + // is referenced. 29 31 simplified: { 30 - // This is a different take on the above bug that demonstrates the issue 31 - // is only triggered after a definition is referenced. 32 32 #struct: { 33 33 field: { n: 3 } & g 34 34 g: {} 35 - } 35 + } @test(eq, {field: {n: 3}, g: {}}) 36 36 37 - out: #struct & {} 37 + out: #struct & {} @test(eq, {field: {n: 3}, g: {}}) 38 38 } 39 + -- out/errors.txt -- 40 + [eval] eliminated.x: 2 errors in empty disjunction: 41 + eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 42 + ./in.cue:21:5 43 + ./in.cue:22:5 44 + ./in.cue:22:14 45 + eliminated.x.n: field not allowed: 46 + ./in.cue:21:13 47 + ./in.cue:22:15 48 + [eval] eliminated.x: 2 errors in empty disjunction: 49 + eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 50 + ./in.cue:21:5 51 + ./in.cue:22:5 52 + ./in.cue:22:14 53 + eliminated.x.n: field not allowed: 54 + ./in.cue:21:13 55 + ./in.cue:22:15 39 56 -- out/eval/stats -- 40 57 Leaks: 0 41 58 Freed: 43 ··· 46 63 Unifications: 35 47 64 Conjuncts: 69 48 65 Disjuncts: 46 49 - -- out/evalalpha -- 50 - Errors: 51 - eliminated.x: 2 errors in empty disjunction: 52 - eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 53 - ./in.cue:22:5 54 - ./in.cue:23:5 55 - ./in.cue:23:14 56 - eliminated.x.n: field not allowed: 57 - ./in.cue:22:14 58 - ./in.cue:23:16 59 - 60 - Result: 61 - (_|_){ 62 - // [eval] 63 - issue3330: (struct){ 64 - #A: (#struct){ 65 - let empty#1 = (#struct){ 66 - } 67 - field: (#struct){ 68 - n: (int){ 3 } 69 - } 70 - } 71 - out: (#list){ 72 - 0: ~(issue3330.#A) 73 - } 74 - } 75 - eliminated: (_|_){ 76 - // [eval] 77 - #empty: (#struct){ 78 - } 79 - x: (_|_){ 80 - // [eval] eliminated.x: 2 errors in empty disjunction: 81 - // eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 82 - // ./in.cue:22:5 83 - // ./in.cue:23:5 84 - // ./in.cue:23:14 85 - // eliminated.x.n: field not allowed: 86 - // ./in.cue:22:14 87 - // ./in.cue:23:16 88 - n: (int){ 3 } 89 - } 90 - out: (_|_){ 91 - // [eval] eliminated.x: 2 errors in empty disjunction: 92 - // eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 93 - // ./in.cue:22:5 94 - // ./in.cue:23:5 95 - // ./in.cue:23:14 96 - // eliminated.x.n: field not allowed: 97 - // ./in.cue:22:14 98 - // ./in.cue:23:16 99 - } 100 - } 101 - simplified: (struct){ 102 - #struct: (#struct){ 103 - field: (#struct){ 104 - n: (int){ 3 } 105 - } 106 - g: (#struct){ 107 - } 108 - } 109 - out: (#struct){ 110 - field: (#struct){ 111 - n: (int){ 3 } 112 - } 113 - g: (#struct){ 114 - } 115 - } 116 - } 117 - } 118 - -- diff/-out/evalalpha<==>+out/eval -- 119 - diff old new 120 - --- old 121 - +++ new 122 - @@ -1,12 +1,11 @@ 123 - Errors: 124 - eliminated.x: 2 errors in empty disjunction: 125 - -eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 126 - +eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 127 - ./in.cue:22:5 128 - + ./in.cue:23:5 129 - ./in.cue:23:14 130 - eliminated.x.n: field not allowed: 131 - - ./in.cue:21:10 132 - ./in.cue:22:14 133 - - ./in.cue:23:5 134 - ./in.cue:23:16 135 - 136 - Result: 137 - @@ -21,13 +20,7 @@ 138 - } 139 - } 140 - out: (#list){ 141 - - 0: (#struct){ 142 - - let empty#1 = (#struct){ 143 - - } 144 - - field: (#struct){ 145 - - n: (int){ 3 } 146 - - } 147 - - } 148 - + 0: ~(issue3330.#A) 149 - } 150 - } 151 - eliminated: (_|_){ 152 - @@ -36,31 +29,23 @@ 153 - } 154 - x: (_|_){ 155 - // [eval] eliminated.x: 2 errors in empty disjunction: 156 - - // eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 157 - - // ./in.cue:22:5 158 - - // ./in.cue:23:14 159 - - // eliminated.x.n: field not allowed: 160 - - // ./in.cue:21:10 161 - - // ./in.cue:22:14 162 - - // ./in.cue:23:5 163 - - // ./in.cue:23:16 164 - - n: (_|_){ 165 - - // [eval] eliminated.x.n: field not allowed: 166 - - // ./in.cue:21:10 167 - - // ./in.cue:22:14 168 - - // ./in.cue:23:5 169 - - // ./in.cue:23:16 170 - - } 171 - + // eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 172 - + // ./in.cue:22:5 173 - + // ./in.cue:23:5 174 - + // ./in.cue:23:14 175 - + // eliminated.x.n: field not allowed: 176 - + // ./in.cue:22:14 177 - + // ./in.cue:23:16 178 - + n: (int){ 3 } 179 - } 180 - out: (_|_){ 181 - // [eval] eliminated.x: 2 errors in empty disjunction: 182 - - // eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 183 - - // ./in.cue:22:5 184 - - // ./in.cue:23:14 185 - - // eliminated.x.n: field not allowed: 186 - - // ./in.cue:21:10 187 - - // ./in.cue:22:14 188 - - // ./in.cue:23:5 189 - + // eliminated.x: conflicting values {n:3} and null (mismatched types struct and null): 190 - + // ./in.cue:22:5 191 - + // ./in.cue:23:5 192 - + // ./in.cue:23:14 193 - + // eliminated.x.n: field not allowed: 194 - + // ./in.cue:22:14 195 - // ./in.cue:23:16 196 - } 197 - } 198 - -- diff/todo/p3 -- 199 - Small differences in error output. 200 - -- out/eval -- 201 - Errors: 202 - eliminated.x: 2 errors in empty disjunction: 203 - eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 204 - ./in.cue:22:5 205 - ./in.cue:23:14 206 - eliminated.x.n: field not allowed: 207 - ./in.cue:21:10 208 - ./in.cue:22:14 209 - ./in.cue:23:5 210 - ./in.cue:23:16 211 - 212 - Result: 213 - (_|_){ 214 - // [eval] 215 - issue3330: (struct){ 216 - #A: (#struct){ 217 - let empty#1 = (#struct){ 218 - } 219 - field: (#struct){ 220 - n: (int){ 3 } 221 - } 222 - } 223 - out: (#list){ 224 - 0: (#struct){ 225 - let empty#1 = (#struct){ 226 - } 227 - field: (#struct){ 228 - n: (int){ 3 } 229 - } 230 - } 231 - } 232 - } 233 - eliminated: (_|_){ 234 - // [eval] 235 - #empty: (#struct){ 236 - } 237 - x: (_|_){ 238 - // [eval] eliminated.x: 2 errors in empty disjunction: 239 - // eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 240 - // ./in.cue:22:5 241 - // ./in.cue:23:14 242 - // eliminated.x.n: field not allowed: 243 - // ./in.cue:21:10 244 - // ./in.cue:22:14 245 - // ./in.cue:23:5 246 - // ./in.cue:23:16 247 - n: (_|_){ 248 - // [eval] eliminated.x.n: field not allowed: 249 - // ./in.cue:21:10 250 - // ./in.cue:22:14 251 - // ./in.cue:23:5 252 - // ./in.cue:23:16 253 - } 254 - } 255 - out: (_|_){ 256 - // [eval] eliminated.x: 2 errors in empty disjunction: 257 - // eliminated.x: conflicting values null and {n:3} (mismatched types null and struct): 258 - // ./in.cue:22:5 259 - // ./in.cue:23:14 260 - // eliminated.x.n: field not allowed: 261 - // ./in.cue:21:10 262 - // ./in.cue:22:14 263 - // ./in.cue:23:5 264 - // ./in.cue:23:16 265 - } 266 - } 267 - simplified: (struct){ 268 - #struct: (#struct){ 269 - field: (#struct){ 270 - n: (int){ 3 } 271 - } 272 - g: (#struct){ 273 - } 274 - } 275 - out: (#struct){ 276 - field: (#struct){ 277 - n: (int){ 3 } 278 - } 279 - g: (#struct){ 280 - } 281 - } 282 - } 283 - } 284 66 -- out/compile -- 285 67 --- in.cue 286 68 {
-1
internal/cuetest/cuetest.go
··· 82 82 // It is controlled by setting CUE_FORMAT_TXTAR to a non-empty string like "true". 83 83 var FormatTxtar = os.Getenv(envFormatTxtar) != "" 84 84 85 - 86 85 // Condition adds support for CUE-specific testscript conditions within 87 86 // testscript scripts. Supported conditions include: 88 87 //
+2 -2
internal/cuetxtar/inline.go
··· 898 898 // subpath returns the #subpath value from the archive comment, or empty string. 899 899 func (r *inlineRunner) subpath() string { 900 900 prefix := []byte("#subpath:") 901 - for _, line := range strings.Split(string(r.archive.Comment), "\n") { 901 + for line := range strings.SplitSeq(string(r.archive.Comment), "\n") { 902 902 b := []byte(strings.TrimSpace(line)) 903 903 if strings.HasPrefix(string(b), string(prefix)) { 904 904 return strings.TrimSpace(string(b[len(prefix):])) ··· 1207 1207 1208 1208 // Parse expected kind(s) — may be pipe-separated like "int|string". 1209 1209 var expectedKind cue.Kind 1210 - for _, ks := range strings.Split(expectedStr, "|") { 1210 + for ks := range strings.SplitSeq(expectedStr, "|") { 1211 1211 k := parseKindStr(strings.TrimSpace(ks)) 1212 1212 if k == cue.BottomKind { 1213 1213 t.Errorf("path %s: @test(kind=%q): unknown kind %q", path, expectedStr, ks)
+377 -56
internal/cuetxtar/inline_err.go
··· 14 14 15 15 // This file contains all error-assertion logic for the inline test runner: 16 16 // types, parsing, matching, position checking, and write-back for 17 - // @test(err, ...) directives. 17 + // @test(err, ...) and @test(err, suberr=(...)) directives. 18 18 19 19 package cuetxtar 20 20 ··· 76 76 // posSet is true when pos= was 77 77 // explicitly provided (including pos=[] to assert no positions). 78 78 posSet bool 79 + // suberrs holds expected sub-error specs for multi-error (list) values. 80 + // Each entry is matched order-independently against errors.Errors(val.Err()). 81 + suberrs []*errArgs 79 82 } 80 83 81 84 // matchesCode reports whether the given error code satisfies the codes ··· 87 90 return slices.Contains(ea.codes, got) 88 91 } 89 92 93 + // posUpdate records a pending pos=[...] replacement within a suberr=(...) group. 94 + type posUpdate struct { 95 + expIdx int // 0-based index of the suberr=(...) group in the attribute 96 + positions []token.Pos // actual positions to write 97 + } 98 + 99 + // posWrite records a pending pos= attribute update for CUE_UPDATE write-back. 100 + type posWrite struct { 101 + fileName string // archive .cue file name, e.g. "in.cue" 102 + attrOffset int // byte offset of the @test attr in the original file data 103 + attrLen int // byte length of the original @test attr text 104 + newAttrText string // replacement attribute text with updated pos=[...] 105 + } 106 + 90 107 // parseErrArgs extracts err sub-options from an already-parsed Attr. 91 108 // The attribute body is expected to start with "err" as the first positional arg. 92 109 func parseErrArgs(a internal.Attr) (errArgs, error) { ··· 117 134 } 118 135 ea.pos = specs 119 136 ea.posSet = true 137 + case kv.Key() == "suberr": 138 + inner := strings.TrimSpace(kv.Value()) 139 + if !strings.HasPrefix(inner, "(") || !strings.HasSuffix(inner, ")") { 140 + return ea, fmt.Errorf("@test(err, suberr=...): value must be wrapped in (...), got %q", inner) 141 + } 142 + inner = inner[1 : len(inner)-1] 143 + // Reuse parseErrArgs by building a synthetic "err, <inner>" attr body. 144 + syntheticAttr := internal.ParseAttrBody(token.NoPos, "err, "+inner) 145 + subEA, err := parseErrArgs(syntheticAttr) 146 + if err != nil { 147 + return ea, fmt.Errorf("@test(err, suberr=...): %w", err) 148 + } 149 + ea.suberrs = append(ea.suberrs, &subEA) 120 150 } 121 151 } 122 152 return ea, nil ··· 188 218 return specs, nil 189 219 } 190 220 221 + // matchesErrSpec reports whether act satisfies all discriminating constraints 222 + // in ea. It is a pure predicate — it never calls t.Errorf. 223 + // 224 + // Position specs are used for discrimination only when len(ea.pos) > 0. 225 + // An empty pos=[] placeholder does not influence matching; use 226 + // checkSubErrPositions to validate positions after a match is found. 227 + // 228 + // code= is not checked here because cueerrors.Error does not expose adt.Code 229 + // directly; code checking is done at the cue.Value level in runErrAssertion. 230 + func (r *inlineRunner) matchesErrSpec(act cueerrors.Error, ea *errArgs, baseLine int) bool { 231 + if ea.contains != "" && !strings.Contains(act.Error(), ea.contains) { 232 + return false 233 + } 234 + // Only use pos for discrimination when it is non-empty. An empty pos=[] 235 + // is a placeholder; positions are validated separately by checkSubErrPositions. 236 + if ea.posSet && len(ea.pos) > 0 { 237 + positions := positionsFromSingleError(act) 238 + if !posSpecsMatch(positions, ea.pos, baseLine, r.relFilename) { 239 + return false 240 + } 241 + } 242 + return true 243 + } 244 + 191 245 // runErrAssertion checks that an error is present at val, applying sub-options. 192 246 func (r *inlineRunner) runErrAssertion(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 193 247 t.Helper() ··· 214 268 return 215 269 } 216 270 217 - // Validate error code. 271 + // Validate error code (uses adt.Bottom.Code, only available at cue.Value level). 218 272 if len(ea.codes) > 0 { 219 273 gotCode := r.errorCode(val) 220 274 if !ea.matchesCode(gotCode) { ··· 231 285 // Validate error positions. 232 286 if ea.posSet { 233 287 r.checkErrPositions(t, path, val, pa) 288 + } 289 + // Validate sub-errors. 290 + if len(ea.suberrs) > 0 { 291 + r.checkSubErrors(t, path, val, ea, pa) 234 292 } 235 293 } 236 294 237 - // checkErrPositions verifies that the error positions on val match the pos= 238 - // spec in pa. When positions don't match: 239 - // - pos=[] (placeholder): update on CUE_UPDATE=1. 240 - // - pos=[non-empty]: update on CUE_UPDATE=force only. 241 - func (r *inlineRunner) checkErrPositions(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 295 + // checkSubErrors verifies that the sub-errors of a multi-error value at val 296 + // match the suberr=(...) specs in ea. 297 + // 298 + // Matching is two-pass and order-independent: 299 + // - Pass 1 (exact): specs with non-empty pos= are matched first — they 300 + // uniquely identify a sub-error even when multiple errors share a contains 301 + // substring. 302 + // - Pass 2 (contains): remaining specs (pos=[] or no pos=) are matched by 303 + // contains= against the still-unmatched actual errors. 304 + // 305 + // After matching, positions are validated for each pair. pos=[] is a 306 + // placeholder that triggers writeback when CUE_UPDATE=1. 307 + // 308 + // When the error is a failed disjunction, CUE prepends a summary entry of 309 + // the form "N errors in empty disjunction:" to the list; checkSubErrors 310 + // detects and skips this header. 311 + func (r *inlineRunner) checkSubErrors(t testing.TB, path cue.Path, val cue.Value, ea *errArgs, pa parsedTestAttr) { 242 312 t.Helper() 243 - err := val.Err() 244 - if err == nil { 245 - t.Errorf("path %s: @test(err, pos=...): value has no error", path) 313 + all := cueerrors.Errors(val.Err()) 314 + // Skip the disjunction header entry if present. 315 + actual := all 316 + if len(all) > 1 && isDisjunctionHeader(all[0]) { 317 + actual = all[1:] 318 + } 319 + expected := ea.suberrs 320 + 321 + if len(actual) != len(expected) { 322 + t.Errorf("path %s: @test(err, suberr=...): got %d sub-error(s), want %d", 323 + path, len(actual), len(expected)) 324 + for i, a := range actual { 325 + t.Logf(" actual[%d]: %s", i, a.Error()) 326 + } 246 327 return 247 328 } 248 - positions := cueerrors.Positions(err) 249 - expected := pa.errArgs.pos 329 + 330 + // matchedPair records an (actual, expected) pairing together with expIdx — 331 + // the 0-based index of the expected spec within ea.suberrs, which is the 332 + // same as its ordinal position among suberr=(...) groups in the source 333 + // attribute text (needed for pos= writeback). 334 + type matchedPair struct { 335 + act cueerrors.Error 336 + exp *errArgs 337 + expIdx int 338 + } 250 339 251 - match := len(positions) == len(expected) 252 - if match { 253 - for i, exp := range expected { 254 - got := positions[i] 255 - if exp.fileName != "" { 256 - // Absolute form: match filename + absolute line + column. 257 - // Normalize the position filename for archives loaded via 258 - // loadWithConfig, which stores absolute paths in positions. 259 - if r.relFilename(got.Filename()) != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 260 - match = false 261 - break 262 - } 263 - } else { 264 - // Relative form: match line delta from @test + column. 265 - if got.Line() != pa.baseLine+exp.deltaLine || got.Column() != exp.col { 266 - match = false 267 - break 340 + usedAct := make([]bool, len(actual)) 341 + expMatched := make([]bool, len(expected)) 342 + var pairs []matchedPair 343 + 344 + // Pass 1 — exact: specs with non-empty pos= matched first. 345 + // These specs uniquely identify an error even when contains substrings overlap. 346 + for i, exp := range expected { 347 + if !exp.posSet || len(exp.pos) == 0 { 348 + continue 349 + } 350 + for j, act := range actual { 351 + if !usedAct[j] && r.matchesErrSpec(act, exp, pa.baseLine) { 352 + usedAct[j] = true 353 + expMatched[i] = true 354 + pairs = append(pairs, matchedPair{act, exp, i}) 355 + break 356 + } 357 + } 358 + } 359 + 360 + // Pass 2 — contains: specs not yet matched (pos=[] or no pos=) matched 361 + // against remaining actual errors by contains= only. 362 + // Specs with non-empty pos= are handled by pass 1 and the reporting 363 + // section below; skip them here to avoid duplicate error messages. 364 + for i, exp := range expected { 365 + if expMatched[i] { 366 + continue 367 + } 368 + if exp.posSet && len(exp.pos) > 0 { 369 + continue 370 + } 371 + matched := false 372 + for j, act := range actual { 373 + if !usedAct[j] && r.matchesErrSpec(act, exp, pa.baseLine) { 374 + usedAct[j] = true 375 + expMatched[i] = true 376 + matched = true 377 + pairs = append(pairs, matchedPair{act, exp, i}) 378 + break 379 + } 380 + } 381 + if !matched { 382 + desc := exp.contains 383 + if desc == "" { 384 + desc = fmt.Sprintf("code=%v", exp.codes) 385 + } 386 + t.Errorf("path %s: @test(err, suberr=...): no sub-error matched %q", path, desc) 387 + } 388 + } 389 + // Report pass-1 specs that also failed to match. 390 + // When contains= is set, scan all actual errors for a contains-only match 391 + // and report a position diff; this is more informative than "no match". 392 + for i, exp := range expected { 393 + if !expMatched[i] && exp.posSet && len(exp.pos) > 0 { 394 + if exp.contains == "" { 395 + t.Errorf("path %s: @test(err, suberr=...): no sub-error matched pos=%v", path, exp.pos) 396 + continue 397 + } 398 + found := false 399 + for _, act := range actual { 400 + if !strings.Contains(act.Error(), exp.contains) { 401 + continue 268 402 } 403 + found = true 404 + r.reportPosMismatch(t, path, "@test(err, suberr=...)", positionsFromSingleError(act), exp.pos, pa.baseLine) 405 + break 406 + } 407 + if !found { 408 + t.Errorf("path %s: @test(err, suberr=...): no sub-error matched pos=%v contains=%q", 409 + path, exp.pos, exp.contains) 269 410 } 270 411 } 271 412 } 272 - if match { 273 - return 413 + 414 + // Validate / writeback positions for matched pairs. 415 + // Collect all placeholder updates and apply them atomically to avoid 416 + // multiple posWrite entries clobbering each other on the same attribute. 417 + var posUpdates []posUpdate 418 + needWriteback := false 419 + for _, p := range pairs { 420 + if !p.exp.posSet { 421 + continue 422 + } 423 + positions := positionsFromSingleError(p.act) 424 + isPlaceholder := len(p.exp.pos) == 0 425 + if posSpecsMatch(positions, p.exp.pos, pa.baseLine, r.relFilename) { 426 + continue 427 + } 428 + if isPlaceholder && (cuetest.UpdateGoldenFiles || cuetest.ForceUpdateGoldenFiles) { 429 + posUpdates = append(posUpdates, posUpdate{p.expIdx, positions}) 430 + needWriteback = true 431 + continue 432 + } 433 + if !isPlaceholder && cuetest.ForceUpdateGoldenFiles { 434 + posUpdates = append(posUpdates, posUpdate{p.expIdx, positions}) 435 + needWriteback = true 436 + continue 437 + } 438 + // Report mismatch. 439 + r.reportPosMismatch(t, path, "@test(err, suberr=...)", positions, p.exp.pos, pa.baseLine) 440 + } 441 + if needWriteback { 442 + r.enqueueSubErrPosWrites(pa, posUpdates) 274 443 } 444 + } 275 445 276 - // pos=[] is a fill-in placeholder: update with CUE_UPDATE=1. 277 - // pos=[non-empty] that is wrong: update only with CUE_UPDATE=force. 278 - isPlaceholder := len(expected) == 0 279 - if (isPlaceholder && cuetest.UpdateGoldenFiles) || cuetest.ForceUpdateGoldenFiles { 280 - r.enqueuePosWrite(pa, positions) 281 - return 446 + // enqueueSubErrPosWrites applies all sub-error position updates atomically to 447 + // the source attribute, producing a single posWrite entry. Each update replaces 448 + // pos=[...] in the expIdx-th suberr=(...) group. 449 + // formatPosSpec converts a single token.Pos to a position spec string. 450 + // Positions in the same file as the @test attribute are written as 451 + // deltaLine:col (relative to pa.baseLine); positions in other files are 452 + // written as filename:absLine:col (absolute). 453 + func (r *inlineRunner) formatPosSpec(p token.Pos, pa parsedTestAttr) string { 454 + if p.Filename() == "" || p.Filename() == pa.srcFileName { 455 + return fmt.Sprintf("%d:%d", p.Line()-pa.baseLine, p.Column()) 282 456 } 457 + return fmt.Sprintf("%s:%d:%d", r.relFilename(p.Filename()), p.Line(), p.Column()) 458 + } 283 459 284 - if len(positions) != len(expected) { 285 - t.Errorf("path %s: @test(err, pos=...): got %d position(s), want %d", path, len(positions), len(expected)) 460 + func (r *inlineRunner) enqueueSubErrPosWrites(pa parsedTestAttr, updates []posUpdate) { 461 + newAttrText := pa.srcAttr.Text 462 + // Apply updates from highest expIdx to lowest so earlier indices stay valid. 463 + slices.SortFunc(updates, func(a, b posUpdate) int { 464 + return b.expIdx - a.expIdx 465 + }) 466 + for _, u := range updates { 467 + parts := make([]string, len(u.positions)) 468 + for i, p := range u.positions { 469 + parts[i] = r.formatPosSpec(p, pa) 470 + } 471 + newPosStr := strings.Join(parts, " ") 472 + newAttrText = replaceSuberrPos(newAttrText, u.expIdx, newPosStr) 473 + } 474 + r.pendingPosWrites = append(r.pendingPosWrites, posWrite{ 475 + fileName: pa.srcFileName, 476 + attrOffset: pa.srcAttr.Pos().Offset(), 477 + attrLen: len(pa.srcAttr.Text), 478 + newAttrText: newAttrText, 479 + }) 480 + } 481 + 482 + // replaceSuberrPos replaces the pos=[...] content in the n-th suberr=(...) 483 + // group within attrText with newPosContent. 484 + func replaceSuberrPos(attrText string, n int, newPosContent string) string { 485 + // Find the start of the n-th "suberr=(" occurrence. 486 + pos := 0 487 + for i := 0; i <= n; i++ { 488 + idx := strings.Index(attrText[pos:], "suberr=(") 489 + if idx < 0 { 490 + return attrText 491 + } 492 + if i < n { 493 + pos += idx + len("suberr=(") 494 + } else { 495 + pos += idx 496 + } 497 + } 498 + // Scan past "suberr=(" to find the content end (matching closing paren). 499 + innerStart := pos + len("suberr=(") 500 + depth := 1 501 + end := innerStart 502 + for end < len(attrText) && depth > 0 { 503 + switch attrText[end] { 504 + case '(': 505 + depth++ 506 + case ')': 507 + depth-- 508 + } 509 + if depth > 0 { 510 + end++ 511 + } 512 + } 513 + // attrText[innerStart:end] is the content inside suberr=(...). 514 + inner := attrText[innerStart:end] 515 + posIdx := strings.Index(inner, "pos=[") 516 + if posIdx < 0 { 517 + return attrText // no pos= in this suberr group 518 + } 519 + bracket := posIdx + len("pos=[") 520 + closeIdx := strings.Index(inner[bracket:], "]") 521 + if closeIdx < 0 { 522 + return attrText 523 + } 524 + closeIdx += bracket + 1 // include "]" 525 + newInner := inner[:posIdx] + "pos=[" + newPosContent + "]" + inner[closeIdx:] 526 + return attrText[:innerStart] + newInner + attrText[end:] 527 + } 528 + 529 + // isDisjunctionHeader reports whether e is the synthetic summary error that 530 + // CUE prepends to a failed-disjunction error list, e.g. "2 errors in empty 531 + // disjunction:". These entries are structural scaffolding and not individual 532 + // disjunct errors. 533 + func isDisjunctionHeader(e cueerrors.Error) bool { 534 + msg := e.Error() 535 + // The header always has the form "N errors in empty disjunction:" where N >= 2. 536 + // Use a simple heuristic: ends with "errors in empty disjunction:". 537 + return strings.Contains(msg, "errors in empty disjunction:") 538 + } 539 + 540 + // positionsFromSingleError extracts the token positions from a single 541 + // cueerrors.Error (primary position first, then input positions sorted). 542 + // Unlike cueerrors.Positions, this works on an individual error rather than 543 + // potentially a list (where cueerrors.Positions only sees the first element). 544 + func positionsFromSingleError(e cueerrors.Error) []token.Pos { 545 + var a []token.Pos 546 + if p := e.Position(); p.File() != nil { 547 + a = append(a, p) 548 + } 549 + sortOffset := len(a) 550 + for _, p := range e.InputPositions() { 551 + if p.File() != nil && p != e.Position() { 552 + a = append(a, p) 553 + } 554 + } 555 + slices.SortFunc(a[sortOffset:], token.Pos.Compare) 556 + return slices.Compact(a) 557 + } 558 + 559 + // posSpecsMatch reports whether positions match specs exactly. 560 + // baseLine is the line number of the @test attribute; used to resolve relative specs. 561 + // The runner's relFilename method is needed for absolute specs — pass it as a func. 562 + func posSpecsMatch(positions []token.Pos, specs []posSpec, baseLine int, relFilename func(string) string) bool { 563 + if len(positions) != len(specs) { 564 + return false 565 + } 566 + for i, exp := range specs { 567 + got := positions[i] 568 + if exp.fileName != "" { 569 + if relFilename(got.Filename()) != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 570 + return false 571 + } 572 + } else { 573 + if got.Line() != baseLine+exp.deltaLine || got.Column() != exp.col { 574 + return false 575 + } 576 + } 577 + } 578 + return true 579 + } 580 + 581 + // reportPosMismatch reports a position mismatch between actual positions and 582 + // expected specs. directive is included verbatim in each error message. 583 + // If counts differ, only the count error is reported. Otherwise each 584 + // mismatched position is reported individually. 585 + func (r *inlineRunner) reportPosMismatch(t testing.TB, path cue.Path, directive string, positions []token.Pos, specs []posSpec, baseLine int) { 586 + t.Helper() 587 + if len(positions) != len(specs) { 588 + t.Errorf("path %s: %s: got %d position(s), want %d", path, directive, len(positions), len(specs)) 286 589 for i, p := range positions { 287 590 t.Logf(" actual[%d]: %d:%d", i, p.Line(), p.Column()) 288 591 } 289 592 return 290 593 } 291 - for i, exp := range expected { 594 + for i, exp := range specs { 292 595 got := positions[i] 293 596 if exp.fileName != "" { 294 597 gotFile := r.relFilename(got.Filename()) 295 598 if gotFile != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 296 - t.Errorf("path %s: @test(err, pos=...): position[%d]: got %s:%d:%d, want %s:%d:%d", 297 - path, i, gotFile, got.Line(), got.Column(), exp.fileName, exp.absLine, exp.col) 599 + t.Errorf("path %s: %s: position[%d]: got %s:%d:%d, want %s:%d:%d", 600 + path, directive, i, gotFile, got.Line(), got.Column(), exp.fileName, exp.absLine, exp.col) 298 601 } 299 602 } else { 300 - wantLine := pa.baseLine + exp.deltaLine 603 + wantLine := baseLine + exp.deltaLine 301 604 if got.Line() != wantLine || got.Column() != exp.col { 302 - t.Errorf("path %s: @test(err, pos=...): position[%d]: got %d:%d, want %d:%d", 303 - path, i, got.Line(), got.Column(), wantLine, exp.col) 605 + t.Errorf("path %s: %s: position[%d]: got %d:%d, want %d:%d", 606 + path, directive, i, got.Line()-baseLine, got.Column(), exp.deltaLine, exp.col) 304 607 } 305 608 } 306 609 } 307 610 } 308 611 309 - // posWrite records a pending pos= attribute update for CUE_UPDATE write-back. 310 - type posWrite struct { 311 - fileName string // archive .cue file name, e.g. "in.cue" 312 - attrOffset int // byte offset of the @test attr in the original file data 313 - attrLen int // byte length of the original @test attr text 314 - newAttrText string // replacement attribute text with updated pos=[...] 612 + // checkErrPositions verifies that the error positions on val match the pos= 613 + 614 + // spec in pa. When positions don't match: 615 + // - pos=[] (placeholder): update on CUE_UPDATE=1. 616 + // - pos=[non-empty]: update on CUE_UPDATE=force only. 617 + func (r *inlineRunner) checkErrPositions(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 618 + t.Helper() 619 + err := val.Err() 620 + if err == nil { 621 + t.Errorf("path %s: @test(err, pos=...): value has no error", path) 622 + return 623 + } 624 + positions := cueerrors.Positions(err) 625 + expected := pa.errArgs.pos 626 + 627 + if posSpecsMatch(positions, expected, pa.baseLine, r.relFilename) { 628 + return 629 + } 630 + 631 + // pos=[] is a fill-in placeholder: update with CUE_UPDATE=1. 632 + // pos=[non-empty] that is wrong: update only with CUE_UPDATE=force. 633 + isPlaceholder := len(expected) == 0 634 + if (isPlaceholder && cuetest.UpdateGoldenFiles) || cuetest.ForceUpdateGoldenFiles { 635 + r.enqueuePosWrite(pa, positions) 636 + return 637 + } 638 + 639 + r.reportPosMismatch(t, path, "@test(err, pos=...)", positions, expected, pa.baseLine) 315 640 } 316 641 317 642 // enqueuePosWrite formats positions as pos specs and enqueues a write-back ··· 323 648 func (r *inlineRunner) enqueuePosWrite(pa parsedTestAttr, positions []token.Pos) { 324 649 parts := make([]string, len(positions)) 325 650 for i, p := range positions { 326 - if p.Filename() == "" || p.Filename() == pa.srcFileName { 327 - parts[i] = fmt.Sprintf("%d:%d", p.Line()-pa.baseLine, p.Column()) 328 - } else { 329 - parts[i] = fmt.Sprintf("%s:%d:%d", p.Filename(), p.Line(), p.Column()) 330 - } 651 + parts[i] = r.formatPosSpec(p, pa) 331 652 } 332 653 newPosStr := strings.Join(parts, " ") 333 654
+32
internal/cuetxtar/inlinerunner_test.go
··· 301 301 `) 302 302 }) 303 303 } 304 + 305 + // TestInlineRunner_SubErrors verifies @test(err, suberr=(...)) sub-error matching. 306 + func TestInlineRunner_SubErrors(t *testing.T) { 307 + run := func(t *testing.T, archiveStr string) { 308 + t.Helper() 309 + archive := txtar.Parse([]byte(archiveStr)) 310 + runner := cuetxtar.NewInlineRunner(t, nil, archive, t.TempDir()) 311 + runner.Run() 312 + } 313 + 314 + t.Run("two sub-errors both matched passes", func(t *testing.T) { 315 + // null | {n: 3} unified with #empty (closed {}) & {n: 3} produces two sub-errors. 316 + run(t, `-- test.cue -- 317 + #empty: {} 318 + x: null | {n: 3} 319 + x: #empty & {n: 3} @test(err, code=eval, 320 + suberr=(contains="conflicting values"), 321 + suberr=(contains="not allowed")) 322 + `) 323 + }) 324 + 325 + t.Run("order-independent matching passes", func(t *testing.T) { 326 + // Specs in reversed order relative to actual sub-errors — should still pass. 327 + run(t, `-- test.cue -- 328 + #empty: {} 329 + x: null | {n: 3} 330 + x: #empty & {n: 3} @test(err, code=eval, 331 + suberr=(contains="not allowed"), 332 + suberr=(contains="conflicting values")) 333 + `) 334 + }) 335 + }