this repo has no description
0
fork

Configure Feed

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

internal/cuetxtar: move error assertion code to inline_err.go

Extract all @test(err, ...) infrastructure from inline.go into a
dedicated inline_err.go: types (posSpec, errArgs, posWrite), parsing
(parseErrArgs, parseParenList, parsePosSpecs), and runner methods
(runErrAssertion, checkErrPositions, enqueuePosWrite, applyPosWritebacks,
isError, errorCode, errorMessage, findDescendantError).

No functional changes; this is a pure file split.

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

+446 -411
-411
internal/cuetxtar/inline.go
··· 51 51 "cuelang.org/go/cue/format" 52 52 "cuelang.org/go/cue/load" 53 53 "cuelang.org/go/cue/parser" 54 - "cuelang.org/go/cue/token" 55 54 "cuelang.org/go/internal" 56 55 "cuelang.org/go/internal/core/debug" 57 56 "cuelang.org/go/internal/cuetdtest" ··· 175 174 baseLine int 176 175 } 177 176 178 - // posSpec is an expected error position. Two forms are supported: 179 - // 180 - // Relative form (fileName == ""): the position is expressed as a signed line 181 - // delta from the @test attribute's line (0 = same line) and a 1-indexed 182 - // column. Written as deltaLine:col (e.g. "0:5", "-2:13"). Using a delta keeps 183 - // the assertion stable when lines are added or removed above the test. 184 - // 185 - // Absolute form (fileName != ""): the position is in a different file, given 186 - // as a filename, absolute 1-indexed line number, and 1-indexed column. Written 187 - // as filename:absLine:col (e.g. "fixture.cue:3:5"). This form is used for 188 - // errors whose source location is in a shared fixture file. 189 - type posSpec struct { 190 - // fileName is non-empty for cross-file (absolute) positions. 191 - fileName string 192 - // deltaLine is the signed offset from the @test line; used when fileName == "". 193 - deltaLine int 194 - // absLine is the absolute 1-indexed line number; used when fileName != "". 195 - absLine int 196 - // col is the 1-indexed column number (used in both forms). 197 - col int 198 - } 199 - 200 - // errArgs holds parsed sub-options from an @test(err, ...) directive. 201 - type errArgs struct { 202 - // codes holds the acceptable error codes, e.g. ["cycle"] or ["cycle", "incomplete"]. 203 - // A single code= value is stored as a one-element slice; code=(a|b) as two. 204 - // An empty slice means any error code is accepted. 205 - codes []string 206 - // contains is a substring the error message must contain. 207 - contains string 208 - // any requires any descendant of the annotated field to have the error. 209 - any bool 210 - // paths lists specific paths (relative to test-case root) where the error 211 - // must occur. Populated when path=(...) is present. 212 - paths []string 213 - // pos lists expected error positions as (deltaLine:col) pairs relative to 214 - // the line containing the @test attribute. 215 - pos []posSpec 216 - // posSet is true when pos= was 217 - // explicitly provided (including pos=[] to assert no positions). 218 - posSet bool 219 - } 220 - 221 - // matchesCode reports whether the given error code satisfies the codes 222 - // constraint. An empty codes slice means any code is accepted. 223 - func (ea *errArgs) matchesCode(got string) bool { 224 - if len(ea.codes) == 0 { 225 - return true 226 - } 227 - return slices.Contains(ea.codes, got) 228 - } 229 - 230 177 // parseTestAttr parses the body of a @test(...) attribute node. 231 178 // It returns a parsedTestAttr for each logical directive in the attribute. 232 179 // A single @test(...) contains exactly one directive (the first positional ··· 288 235 } 289 236 290 237 return result, nil 291 - } 292 - 293 - // parseErrArgs extracts err sub-options from an already-parsed Attr. 294 - // The attribute body is expected to start with "err" as the first positional arg. 295 - func parseErrArgs(a internal.Attr) (errArgs, error) { 296 - var ea errArgs 297 - // Start from index 1 (index 0 is "err"). 298 - for _, kv := range a.Fields[1:] { 299 - switch { 300 - case kv.Key() == "code": 301 - codes, err := parseParenList(kv.Value()) 302 - if err != nil { 303 - return ea, fmt.Errorf("@test(err, code=...): %w", err) 304 - } 305 - ea.codes = codes 306 - case kv.Key() == "contains": 307 - ea.contains = kv.Value() 308 - case kv.Key() == "" && kv.Value() == "any": 309 - ea.any = true 310 - case kv.Key() == "path": 311 - paths, err := parseParenList(kv.Value()) 312 - if err != nil { 313 - return ea, fmt.Errorf("@test(err, path=...): %w", err) 314 - } 315 - ea.paths = paths 316 - case kv.Key() == "pos": 317 - specs, err := parsePosSpecs(kv.Value()) 318 - if err != nil { 319 - return ea, fmt.Errorf("@test(err, pos=...): %w", err) 320 - } 321 - ea.pos = specs 322 - ea.posSet = true 323 - } 324 - } 325 - return ea, nil 326 - } 327 - 328 - // parseParenList parses a balanced parenthesized pipe-separated list like 329 - // (path1|path2|path3), returning ["path1","path2","path3"]. 330 - // The input may or may not include the outer parentheses. 331 - func parseParenList(s string) ([]string, error) { 332 - s = strings.TrimSpace(s) 333 - if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { 334 - s = s[1 : len(s)-1] 335 - } 336 - if s == "" { 337 - return nil, nil 338 - } 339 - parts := strings.Split(s, "|") 340 - for i, p := range parts { 341 - parts[i] = strings.TrimSpace(p) 342 - } 343 - return parts, nil 344 - } 345 - 346 - // parsePosSpecs parses a pos= value into a slice of posSpec. 347 - // The value must be enclosed in square brackets; elements are whitespace-separated. 348 - // Two element forms are supported: 349 - // 350 - // - deltaLine:col — relative position on the same file (one colon). 351 - // deltaLine is a signed offset from the @test attribute's line (0 = same line). 352 - // - filename:absLine:col — absolute position in another file (two colons). 353 - // absLine is the 1-indexed line in the named file. 354 - func parsePosSpecs(s string) ([]posSpec, error) { 355 - s = strings.TrimSpace(s) 356 - if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { 357 - return nil, fmt.Errorf("pos= value must be enclosed in square brackets, got %q", s) 358 - } 359 - s = s[1 : len(s)-1] 360 - var specs []posSpec 361 - for _, p := range strings.Fields(s) { 362 - parts := strings.SplitN(p, ":", 3) 363 - switch len(parts) { 364 - case 2: 365 - // Relative form: deltaLine:col 366 - deltaLine, err := strconv.Atoi(parts[0]) 367 - if err != nil { 368 - return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 369 - } 370 - colNum, err := strconv.Atoi(parts[1]) 371 - if err != nil { 372 - return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 373 - } 374 - specs = append(specs, posSpec{deltaLine: deltaLine, col: colNum}) 375 - case 3: 376 - // Absolute form: filename:absLine:col 377 - fileName := parts[0] 378 - absLine, err := strconv.Atoi(parts[1]) 379 - if err != nil { 380 - return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 381 - } 382 - colNum, err := strconv.Atoi(parts[2]) 383 - if err != nil { 384 - return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 385 - } 386 - specs = append(specs, posSpec{fileName: fileName, absLine: absLine, col: colNum}) 387 - default: 388 - return nil, fmt.Errorf("invalid pos spec %q: expected deltaLine:col or filename:line:col", p) 389 - } 390 - } 391 - return specs, nil 392 238 } 393 239 394 240 // ───────────────────────────────────────────────────────────────────────────── ··· 1320 1166 return fmt.Sprintf("%v", v) 1321 1167 } 1322 1168 return string(b) 1323 - } 1324 - 1325 - // runErrAssertion checks that an error is present at val, applying sub-options. 1326 - func (r *inlineRunner) runErrAssertion(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 1327 - t.Helper() 1328 - ea := pa.errArgs 1329 - if ea == nil { 1330 - // Bare @test(err) — just check that the value is an error. 1331 - if !r.isError(val) { 1332 - t.Errorf("path %s: expected error, got non-error value", path) 1333 - } 1334 - return 1335 - } 1336 - 1337 - if ea.any { 1338 - // @test(err, any, ...) — check that any descendant has the error. 1339 - found := r.findDescendantError(val, ea) 1340 - if !found { 1341 - t.Errorf("path %s: expected a descendant error with code=%v, none found", path, ea.codes) 1342 - } 1343 - return 1344 - } 1345 - 1346 - if !r.isError(val) { 1347 - t.Errorf("path %s: expected error, got non-error value", path) 1348 - return 1349 - } 1350 - 1351 - // Validate error code. 1352 - if len(ea.codes) > 0 { 1353 - gotCode := r.errorCode(val) 1354 - if !ea.matchesCode(gotCode) { 1355 - t.Errorf("path %s: expected error code %v, got %q", path, ea.codes, gotCode) 1356 - } 1357 - } 1358 - // Validate error message contains. 1359 - if ea.contains != "" { 1360 - msg := r.errorMessage(val) 1361 - if !strings.Contains(msg, ea.contains) { 1362 - t.Errorf("path %s: expected error message to contain %q, got %q", path, ea.contains, msg) 1363 - } 1364 - } 1365 - // Validate error positions. 1366 - if ea.posSet { 1367 - r.checkErrPositions(t, path, val, pa) 1368 - } 1369 - } 1370 - 1371 - // checkErrPositions verifies that the error positions on val match the pos= 1372 - // spec in pa. When positions don't match: 1373 - // - pos=[] (placeholder): update on CUE_UPDATE=1. 1374 - // - pos=[non-empty]: update on CUE_UPDATE=force only. 1375 - func (r *inlineRunner) checkErrPositions(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 1376 - t.Helper() 1377 - err := val.Err() 1378 - if err == nil { 1379 - t.Errorf("path %s: @test(err, pos=...): value has no error", path) 1380 - return 1381 - } 1382 - positions := cueerrors.Positions(err) 1383 - expected := pa.errArgs.pos 1384 - 1385 - match := len(positions) == len(expected) 1386 - if match { 1387 - for i, exp := range expected { 1388 - got := positions[i] 1389 - if exp.fileName != "" { 1390 - // Absolute form: match filename + absolute line + column. 1391 - // Normalize the position filename for archives loaded via 1392 - // loadWithConfig, which stores absolute paths in positions. 1393 - if r.relFilename(got.Filename()) != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 1394 - match = false 1395 - break 1396 - } 1397 - } else { 1398 - // Relative form: match line delta from @test + column. 1399 - if got.Line() != pa.baseLine+exp.deltaLine || got.Column() != exp.col { 1400 - match = false 1401 - break 1402 - } 1403 - } 1404 - } 1405 - } 1406 - if match { 1407 - return 1408 - } 1409 - 1410 - // pos=[] is a fill-in placeholder: update with CUE_UPDATE=1. 1411 - // pos=[non-empty] that is wrong: update only with CUE_UPDATE=force. 1412 - isPlaceholder := len(expected) == 0 1413 - if (isPlaceholder && cuetest.UpdateGoldenFiles) || cuetest.ForceUpdateGoldenFiles { 1414 - r.enqueuePosWrite(pa, positions) 1415 - return 1416 - } 1417 - 1418 - if len(positions) != len(expected) { 1419 - t.Errorf("path %s: @test(err, pos=...): got %d position(s), want %d", path, len(positions), len(expected)) 1420 - for i, p := range positions { 1421 - t.Logf(" actual[%d]: %d:%d", i, p.Line(), p.Column()) 1422 - } 1423 - return 1424 - } 1425 - for i, exp := range expected { 1426 - got := positions[i] 1427 - if exp.fileName != "" { 1428 - gotFile := r.relFilename(got.Filename()) 1429 - if gotFile != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 1430 - t.Errorf("path %s: @test(err, pos=...): position[%d]: got %s:%d:%d, want %s:%d:%d", 1431 - path, i, gotFile, got.Line(), got.Column(), exp.fileName, exp.absLine, exp.col) 1432 - } 1433 - } else { 1434 - wantLine := pa.baseLine + exp.deltaLine 1435 - if got.Line() != wantLine || got.Column() != exp.col { 1436 - t.Errorf("path %s: @test(err, pos=...): position[%d]: got %d:%d, want %d:%d", 1437 - path, i, got.Line(), got.Column(), wantLine, exp.col) 1438 - } 1439 - } 1440 - } 1441 - } 1442 - 1443 - // posWrite records a pending pos= attribute update for CUE_UPDATE write-back. 1444 - type posWrite struct { 1445 - fileName string // archive .cue file name, e.g. "in.cue" 1446 - attrOffset int // byte offset of the @test attr in the original file data 1447 - attrLen int // byte length of the original @test attr text 1448 - newAttrText string // replacement attribute text with updated pos=[...] 1449 - } 1450 - 1451 - // enqueuePosWrite formats positions as pos specs and enqueues a write-back 1452 - // that replaces the pos=[...] value in the source attribute. 1453 - // 1454 - // Positions in the same file as the @test attribute are written as deltaLine:col 1455 - // (relative to pa.baseLine). Positions in a different file are written as 1456 - // filename:absLine:col (absolute). 1457 - func (r *inlineRunner) enqueuePosWrite(pa parsedTestAttr, positions []token.Pos) { 1458 - parts := make([]string, len(positions)) 1459 - for i, p := range positions { 1460 - if p.Filename() == "" || p.Filename() == pa.srcFileName { 1461 - parts[i] = fmt.Sprintf("%d:%d", p.Line()-pa.baseLine, p.Column()) 1462 - } else { 1463 - parts[i] = fmt.Sprintf("%s:%d:%d", p.Filename(), p.Line(), p.Column()) 1464 - } 1465 - } 1466 - newPosStr := strings.Join(parts, " ") 1467 - 1468 - old := pa.srcAttr.Text 1469 - start := strings.Index(old, "pos=[") 1470 - if start < 0 { 1471 - return 1472 - } 1473 - bracket := start + len("pos=[") 1474 - end := strings.Index(old[bracket:], "]") 1475 - if end < 0 { 1476 - return 1477 - } 1478 - end += bracket + 1 // include the "]" 1479 - newAttrText := old[:start] + "pos=[" + newPosStr + "]" + old[end:] 1480 - 1481 - r.pendingPosWrites = append(r.pendingPosWrites, posWrite{ 1482 - fileName: pa.srcFileName, 1483 - attrOffset: pa.srcAttr.Pos().Offset(), 1484 - attrLen: len(pa.srcAttr.Text), 1485 - newAttrText: newAttrText, 1486 - }) 1487 - } 1488 - 1489 - // applyPosWritebacks writes pending pos= attribute updates to the archive file. 1490 - // Replacements are applied by byte offset from end to start so that earlier 1491 - // offsets remain valid after each substitution. 1492 - func (r *inlineRunner) applyPosWritebacks() { 1493 - if len(r.pendingPosWrites) == 0 || r.filePath == "" { 1494 - return 1495 - } 1496 - changed := false 1497 - for i, f := range r.archive.Files { 1498 - var writes []posWrite 1499 - for _, pw := range r.pendingPosWrites { 1500 - if pw.fileName == f.Name { 1501 - writes = append(writes, pw) 1502 - } 1503 - } 1504 - if len(writes) == 0 { 1505 - continue 1506 - } 1507 - // Sort descending by offset so earlier offsets stay valid. 1508 - slices.SortFunc(writes, func(a, b posWrite) int { 1509 - return b.attrOffset - a.attrOffset 1510 - }) 1511 - data := append([]byte(nil), f.Data...) 1512 - for _, pw := range writes { 1513 - end := pw.attrOffset + pw.attrLen 1514 - if end > len(data) { 1515 - continue 1516 - } 1517 - data = append(data[:pw.attrOffset:pw.attrOffset], 1518 - append([]byte(pw.newAttrText), data[end:]...)...) 1519 - changed = true 1520 - } 1521 - r.archive.Files[i].Data = data 1522 - } 1523 - if changed { 1524 - out := txtar.Format(r.archive) 1525 - if err := os.WriteFile(r.filePath, out, 0o644); err != nil { 1526 - r.t.Errorf("inline: pos write-back to %s: %v", r.filePath, err) 1527 - } 1528 - } 1529 - } 1530 - 1531 - // isError reports whether val is an error value (bottom). 1532 - func (r *inlineRunner) isError(val cue.Value) bool { 1533 - core := val.Core() 1534 - if core.V == nil { 1535 - return false 1536 - } 1537 - return core.V.Bottom() != nil 1538 - } 1539 - 1540 - // errorCode returns the string code of the error at val, or "" if not an error. 1541 - func (r *inlineRunner) errorCode(val cue.Value) string { 1542 - core := val.Core() 1543 - if core.V == nil { 1544 - return "" 1545 - } 1546 - b := core.V.Bottom() 1547 - if b == nil { 1548 - return "" 1549 - } 1550 - return b.Code.String() 1551 - } 1552 - 1553 - // errorMessage returns the human-readable error message for val. 1554 - func (r *inlineRunner) errorMessage(val cue.Value) string { 1555 - if err := val.Err(); err != nil { 1556 - return err.Error() 1557 - } 1558 - return "" 1559 - } 1560 - 1561 - // findDescendantError walks val looking for any descendant with an error 1562 - // matching ea. Returns true if found. 1563 - func (r *inlineRunner) findDescendantError(val cue.Value, ea *errArgs) bool { 1564 - if r.isError(val) { 1565 - if ea.matchesCode(r.errorCode(val)) { 1566 - return true 1567 - } 1568 - } 1569 - // Walk fields. 1570 - iter, err := val.Fields() 1571 - if err != nil { 1572 - return false 1573 - } 1574 - for iter.Next() { 1575 - if r.findDescendantError(iter.Value(), ea) { 1576 - return true 1577 - } 1578 - } 1579 - return false 1580 1169 } 1581 1170 1582 1171 // runLeqInline checks that val is subsumed by the constraint in pa.
+446
internal/cuetxtar/inline_err.go
··· 1 + // Copyright 2026 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + // This file contains all error-assertion logic for the inline test runner: 16 + // types, parsing, matching, position checking, and write-back for 17 + // @test(err, ...) directives. 18 + 19 + package cuetxtar 20 + 21 + import ( 22 + "fmt" 23 + "os" 24 + "slices" 25 + "strconv" 26 + "strings" 27 + "testing" 28 + 29 + "golang.org/x/tools/txtar" 30 + 31 + "cuelang.org/go/cue" 32 + cueerrors "cuelang.org/go/cue/errors" 33 + "cuelang.org/go/cue/token" 34 + "cuelang.org/go/internal" 35 + "cuelang.org/go/internal/cuetest" 36 + ) 37 + 38 + // posSpec is an expected error position. Two forms are supported: 39 + // 40 + // Relative form (fileName == ""): the position is expressed as a signed line 41 + // delta from the @test attribute's line (0 = same line) and a 1-indexed 42 + // column. Written as deltaLine:col (e.g. "0:5", "-2:13"). Using a delta keeps 43 + // the assertion stable when lines are added or removed above the test. 44 + // 45 + // Absolute form (fileName != ""): the position is in a different file, given 46 + // as a filename, absolute 1-indexed line number, and 1-indexed column. Written 47 + // as filename:absLine:col (e.g. "fixture.cue:3:5"). This form is used for 48 + // errors whose source location is in a shared fixture file. 49 + type posSpec struct { 50 + // fileName is non-empty for cross-file (absolute) positions. 51 + fileName string 52 + // deltaLine is the signed offset from the @test line; used when fileName == "". 53 + deltaLine int 54 + // absLine is the absolute 1-indexed line number; used when fileName != "". 55 + absLine int 56 + // col is the 1-indexed column number (used in both forms). 57 + col int 58 + } 59 + 60 + // errArgs holds parsed sub-options from an @test(err, ...) directive. 61 + type errArgs struct { 62 + // codes holds the acceptable error codes, e.g. ["cycle"] or ["cycle", "incomplete"]. 63 + // A single code= value is stored as a one-element slice; code=(a|b) as two. 64 + // An empty slice means any error code is accepted. 65 + codes []string 66 + // contains is a substring the error message must contain. 67 + contains string 68 + // any requires any descendant of the annotated field to have the error. 69 + any bool 70 + // paths lists specific paths (relative to test-case root) where the error 71 + // must occur. Populated when path=(...) is present. 72 + paths []string 73 + // pos lists expected error positions as (deltaLine:col) pairs relative to 74 + // the line containing the @test attribute. 75 + pos []posSpec 76 + // posSet is true when pos= was 77 + // explicitly provided (including pos=[] to assert no positions). 78 + posSet bool 79 + } 80 + 81 + // matchesCode reports whether the given error code satisfies the codes 82 + // constraint. An empty codes slice means any code is accepted. 83 + func (ea *errArgs) matchesCode(got string) bool { 84 + if len(ea.codes) == 0 { 85 + return true 86 + } 87 + return slices.Contains(ea.codes, got) 88 + } 89 + 90 + // parseErrArgs extracts err sub-options from an already-parsed Attr. 91 + // The attribute body is expected to start with "err" as the first positional arg. 92 + func parseErrArgs(a internal.Attr) (errArgs, error) { 93 + var ea errArgs 94 + // Start from index 1 (index 0 is "err"). 95 + for _, kv := range a.Fields[1:] { 96 + switch { 97 + case kv.Key() == "code": 98 + codes, err := parseParenList(kv.Value()) 99 + if err != nil { 100 + return ea, fmt.Errorf("@test(err, code=...): %w", err) 101 + } 102 + ea.codes = codes 103 + case kv.Key() == "contains": 104 + ea.contains = kv.Value() 105 + case kv.Key() == "" && kv.Value() == "any": 106 + ea.any = true 107 + case kv.Key() == "path": 108 + paths, err := parseParenList(kv.Value()) 109 + if err != nil { 110 + return ea, fmt.Errorf("@test(err, path=...): %w", err) 111 + } 112 + ea.paths = paths 113 + case kv.Key() == "pos": 114 + specs, err := parsePosSpecs(kv.Value()) 115 + if err != nil { 116 + return ea, fmt.Errorf("@test(err, pos=...): %w", err) 117 + } 118 + ea.pos = specs 119 + ea.posSet = true 120 + } 121 + } 122 + return ea, nil 123 + } 124 + 125 + // parseParenList parses a balanced parenthesized pipe-separated list like 126 + // (path1|path2|path3), returning ["path1","path2","path3"]. 127 + // The input may or may not include the outer parentheses. 128 + func parseParenList(s string) ([]string, error) { 129 + s = strings.TrimSpace(s) 130 + if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { 131 + s = s[1 : len(s)-1] 132 + } 133 + if s == "" { 134 + return nil, nil 135 + } 136 + parts := strings.Split(s, "|") 137 + for i, p := range parts { 138 + parts[i] = strings.TrimSpace(p) 139 + } 140 + return parts, nil 141 + } 142 + 143 + // parsePosSpecs parses a pos= value into a slice of posSpec. 144 + // The value must be enclosed in square brackets; elements are whitespace-separated. 145 + // Two element forms are supported: 146 + // 147 + // - deltaLine:col — relative position on the same file (one colon). 148 + // deltaLine is a signed offset from the @test attribute's line (0 = same line). 149 + // - filename:absLine:col — absolute position in another file (two colons). 150 + // absLine is the 1-indexed line in the named file. 151 + func parsePosSpecs(s string) ([]posSpec, error) { 152 + s = strings.TrimSpace(s) 153 + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { 154 + return nil, fmt.Errorf("pos= value must be enclosed in square brackets, got %q", s) 155 + } 156 + s = s[1 : len(s)-1] 157 + var specs []posSpec 158 + for _, p := range strings.Fields(s) { 159 + parts := strings.SplitN(p, ":", 3) 160 + switch len(parts) { 161 + case 2: 162 + // Relative form: deltaLine:col 163 + deltaLine, err := strconv.Atoi(parts[0]) 164 + if err != nil { 165 + return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 166 + } 167 + colNum, err := strconv.Atoi(parts[1]) 168 + if err != nil { 169 + return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 170 + } 171 + specs = append(specs, posSpec{deltaLine: deltaLine, col: colNum}) 172 + case 3: 173 + // Absolute form: filename:absLine:col 174 + fileName := parts[0] 175 + absLine, err := strconv.Atoi(parts[1]) 176 + if err != nil { 177 + return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 178 + } 179 + colNum, err := strconv.Atoi(parts[2]) 180 + if err != nil { 181 + return nil, fmt.Errorf("invalid pos spec %q: %w", p, err) 182 + } 183 + specs = append(specs, posSpec{fileName: fileName, absLine: absLine, col: colNum}) 184 + default: 185 + return nil, fmt.Errorf("invalid pos spec %q: expected deltaLine:col or filename:line:col", p) 186 + } 187 + } 188 + return specs, nil 189 + } 190 + 191 + // runErrAssertion checks that an error is present at val, applying sub-options. 192 + func (r *inlineRunner) runErrAssertion(t testing.TB, path cue.Path, val cue.Value, pa parsedTestAttr) { 193 + t.Helper() 194 + ea := pa.errArgs 195 + if ea == nil { 196 + // Bare @test(err) — just check that the value is an error. 197 + if !r.isError(val) { 198 + t.Errorf("path %s: expected error, got non-error value", path) 199 + } 200 + return 201 + } 202 + 203 + if ea.any { 204 + // @test(err, any, ...) — check that any descendant has the error. 205 + found := r.findDescendantError(val, ea) 206 + if !found { 207 + t.Errorf("path %s: expected a descendant error with code=%v, none found", path, ea.codes) 208 + } 209 + return 210 + } 211 + 212 + if !r.isError(val) { 213 + t.Errorf("path %s: expected error, got non-error value", path) 214 + return 215 + } 216 + 217 + // Validate error code. 218 + if len(ea.codes) > 0 { 219 + gotCode := r.errorCode(val) 220 + if !ea.matchesCode(gotCode) { 221 + t.Errorf("path %s: expected error code %v, got %q", path, ea.codes, gotCode) 222 + } 223 + } 224 + // Validate error message contains. 225 + if ea.contains != "" { 226 + msg := r.errorMessage(val) 227 + if !strings.Contains(msg, ea.contains) { 228 + t.Errorf("path %s: expected error message to contain %q, got %q", path, ea.contains, msg) 229 + } 230 + } 231 + // Validate error positions. 232 + if ea.posSet { 233 + r.checkErrPositions(t, path, val, pa) 234 + } 235 + } 236 + 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) { 242 + t.Helper() 243 + err := val.Err() 244 + if err == nil { 245 + t.Errorf("path %s: @test(err, pos=...): value has no error", path) 246 + return 247 + } 248 + positions := cueerrors.Positions(err) 249 + expected := pa.errArgs.pos 250 + 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 268 + } 269 + } 270 + } 271 + } 272 + if match { 273 + return 274 + } 275 + 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 282 + } 283 + 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)) 286 + for i, p := range positions { 287 + t.Logf(" actual[%d]: %d:%d", i, p.Line(), p.Column()) 288 + } 289 + return 290 + } 291 + for i, exp := range expected { 292 + got := positions[i] 293 + if exp.fileName != "" { 294 + gotFile := r.relFilename(got.Filename()) 295 + 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) 298 + } 299 + } else { 300 + wantLine := pa.baseLine + exp.deltaLine 301 + 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) 304 + } 305 + } 306 + } 307 + } 308 + 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=[...] 315 + } 316 + 317 + // enqueuePosWrite formats positions as pos specs and enqueues a write-back 318 + // that replaces the pos=[...] value in the source attribute. 319 + // 320 + // Positions in the same file as the @test attribute are written as deltaLine:col 321 + // (relative to pa.baseLine). Positions in a different file are written as 322 + // filename:absLine:col (absolute). 323 + func (r *inlineRunner) enqueuePosWrite(pa parsedTestAttr, positions []token.Pos) { 324 + parts := make([]string, len(positions)) 325 + 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 + } 331 + } 332 + newPosStr := strings.Join(parts, " ") 333 + 334 + old := pa.srcAttr.Text 335 + start := strings.Index(old, "pos=[") 336 + if start < 0 { 337 + return 338 + } 339 + bracket := start + len("pos=[") 340 + end := strings.Index(old[bracket:], "]") 341 + if end < 0 { 342 + return 343 + } 344 + end += bracket + 1 // include the "]" 345 + newAttrText := old[:start] + "pos=[" + newPosStr + "]" + old[end:] 346 + 347 + r.pendingPosWrites = append(r.pendingPosWrites, posWrite{ 348 + fileName: pa.srcFileName, 349 + attrOffset: pa.srcAttr.Pos().Offset(), 350 + attrLen: len(pa.srcAttr.Text), 351 + newAttrText: newAttrText, 352 + }) 353 + } 354 + 355 + // applyPosWritebacks writes pending pos= attribute updates to the archive file. 356 + // Replacements are applied by byte offset from end to start so that earlier 357 + // offsets remain valid after each substitution. 358 + func (r *inlineRunner) applyPosWritebacks() { 359 + if len(r.pendingPosWrites) == 0 || r.filePath == "" { 360 + return 361 + } 362 + changed := false 363 + for i, f := range r.archive.Files { 364 + var writes []posWrite 365 + for _, pw := range r.pendingPosWrites { 366 + if pw.fileName == f.Name { 367 + writes = append(writes, pw) 368 + } 369 + } 370 + if len(writes) == 0 { 371 + continue 372 + } 373 + // Sort descending by offset so earlier offsets stay valid. 374 + slices.SortFunc(writes, func(a, b posWrite) int { 375 + return b.attrOffset - a.attrOffset 376 + }) 377 + data := append([]byte(nil), f.Data...) 378 + for _, pw := range writes { 379 + end := pw.attrOffset + pw.attrLen 380 + if end > len(data) { 381 + continue 382 + } 383 + data = append(data[:pw.attrOffset:pw.attrOffset], 384 + append([]byte(pw.newAttrText), data[end:]...)...) 385 + changed = true 386 + } 387 + r.archive.Files[i].Data = data 388 + } 389 + if changed { 390 + out := txtar.Format(r.archive) 391 + if err := os.WriteFile(r.filePath, out, 0o644); err != nil { 392 + r.t.Errorf("inline: pos write-back to %s: %v", r.filePath, err) 393 + } 394 + } 395 + } 396 + 397 + // isError reports whether val is an error value (bottom). 398 + func (r *inlineRunner) isError(val cue.Value) bool { 399 + core := val.Core() 400 + if core.V == nil { 401 + return false 402 + } 403 + return core.V.Bottom() != nil 404 + } 405 + 406 + // errorCode returns the string code of the error at val, or "" if not an error. 407 + func (r *inlineRunner) errorCode(val cue.Value) string { 408 + core := val.Core() 409 + if core.V == nil { 410 + return "" 411 + } 412 + b := core.V.Bottom() 413 + if b == nil { 414 + return "" 415 + } 416 + return b.Code.String() 417 + } 418 + 419 + // errorMessage returns the human-readable error message for val. 420 + func (r *inlineRunner) errorMessage(val cue.Value) string { 421 + if err := val.Err(); err != nil { 422 + return err.Error() 423 + } 424 + return "" 425 + } 426 + 427 + // findDescendantError walks val looking for any descendant with an error 428 + // matching ea. Returns true if found. 429 + func (r *inlineRunner) findDescendantError(val cue.Value, ea *errArgs) bool { 430 + if r.isError(val) { 431 + if ea.matchesCode(r.errorCode(val)) { 432 + return true 433 + } 434 + } 435 + // Walk fields. 436 + iter, err := val.Fields() 437 + if err != nil { 438 + return false 439 + } 440 + for iter.Next() { 441 + if r.findDescendantError(iter.Value(), ea) { 442 + return true 443 + } 444 + } 445 + return false 446 + }