this repo has no description
0
fork

Configure Feed

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

internal/cuetxtar: add at=, pos= order-independence, and hidden field guards

Add at=<path> directive to navigate from the annotated field's value to a
sub-path before checking the error. Add order-independent pos=[...] matching
so position specs can appear in any order and commas are optional. Reject
any+ pos= combinations that would be ambiguous. Guard against hidden field
labels (identifiers starting with _) in walkStruct and the top-level file
loop: skip walkField when cue.Label or cue.ParsePath returns an error path.
Add clear error reporting in runErrAssertion when at= contains an invalid
or hidden path. Update the inline-test-attributes spec and add tests.

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

+237 -41
+17 -4
doc/specs/inline-test-attributes/spec.md
··· 224 224 - `code=<code>` — the error code must match. Valid codes include `cycle`, `eval`, `incomplete`, `structural`, `reference`, and any other code defined in `cuelang.org/go/cue/errors`. 225 225 - `contains="substring"` — the error message must contain the given substring. 226 226 - `any` (bare flag) — at least one **descendant** of the annotated field must have an error. The annotated field itself is not required to be an error. `code` MUST be specified when `any` is used. 227 + - `at=<path>` — navigate to the given CUE path (relative to the annotated field) before checking the error. Useful when the erroneous sub-field cannot be directly annotated (e.g. it is inside a comprehension or a pattern constraint). `pos=` is not compatible with `any`; `at=` and `any` may not be combined. 227 228 - `args=[v1, v2, ...]` — the values returned by the error's `Msg()` method must include all listed strings (matched via `fmt.Sprint`, order-independent, subset check). See `Requirement: err directive — args= sub-option` for details. 228 229 - `suberr=(...)` — matches one sub-error of a multi-error value (e.g. a failed disjunction). Multiple `suberr=` entries match sub-errors order-independently. The body accepts the same sub-options as `@test(err, ...)`. See `Requirement: err directive — suberr=(...)` for details. 229 - - `pos=[spec ...]` — asserts the exact set of source positions reported by the error (as returned by `cuelang.org/go/cue/errors.Positions`). Each whitespace-delimited spec takes one of two forms: 230 + - `pos=[spec ...]` — asserts the exact set of source positions reported by the error (as returned by `cuelang.org/go/cue/errors.Positions`). Matching is **order-independent**: the order of specs need not match the order of actual positions. Commas between specs are optional. Each spec takes one of two forms: 230 231 - `deltaLine:col` — position in the **same file** as the `@test` attribute, expressed as a signed line offset from an *anchor line* and a 1-indexed column. The anchor depends on where the `@test` attribute appears: 231 232 - **Field attribute** (`field: value @test(...)`): anchor is the field's line in the stripped output (`deltaLine=0` = same line as the field). 232 233 - **Struct-level decl attribute** (inside `{ @test(...) ... }`): anchor is the line of the opening `{` of the enclosing struct (`deltaLine=1` = first line inside the struct body). This keeps the assertion stable when the `@test` line itself is stripped, and gives the natural reading "N lines into the struct". ··· 241 242 #### Scenario: Error in a descendant via any — field attribute form 242 243 - **WHEN** a field carries `@test(err, any, code=cycle)` as a *field attribute* and at least one descendant of that field has a cycle error 243 244 - **THEN** the test passes 245 + 246 + #### Scenario: at= navigates to nested error 247 + - **WHEN** `outer: { inner: bad: string & int } @test(err, at=inner.bad, code=eval)` is declared 248 + - **THEN** the runner navigates to `outer.inner.bad`, finds an eval error there, and the test passes 249 + 250 + #### Scenario: at= sub-path not found fails 251 + - **WHEN** `x: {a: 1} @test(err, at=a.nonexistent)` is declared 252 + - **THEN** the test fails reporting that the sub-path was not found 244 253 245 254 #### Scenario: Error code mismatch 246 255 - **WHEN** a field carries `@test(err, code=cycle)` and the error code is `incomplete` ··· 251 260 - **THEN** the test fails 252 261 253 262 #### Scenario: pos= matches error positions on field attribute 254 - - **WHEN** `@test(err, pos=[0:5 0:14])` is a **field attribute** and the error reports exactly two positions on the same line as the field (column 5 and column 14) 255 - - **THEN** the test passes 263 + - **WHEN** `@test(err, pos=[0:5, 0:14])` is a **field attribute** and the error reports exactly two positions on the same line as the field (column 5 and column 14) 264 + - **THEN** the test passes (commas between specs are optional; order of specs need not match order of actual positions) 256 265 257 266 #### Scenario: pos= matches error positions on struct decl attribute 258 - - **WHEN** `d: { @test(err, pos=[1:5 2:2]) ... }` is declared and the error reports positions at the 1st and 2nd lines inside `d`'s `{` at the given columns 267 + - **WHEN** `d: { @test(err, pos=[1:5, 2:2]) ... }` is declared and the error reports positions at the 1st and 2nd lines inside `d`'s `{` at the given columns 259 268 - **THEN** the test passes (`deltaLine=1` means 1 line after `{`, i.e. the first field) 269 + 270 + #### Scenario: pos= is order-independent 271 + - **WHEN** `@test(err, pos=[0:14, 0:5])` is declared and the error reports positions at columns 5 and 14 (in that order) 272 + - **THEN** the test passes regardless of the order specs are listed 260 273 261 274 #### Scenario: pos= empty placeholder fills on CUE_UPDATE 262 275 - **WHEN** `@test(err, pos=[])` is declared and `CUE_UPDATE=1` is set
+6 -2
internal/cuetxtar/inline.go
··· 362 362 363 363 case *ast.Field: 364 364 subPath := appendPath(path, e.Label) 365 - walkField(e, subPath) 365 + if subPath.Err() == nil { 366 + walkField(e, subPath) 367 + } 366 368 newElts = append(newElts, elt) 367 369 368 370 default: ··· 405 407 continue 406 408 } 407 409 fieldPath := cue.MakePath(cue.Label(field.Label)) 408 - walkField(field, fieldPath) 410 + if fieldPath.Err() == nil { 411 + walkField(field, fieldPath) 412 + } 409 413 } 410 414 411 415 return records
+110 -35
internal/cuetxtar/inline_err.go
··· 68 68 contains string 69 69 // any requires any descendant of the annotated field to have the error. 70 70 any bool 71 - // paths lists specific paths (relative to test-case root) where the error 72 - // must occur. Populated when path=(...) is present. 73 - paths []string 71 + // at is a relative CUE path string (e.g. "a.b") to navigate to from the 72 + // annotated field before checking the error. Allows asserting errors in 73 + // sub-fields that cannot be directly annotated. 74 + at string 74 75 // pos lists expected error positions as (deltaLine:col) pairs relative to 75 76 // the line containing the @test attribute. 76 77 pos []posSpec ··· 125 126 ea.contains = kv.Value() 126 127 case kv.Key() == "" && kv.Value() == "any": 127 128 ea.any = true 128 - case kv.Key() == "path": 129 - paths, err := parseParenList(kv.Value()) 130 - if err != nil { 131 - return ea, fmt.Errorf("@test(err, path=...): %w", err) 132 - } 133 - ea.paths = paths 129 + case kv.Key() == "at": 130 + ea.at = kv.Value() 134 131 case kv.Key() == "pos": 135 132 specs, err := parsePosSpecs(kv.Value()) 136 133 if err != nil { ··· 207 204 s = inner 208 205 var specs []posSpec 209 206 for _, p := range strings.Fields(s) { 207 + // TODO: make these required. 208 + p = strings.TrimRight(p, ",") // commas are optional separators 209 + if p == "" { 210 + continue 211 + } 210 212 parts := strings.SplitN(p, ":", 3) 211 213 switch len(parts) { 212 214 case 2: ··· 292 294 return 293 295 } 294 296 297 + if ea.at != "" { 298 + // @test(err, at=<path>, ...) — navigate to sub-path then check error. 299 + subPath := cue.ParsePath(ea.at) 300 + if err := subPath.Err(); err != nil { 301 + t.Errorf("path %s: @test(err, at=%s): invalid path: %v", path, ea.at, err) 302 + return 303 + } 304 + subVal := val.LookupPath(subPath) 305 + if !subVal.Exists() { 306 + t.Errorf("path %s: @test(err, at=%s): sub-path not found", path, ea.at) 307 + return 308 + } 309 + subFullPath := cue.MakePath(append(path.Selectors(), subPath.Selectors()...)...) 310 + subPA := pa 311 + subPA.errArgs = &errArgs{ 312 + codes: ea.codes, 313 + contains: ea.contains, 314 + any: false, 315 + posSet: ea.posSet, 316 + pos: ea.pos, 317 + suberrs: ea.suberrs, 318 + msgArgs: ea.msgArgs, 319 + } 320 + r.runErrAssertion(t, subFullPath, subVal, subPA) 321 + return 322 + } 323 + 295 324 if ea.any { 296 325 // @test(err, any, ...) — check that any descendant has the error. 326 + if ea.posSet { 327 + t.Errorf("path %s: @test(err, any, pos=...): pos= is not supported with any", path) 328 + return 329 + } 297 330 found := r.findDescendantError(val, ea) 298 331 if !found { 299 332 t.Errorf("path %s: expected a descendant error with code=%v, none found", path, ea.codes) ··· 609 642 return slices.Compact(a) 610 643 } 611 644 612 - // posSpecsMatch reports whether positions match specs exactly. 645 + // posSpecsMatch reports whether positions match specs in any order. 613 646 // baseLine is the line number of the @test attribute; used to resolve relative specs. 614 647 // The runner's relFilename method is needed for absolute specs — pass it as a func. 615 648 func posSpecsMatch(positions []token.Pos, specs []posSpec, baseLine int, relFilename func(string) string) bool { 616 649 if len(positions) != len(specs) { 617 650 return false 618 651 } 619 - for i, exp := range specs { 620 - got := positions[i] 621 - if exp.fileName != "" { 622 - if relFilename(got.Filename()) != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 623 - return false 652 + used := make([]bool, len(positions)) 653 + for _, exp := range specs { 654 + matched := false 655 + for i, got := range positions { 656 + if used[i] { 657 + continue 624 658 } 625 - } else { 626 - if got.Line() != baseLine+exp.deltaLine || got.Column() != exp.col { 627 - return false 659 + if posMatchesSpec(got, exp, baseLine, relFilename) { 660 + used[i] = true 661 + matched = true 662 + break 628 663 } 629 664 } 665 + if !matched { 666 + return false 667 + } 630 668 } 631 669 return true 632 670 } 633 671 672 + // posMatchesSpec reports whether a single token.Pos satisfies a posSpec. 673 + func posMatchesSpec(got token.Pos, exp posSpec, baseLine int, relFilename func(string) string) bool { 674 + if exp.fileName != "" { 675 + return relFilename(got.Filename()) == exp.fileName && got.Line() == exp.absLine && got.Column() == exp.col 676 + } 677 + return got.Line() == baseLine+exp.deltaLine && got.Column() == exp.col 678 + } 679 + 634 680 // reportPosMismatch reports a position mismatch between actual positions and 635 681 // expected specs. directive is included verbatim in each error message. 636 682 // If counts differ, only the count error is reported. Otherwise each 637 - // mismatched position is reported individually. 683 + // unmatched expected spec is reported individually (order-independent). 638 684 func (r *inlineRunner) reportPosMismatch(t testing.TB, path cue.Path, directive string, positions []token.Pos, specs []posSpec, baseLine int) { 639 685 t.Helper() 640 686 if len(positions) != len(specs) { 641 687 t.Errorf("path %s: %s: got %d position(s), want %d", path, directive, len(positions), len(specs)) 642 - for i, p := range positions { 643 - t.Logf(" actual[%d]: %d:%d", i, p.Line(), p.Column()) 688 + for _, p := range positions { 689 + t.Logf(" actual: %d:%d", p.Line(), p.Column()) 644 690 } 645 691 return 646 692 } 647 - for i, exp := range specs { 648 - got := positions[i] 649 - if exp.fileName != "" { 650 - gotFile := r.relFilename(got.Filename()) 651 - if gotFile != exp.fileName || got.Line() != exp.absLine || got.Column() != exp.col { 652 - t.Errorf("path %s: %s: position[%d]: got %s:%d:%d, want %s:%d:%d", 653 - path, directive, i, gotFile, got.Line(), got.Column(), exp.fileName, exp.absLine, exp.col) 693 + used := make([]bool, len(positions)) 694 + for _, exp := range specs { 695 + matched := false 696 + for i, got := range positions { 697 + if used[i] { 698 + continue 699 + } 700 + if posMatchesSpec(got, exp, baseLine, r.relFilename) { 701 + used[i] = true 702 + matched = true 703 + break 704 + } 705 + } 706 + if !matched { 707 + if exp.fileName != "" { 708 + t.Errorf("path %s: %s: unmatched position %s:%d:%d; actual positions:", path, directive, exp.fileName, exp.absLine, exp.col) 709 + } else { 710 + t.Errorf("path %s: %s: unmatched position %d:%d; actual positions:", path, directive, exp.deltaLine, exp.col) 654 711 } 655 - } else { 656 - wantLine := baseLine + exp.deltaLine 657 - if got.Line() != wantLine || got.Column() != exp.col { 658 - t.Errorf("path %s: %s: position[%d]: got %d:%d, want %d:%d", 659 - path, directive, i, got.Line()-baseLine, got.Column(), exp.deltaLine, exp.col) 712 + for _, p := range positions { 713 + t.Logf(" actual: %d:%d", p.Line(), p.Column()) 660 714 } 661 715 } 662 716 } ··· 798 852 return "" 799 853 } 800 854 855 + // msgArgsMatch reports whether err's Msg() args include all strings in expected 856 + // (matched via fmt.Sprint, order-independent). Returns true when expected is empty. 857 + func msgArgsMatch(err error, expected []string) bool { 858 + if len(expected) == 0 { 859 + return true 860 + } 861 + var e cueerrors.Error 862 + if !errors.As(err, &e) { 863 + return false 864 + } 865 + _, actualArgs := e.Msg() 866 + for _, exp := range expected { 867 + if !slices.ContainsFunc(actualArgs, func(a any) bool { return fmt.Sprint(a) == exp }) { 868 + return false 869 + } 870 + } 871 + return true 872 + } 873 + 801 874 // checkMsgArgs checks that the Msg() args of e include all strings in expected 802 875 // (matched via fmt.Sprint, order-independent). directive is used in error messages. 803 876 func checkMsgArgs(t testing.TB, path cue.Path, e cueerrors.Error, expected []string, directive string) { ··· 815 888 } 816 889 817 890 // findDescendantError walks val looking for any descendant with an error 818 - // matching ea. Returns true if found. 891 + // matching ea (code=, contains=, args=). Returns true if found. 819 892 func (r *inlineRunner) findDescendantError(val cue.Value, ea *errArgs) bool { 820 893 if r.isError(val) { 821 - if ea.matchesCode(r.errorCode(val)) { 894 + if ea.matchesCode(r.errorCode(val)) && 895 + (ea.contains == "" || strings.Contains(r.errorMessage(val), ea.contains)) && 896 + msgArgsMatch(val.Err(), ea.msgArgs) { 822 897 return true 823 898 } 824 899 }
+104
internal/cuetxtar/inline_test.go
··· 175 175 input: "[0:x]", 176 176 wantErr: true, 177 177 }, 178 + { 179 + name: "comma-separated (commas ignored)", 180 + input: "[0:5, 1:13, -2:3]", 181 + want: []posSpec{ 182 + {deltaLine: 0, col: 5}, 183 + {deltaLine: 1, col: 13}, 184 + {deltaLine: -2, col: 3}, 185 + }, 186 + }, 187 + { 188 + name: "trailing comma only", 189 + input: "[0:5,]", 190 + want: []posSpec{{deltaLine: 0, col: 5}}, 191 + }, 178 192 } 179 193 for _, tt := range tests { 180 194 t.Run(tt.name, func(t *testing.T) { ··· 506 520 } 507 521 }) 508 522 } 523 + 524 + // TestAtDirective verifies that @test(err, at=<path>, ...) navigates to a 525 + // sub-path before checking the error. 526 + func TestAtDirective(t *testing.T) { 527 + t.Run("at= navigates to sub-field error", func(t *testing.T) { 528 + ctx := cuecontext.New() 529 + parent := ctx.CompileString("a: {b: int & string}") 530 + if parent.LookupPath(cue.MakePath(cue.Str("a"), cue.Str("b"))).Err() == nil { 531 + t.Fatal("expected a.b to be an error") 532 + } 533 + r := &inlineRunner{} 534 + rec := &failCapture{TB: t} 535 + pa := parsedTestAttr{directive: "err", errArgs: &errArgs{at: "a.b"}} 536 + r.runErrAssertion(rec, cue.MakePath(cue.Str("x")), parent, pa) 537 + if rec.failed { 538 + t.Errorf("unexpected failure: %s", rec.msgs.String()) 539 + } 540 + }) 541 + t.Run("at= missing sub-path fails", func(t *testing.T) { 542 + ctx := cuecontext.New() 543 + val := ctx.CompileString("a: 1") 544 + r := &inlineRunner{} 545 + rec := &failCapture{TB: t} 546 + pa := parsedTestAttr{directive: "err", errArgs: &errArgs{at: "a.nonexistent"}} 547 + r.runErrAssertion(rec, cue.MakePath(cue.Str("x")), val, pa) 548 + if !rec.failed { 549 + t.Errorf("expected failure for missing sub-path") 550 + } 551 + }) 552 + t.Run("at= sub-path not an error fails", func(t *testing.T) { 553 + ctx := cuecontext.New() 554 + val := ctx.CompileString("a: {b: 42}") 555 + r := &inlineRunner{} 556 + rec := &failCapture{TB: t} 557 + pa := parsedTestAttr{directive: "err", errArgs: &errArgs{at: "a.b"}} 558 + r.runErrAssertion(rec, cue.MakePath(cue.Str("x")), val, pa) 559 + if !rec.failed { 560 + t.Errorf("expected failure when sub-path is not an error") 561 + } 562 + }) 563 + } 564 + 565 + // makeTestPos creates a token.Pos at the given 1-indexed line and column in 566 + // a fresh file with the given name. Each line is allocated lineWidth bytes. 567 + func makeTestPos(filename string, line, col int) token.Pos { 568 + const lineWidth = 100 569 + size := line*lineWidth + col 570 + f := token.NewFile(filename, 0, size) 571 + for i := 1; i < line; i++ { 572 + f.AddLine(i * lineWidth) 573 + } 574 + return f.Pos((line-1)*lineWidth+(col-1), token.Blank) 575 + } 576 + 577 + // TestPosSpecsMatch verifies that posSpecsMatch is order-independent. 578 + func TestPosSpecsMatch(t *testing.T) { 579 + identity := func(s string) string { return s } 580 + // Two positions in "in.cue": line 5 col 3 and line 7 col 1. 581 + pos5_3 := makeTestPos("in.cue", 5, 3) 582 + pos7_1 := makeTestPos("in.cue", 7, 1) 583 + // Absolute specs for the same positions (baseLine is irrelevant for absolute). 584 + spec5_3 := posSpec{fileName: "in.cue", absLine: 5, col: 3} 585 + spec7_1 := posSpec{fileName: "in.cue", absLine: 7, col: 1} 586 + t.Run("same order matches", func(t *testing.T) { 587 + if !posSpecsMatch([]token.Pos{pos5_3, pos7_1}, []posSpec{spec5_3, spec7_1}, 0, identity) { 588 + t.Error("expected match in same order") 589 + } 590 + }) 591 + t.Run("reversed order matches", func(t *testing.T) { 592 + if !posSpecsMatch([]token.Pos{pos5_3, pos7_1}, []posSpec{spec7_1, spec5_3}, 0, identity) { 593 + t.Error("expected match with reversed spec order") 594 + } 595 + }) 596 + t.Run("wrong position does not match", func(t *testing.T) { 597 + pos9_2 := makeTestPos("in.cue", 9, 2) 598 + if posSpecsMatch([]token.Pos{pos5_3, pos9_2}, []posSpec{spec5_3, spec7_1}, 0, identity) { 599 + t.Error("expected no match for wrong position") 600 + } 601 + }) 602 + t.Run("count mismatch does not match", func(t *testing.T) { 603 + if posSpecsMatch([]token.Pos{pos5_3}, []posSpec{spec5_3, spec7_1}, 0, identity) { 604 + t.Error("expected no match for count mismatch") 605 + } 606 + }) 607 + t.Run("empty positions and specs match", func(t *testing.T) { 608 + if !posSpecsMatch(nil, nil, 0, identity) { 609 + t.Error("expected empty slices to match") 610 + } 611 + }) 612 + }