brink-syntax-native 0.0.15

Lexer and error-resilient CST for the .brink native surface (B0.5 grammar skeleton)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
//! Prose block elements — scene headings + `[slug]`, block cues, compact
//! cues, parentheticals, header-scoped stitch bodies, and per-flow header
//! tags (#1715; `docs/prose-dialect-spec.md` §8b/§8d).

use super::*;

fn first_node(root: &SyntaxNode, kind: SyntaxKind) -> SyntaxNode {
    // `assert!` + `expect`, not `unwrap_or_else(|| panic!(…))`: this
    // crate's `clippy.toml` exempts `unwrap`/`expect` in tests but
    // `clippy::panic` stays denied everywhere.
    let found = root.descendants().find(|n| n.kind() == kind);
    assert!(found.is_some(), "no {kind:?} node in tree");
    found.expect("asserted present just above")
}

fn tag_texts(node: &SyntaxNode) -> Vec<String> {
    node.children()
        .filter(|n| n.kind() == SyntaxKind::TAG)
        .map(|n| n.text().to_string().trim().to_owned())
        .collect()
}

// ── Scene headings (§8b.3) ───────────────────────────────────────────

#[test]
fn scene_heading_with_slug_and_tags_parses_in_the_ruled_line_order() {
    // The ruled spelling, verbatim from §8b.3: pattern, `[slug]`, tags.
    let src = "INT. MARKET SQUARE - NIGHT [market] #tense #act1\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let heading = first_node(&p.syntax(), SyntaxKind::SCENE_HEADING);
    let heading = ast::SceneHeading::cast(heading).expect("SCENE_HEADING casts");
    assert_eq!(
        heading.title().expect("title").text(),
        "INT. MARKET SQUARE - NIGHT"
    );
    assert_eq!(
        heading
            .slug()
            .expect("slug")
            .name_token()
            .expect("slug ident")
            .text(),
        "market"
    );
    assert_eq!(
        heading
            .tags()
            .map(|t| t.to_string().trim().to_owned())
            .collect::<Vec<_>>(),
        vec!["#tense".to_owned(), "#act1".to_owned()]
    );
}

#[test]
fn ext_prefix_and_a_slugless_heading_are_both_recognized() {
    let src = "EXT. COLD ALLEY - CONTINUOUS\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let heading = ast::SceneHeading::cast(first_node(&p.syntax(), SyntaxKind::SCENE_HEADING))
        .expect("SCENE_HEADING");
    assert_eq!(
        heading.title().expect("title").text(),
        "EXT. COLD ALLEY - CONTINUOUS"
    );
    assert!(
        heading.slug().is_none(),
        "no explicit slug: the address is inferred from the title (spec 3.3)"
    );
}

#[test]
fn a_bracket_in_the_middle_of_a_title_is_not_a_slug() {
    // The slug is recognized only at the tail of the heading; anything
    // else keeps its `[` as ordinary title text.
    let src = "INT. VAULT [b7] - NIGHT\n";
    let p = assert_lossless(src);
    let heading = ast::SceneHeading::cast(first_node(&p.syntax(), SyntaxKind::SCENE_HEADING))
        .expect("SCENE_HEADING");
    assert!(heading.slug().is_none());
    assert_eq!(
        heading.title().expect("title").text(),
        "INT. VAULT [b7] - NIGHT"
    );
}

#[test]
fn a_scene_title_with_an_escaped_hash_does_not_end_the_title_early() {
    // Issue #1738, mirrors `content.rs`'s
    // `a_tag_with_an_escaped_hash_does_not_end_the_tag_early` and
    // `a_cue_name_with_an_escaped_hash_does_not_end_the_name_early`: `#` is
    // one of the four members of the ruled, final inline escape set
    // (§8d.6), but before this fix `scene_title()` gave `\#` zero escape
    // treatment — a bare `HASH` always ended the title, even one
    // immediately preceded by a backslash.
    let src = "INT. MARKET \\#3\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let heading = ast::SceneHeading::cast(first_node(&p.syntax(), SyntaxKind::SCENE_HEADING))
        .expect("SCENE_HEADING");
    assert_eq!(
        heading.title().expect("title").text(),
        "INT. MARKET #3",
        "an escaped `#` must not end the title early, and (issue #2045)
         `SceneTitle::text()` now strips the recognized escape's backslash,
         parity with `markup::escape`'s stripping in ordinary content"
    );
    assert!(
        !has_node_kind(&p.syntax(), SyntaxKind::TAG),
        "the escaped `#` must not be reparsed as a trailing TAG"
    );
}

#[test]
fn a_scene_titles_text_accessor_strips_a_recognized_open_brace_escapes_backslash() {
    // Issue #2045's own scope note: `\{` gets the identical treatment as
    // `\#`, not just the hash case. `scene_title()` never counts braces
    // (unlike `tag()`/`cue_name()`), so there is no depth interaction to
    // guard here — just the same text-accessor stripping.
    let src = "INT. MARKET \\{3\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let heading = ast::SceneHeading::cast(first_node(&p.syntax(), SyntaxKind::SCENE_HEADING))
        .expect("SCENE_HEADING");
    assert_eq!(heading.title().expect("title").text(), "INT. MARKET {3");
}

#[test]
fn int_stays_an_ordinary_identifier_away_from_item_position() {
    // The heading prefix is a declared line shape, not a reserved word:
    // `INT` is still a perfectly good binding name.
    let src = "var INT = 1\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::SCENE_HEADING));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::VAR_DECL));
}

// ── Header-scoped stitch bodies (§8b.2) ──────────────────────────────

#[test]
fn a_heading_scopes_the_lines_below_it_without_braces() {
    let src = "INT. A [a]\nOne.\nTwo.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let stitch = ast::SceneStitch::cast(first_node(&p.syntax(), SyntaxKind::SCENE_STITCH))
        .expect("SCENE_STITCH");
    let body = stitch.body().expect("SCENE_BODY");
    let lines: Vec<_> = body
        .items()
        .filter(|n| n.kind() == SyntaxKind::CONTENT_LINE)
        .collect();
    assert_eq!(lines.len(), 2, "both lines belong to the heading's body");
}

#[test]
fn consecutive_headings_are_flat_siblings_never_nested() {
    // "scenes don't nest - as on a real page" (spec 8b.2).
    let src = "INT. A [a]\nOne.\nEXT. B [b]\nTwo.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let root = p.syntax();
    let stitches: Vec<_> = root
        .children()
        .filter(|n| n.kind() == SyntaxKind::SCENE_STITCH)
        .collect();
    assert_eq!(stitches.len(), 2, "two sibling stitches under SOURCE_FILE");
    for stitch in &stitches {
        let nested = stitch
            .descendants()
            .filter(|n| n.kind() == SyntaxKind::SCENE_STITCH)
            .count();
        assert_eq!(nested, 1, "a scene stitch never contains another");
    }
}

#[test]
fn a_doc_comment_above_the_second_heading_keeps_flat_siblings() {
    // Review finding on #1715: `scene_stitch`'s body loop checked
    // `at_scene_heading` directly, which fails on a leading `///` token
    // (`DOC_COMMENT_OUTER` is not trivia), so the loop never broke and
    // `block::item` recursed into a *nested* stitch instead of the outer
    // dispatcher opening a flat sibling with its doc attached. Same
    // fixture as `consecutive_headings_are_flat_siblings_never_nested`,
    // with a `///` run above the second heading only.
    let src = "INT. A [a]\nOne.\n/// doc\nEXT. B [b]\nTwo.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let root = p.syntax();
    let stitches: Vec<_> = root
        .children()
        .filter(|n| n.kind() == SyntaxKind::SCENE_STITCH)
        .collect();
    assert_eq!(stitches.len(), 2, "two sibling stitches under SOURCE_FILE");
    for stitch in &stitches {
        let nested = stitch
            .descendants()
            .filter(|n| n.kind() == SyntaxKind::SCENE_STITCH)
            .count();
        assert_eq!(nested, 1, "a scene stitch never contains another");
    }

    let second = ast::SceneStitch::cast(stitches[1].clone()).expect("SCENE_STITCH");
    assert!(
        second.doc().is_some(),
        "the `///` run attaches to the second (documented) heading, not the first"
    );
}

#[test]
fn a_heading_body_ends_at_the_enclosing_close() {
    // The other delimiter the ruling names: the enclosing close. The
    // `after` line must land back in the flow's own BLOCK, not the scene.
    let src = "flow f() {\n  INT. A [a]\n  Inside.\n}\nAfter.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let stitch = first_node(&p.syntax(), SyntaxKind::SCENE_STITCH);
    assert!(
        !stitch.text().to_string().contains("After."),
        "the scene body stops at the enclosing `}}`: {}",
        stitch.text()
    );
    let block = first_node(&p.syntax(), SyntaxKind::BLOCK);
    assert!(block.text().to_string().contains("Inside."));
}

#[test]
fn deeper_nesting_uses_the_general_flow_spelling_inside_a_scene_body() {
    // "deeper nesting uses the general `flow x { }` spelling, which
    // stays first-class in prose-ground" (spec 8b.2).
    let src = "INT. A [a]\nOne.\nflow inner() {\n  Two.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let stitch = first_node(&p.syntax(), SyntaxKind::SCENE_STITCH);
    assert!(
        stitch
            .descendants()
            .any(|n| n.kind() == SyntaxKind::FLOW_DECL),
        "the nested flow belongs to the scene's header-scoped body"
    );
}

#[test]
fn a_doc_comment_above_a_heading_attaches_to_the_stitch() {
    let src = "/// The market, after curfew.\nINT. A [a]\nOne.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let stitch = ast::SceneStitch::cast(first_node(&p.syntax(), SyntaxKind::SCENE_STITCH))
        .expect("SCENE_STITCH");
    assert!(
        stitch.doc().is_some(),
        "the `///` run wraps as the stitch's doc"
    );
}

// ── Cues: block and compact (§8b.9, §8d.4) ───────────────────────────

#[test]
fn a_block_cue_carries_its_extension_on_the_tag_channel() {
    // "Cue extensions ride the tag channel" (spec 8d.4) - no parsed `ext`
    // capture, no new payload machinery.
    let src = "@VENDOR #(v.o.)\nYou shouldn't be here.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let cue = first_node(&p.syntax(), SyntaxKind::CUE);
    assert_eq!(tag_texts(&cue), vec!["#(v.o.)".to_owned()]);
    let cue = ast::Cue::cast(cue).expect("CUE");
    assert_eq!(cue.name().expect("name").text(), "VENDOR");
}

#[test]
fn a_multi_word_cue_name_is_one_name() {
    let src = "@MARKET VENDOR\nHello.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let cue = ast::Cue::cast(first_node(&p.syntax(), SyntaxKind::CUE)).expect("CUE");
    assert_eq!(cue.name().expect("name").text(), "MARKET VENDOR");
}

#[test]
fn a_balanced_brace_in_a_cue_name_does_not_swallow_the_enclosing_blocks_own_closer() {
    // #1786: `cue_name()` shared `content::tag()`'s pre-#1728 shape — an
    // unconditional stop at the first raw `R_BRACE`, regardless of an
    // earlier unpaired `{` in the same scan. Confirmed reachable exactly
    // like the `tag()` case: without the fix, this source's `}` (closing
    // the balanced `{gold}`) was mistaken for the flow's own closer, so
    // the flow's `BLOCK` ended right there, "coins." fell out to the top
    // level, and the flow's real closing `}` became a stray top-level
    // token — a parse error. Depth-tracking the same way `tag()` does
    // fixes it.
    let src = "flow f() {\n  @NAME {gold} coins.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let decl =
        ast::FlowDecl::cast(first_node(&p.syntax(), SyntaxKind::FLOW_DECL)).expect("FLOW_DECL");
    assert!(
        decl.body().is_some(),
        "the cue name's balanced brace must not swallow the flow's own body closer"
    );
    let cue = ast::Cue::cast(first_node(&p.syntax(), SyntaxKind::CUE)).expect("CUE");
    assert_eq!(cue.name().expect("name").text(), "NAME {gold} coins.");
}

#[test]
fn an_unbalanced_open_brace_in_a_cue_name_eats_the_enclosing_blocks_own_closer() {
    // The accepted tradeoff, pinned the same way #1728 pinned it for
    // `tag()` (`content.rs`'s
    // `an_unbalanced_open_brace_in_a_tag_eats_the_enclosing_blocks_own_closer`):
    // a raw, unescaped `{` left open inside a cue name is depth-balanced
    // the same as a matched one — the scan can't tell "unbalanced" from
    // "matches the closer" without a real grammar. So this fails to
    // parse: the name's `{` is counted, the very next `}` is consumed as
    // its match instead of stopping the name, and the flow body's own
    // closer is gone by the time EOF is reached.
    let src = "flow f() { @NAME { }\n";
    let p = assert_lossless(src);
    assert!(
        !p.errors().is_empty(),
        "expected the unbalanced `{{` to consume the flow's own closer and error, got: {:?}",
        p.errors()
    );
}

#[test]
fn an_unbalanced_open_brace_in_a_cue_name_with_a_colon_inside_it_eats_the_enclosing_blocks_own_closer()
 {
    // #1851 widens the same accepted tradeoff pinned just above by
    // `an_unbalanced_open_brace_in_a_cue_name_eats_the_enclosing_blocks_own_closer`
    // from R_BRACE to COLON: once COLON is depth-guarded like R_BRACE, a
    // genuinely unbalanced, unescaped `{` left open in a name swallows not
    // just a later `}` but a later `:` too, along with everything after it
    // up to EOF, since nothing at depth > 0 can stop the scan anymore. So
    // this — balanced-looking at a glance, but the name's own `{` is never
    // closed — fails to parse: the R_BRACE that would close the block is
    // consumed as part of the still-open name scan, and the scan runs off
    // the end of input looking for a match.
    let src = "flow f() { @NAME {x: y }\n";
    let p = assert_lossless(src);
    assert!(
        !p.errors().is_empty(),
        "expected the unbalanced `{{` to swallow the `:` and the flow's own closer, got: {:?}",
        p.errors()
    );
}

#[test]
fn a_cue_name_with_an_escaped_open_brace_does_not_swallow_the_enclosing_blocks_own_closer() {
    // `\{` is the literal-brace escape (#1716/PR #1732), not a
    // metacharacter, so it must not count as a depth-opener — otherwise
    // the escaped brace would swallow the enclosing flow's own same-line
    // closer exactly like the unbalanced-raw-brace case above.
    let src = "flow f() { @NAME \\{ }\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert_eq!(count_node_kind(&p.syntax(), SyntaxKind::FLOW_DECL), 1);
}

#[test]
fn a_cue_name_with_an_escaped_backslash_before_a_real_brace_counts_the_brace() {
    // #1852: `\\{` is an escaped backslash (producing literal `\`), followed
    // by a real interpolation-opening brace. The carve-out that excludes
    // `\{` from the depth counter must not fire for `\\{`, because the
    // backslash is itself escaped. Without the fix, the brace is not counted,
    // so the matching `}` ends the cue name prematurely, and the text after
    // it becomes stray content — a parse error.
    let src = "flow f() { @NAME \\\\{ } coins. }\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert_eq!(count_node_kind(&p.syntax(), SyntaxKind::FLOW_DECL), 1);
}

#[test]
fn a_cue_name_with_a_colon_inside_braces_does_not_terminate_the_name() {
    // #1851: `cue_name()` has a COLON stop that mis-parses `@NAME {a:b}`
    // — the colon inside the braces should not terminate the cue name
    // because the braces create an interpolation context. The depth counter
    // should guard the COLON check the same way it guards the R_BRACE check.
    // Without the fix, the cue name ends at the colon, and the text after
    // it becomes stray content — a parse error.
    let src = "flow f() { @NAME {a:b} }\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert_eq!(count_node_kind(&p.syntax(), SyntaxKind::FLOW_DECL), 1);
}

#[test]
fn a_cue_name_with_an_escaped_hash_does_not_end_the_name_early() {
    // Issue #1738, mirrors `content.rs`'s
    // `a_tag_with_an_escaped_hash_does_not_end_the_tag_early`: `#` is one of
    // the four members of the ruled, final inline escape set (§8d.6), but
    // before this fix `cue_name()` gave `\#` zero escape treatment — a bare
    // `HASH` always ended the name, even one immediately preceded by a
    // backslash.
    let src = "@NAME \\#not a tag\nHello.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let cue = ast::Cue::cast(first_node(&p.syntax(), SyntaxKind::CUE)).expect("CUE");
    assert_eq!(
        cue.name().expect("name").text(),
        "NAME #not a tag",
        "an escaped `#` must not end the name early, and (issue #2045)
         `CueName::text()` now strips the recognized escape's backslash,
         parity with `markup::escape`'s stripping in ordinary content"
    );
    assert!(
        !has_node_kind(&p.syntax(), SyntaxKind::TAG),
        "the escaped `#` must not be reparsed as a trailing TAG"
    );
}

#[test]
fn a_hash_inside_an_open_brace_still_ends_a_cue_name_early() {
    // Issue #1883 (item 1), confirmed: existing #1787 reasoning still
    // applies, `cue_name()` does NOT become depth-aware for `HASH` the way
    // it did for `COLON` (#1851) — this is intentional, not a residual
    // gap. `COLON`/`R_BRACE` are raw, ungrammared punctuation this scan
    // locally balances as "just text"; `HASH` is never that — an
    // unescaped `HASH` always begins its own `TAG` node, a real,
    // tokenized CST boundary (the exact reasoning §4.7's per-tag-scope
    // ruling already states for why `HASH` must stay a hard reset between
    // sibling tags). Gating `HASH` by `depth == 0` would let it merge into
    // the *name's own text* whenever a brace happens to be open — turning
    // an always-a-new-TAG token into sometimes-just-text depending on
    // unrelated brace-balance, which would blur that same absolute
    // boundary the other direction. So `@NAME {a#b} c.` still fails to
    // parse: the name ends at "a" (before the unclosed `{` is ever
    // balanced), `#b` becomes a sibling `TAG`, and the still-open `{`'s
    // matching `}` is consumed as an unexpected top-level token once the
    // name's own scan is long over.
    let src = "@NAME {a#b} c.\nHello.\n";
    let p = assert_lossless(src);
    assert!(
        !p.errors().is_empty(),
        "expected the open `{{` to be abandoned at the HASH boundary and \
         the later `}}` to become a stray top-level token, got: {:?}",
        p.errors()
    );
    let cue = ast::Cue::cast(first_node(&p.syntax(), SyntaxKind::CUE)).expect("CUE");
    assert_eq!(
        cue.name().expect("name").text(),
        "NAME {a",
        "the name ends at the unescaped HASH regardless of the still-open `{{`"
    );
    assert_eq!(
        count_node_kind(&p.syntax(), SyntaxKind::TAG),
        1,
        "the HASH starts its own sibling TAG rather than continuing the name"
    );
}

#[test]
fn a_cue_names_own_unescaped_closing_brace_remains_the_terminator_even_when_preceded_by_a_backslash()
 {
    // Issue #1883 (item 2), confirmed intentional, not a residual bug —
    // mirrors `content.rs`'s
    // `a_tags_own_unescaped_closing_brace_remains_the_terminator_even_when_preceded_by_a_backslash`
    // exactly: `\{`'s backslash-parity carve-out (#1852) exists because
    // `\{` is one of the ruled, final four-character inline escape set
    // (§8d.6: `\< \{ \# \\`) — #1716/PR #1732 ruled it the literal-brace
    // escape, so counting it as a real depth-opener would be the
    // surprising reading. `}` is not a member of that set, so there is no
    // equivalent "`\}` is a literal, non-metacharacter close-brace" ruling
    // to protect — an `R_BRACE` preceded by a `BACKSLASH` is exactly what
    // it looks like, an ordinary backslash followed by an ordinary,
    // structurally significant `}`, so it keeps ending the name exactly
    // like an unescaped `}` would, at depth zero.
    let src = "flow f() { @NAME \\{a\\} c. }\n";
    let p = assert_lossless(src);
    assert!(
        !p.errors().is_empty(),
        "expected the name's own `\\}}` to swallow the flow's closer early \
         and leave a stray top-level `}}`, got: {:?}",
        p.errors()
    );
    let name = p
        .syntax()
        .descendants()
        .find(|n| n.kind() == SyntaxKind::CUE_NAME)
        .expect("CUE_NAME");
    assert_eq!(
        name.text(),
        "NAME \\{a\\",
        "the raw CST node — not `ast::CueName::text()`, which (issue #2045) \
         strips the recognized `\\{{` escape's backslash — stops the instant \
         it meets the `}}` from `\\}}`, backslash and all, exactly as it \
         would for an unescaped `}}` at depth zero"
    );
}

#[test]
fn a_cue_names_text_accessor_strips_a_recognized_open_brace_escapes_backslash() {
    // Issue #2045's own scope note: `\{` gets the identical treatment as
    // `\#`. Kept brace-free-of-depth (no unclosed `{` here — the depth
    // carve-out this fix does not touch stays orthogonal, see
    // `a_cue_name_with_an_escaped_open_brace_does_not_swallow_the_enclosing_blocks_own_closer`).
    let src = "@NAME \\{not a brace\nHello.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let cue = ast::Cue::cast(first_node(&p.syntax(), SyntaxKind::CUE)).expect("CUE");
    assert_eq!(cue.name().expect("name").text(), "NAME {not a brace");
}

#[test]
fn a_cue_immediately_followed_by_the_enclosing_blocks_own_closer_still_stops_there() {
    // Guard against over-correcting, the same way `content.rs`'s
    // `a_tag_immediately_followed_by_the_enclosing_blocks_own_closer_still_stops_there`
    // guards `tag()`: with no `{` opened inside the cue name's own text,
    // depth stays zero and the very first `}` — here the flow body's own
    // closer — must still terminate the name, exactly as before this fix.
    let src = "flow f() { @NAME }\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert_eq!(count_node_kind(&p.syntax(), SyntaxKind::FLOW_DECL), 1);
}

#[test]
fn a_cue_name_containing_a_balanced_alternation_brace_does_not_end_early() {
    // Same defect as `a_balanced_brace_in_a_cue_name_does_not_swallow_the_enclosing_blocks_own_closer`,
    // alternation-shaped brace instead of interpolation — parity with
    // `content.rs`'s `a_tag_containing_a_balanced_alternation_brace_does_not_end_early`.
    let src = "flow f() {\n  @NAME {gold|silver} coins.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert_eq!(count_node_kind(&p.syntax(), SyntaxKind::FLOW_DECL), 1);
}

#[test]
fn the_compact_cue_fuses_a_name_and_one_dialogue_line() {
    // Spec 8b.9: a SECOND declared pattern beside the block cue, not a
    // rewrite of it - so it gets its own node kind.
    let src = "@KID: Says who?\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let compact = ast::CompactCue::cast(first_node(&p.syntax(), SyntaxKind::COMPACT_CUE))
        .expect("COMPACT_CUE");
    assert_eq!(compact.name().expect("name").text(), "KID");
    assert_eq!(
        compact.line().expect("fused line").to_string().trim(),
        "Says who?"
    );
    assert!(
        !has_node_kind(&p.syntax(), SyntaxKind::CUE),
        "the compact form is its own shape, not a CUE plus a line"
    );
}

#[test]
fn a_compact_cue_line_keeps_interpolation_and_trailing_tags() {
    let src = "@KID: I have {gold} coins. #beat\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let compact = first_node(&p.syntax(), SyntaxKind::COMPACT_CUE);
    assert!(has_node_kind(&compact, SyntaxKind::INTERPOLATION));
    assert!(has_node_kind(&compact, SyntaxKind::TAG));
}

// ── `!name` sigil dispatch (§3.5b, issue #2004) ──────────────────────

#[test]
fn a_bang_name_line_parses_as_a_bang_dispatch() {
    let src = "!radio TAC-2: All units report in.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let dispatch = ast::BangDispatch::cast(first_node(&p.syntax(), SyntaxKind::BANG_DISPATCH))
        .expect("BANG_DISPATCH");
    assert_eq!(dispatch.name().expect("name").text(), "radio");
    assert_eq!(
        dispatch.line().expect("fused remainder").to_string().trim(),
        "TAC-2: All units report in."
    );
}

#[test]
fn a_bang_dispatch_line_keeps_interpolation_and_trailing_tags() {
    let src = "!radio I have {gold} coins. #beat\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let dispatch = first_node(&p.syntax(), SyntaxKind::BANG_DISPATCH);
    assert!(has_node_kind(&dispatch, SyntaxKind::INTERPOLATION));
    assert!(has_node_kind(&dispatch, SyntaxKind::TAG));
}

#[test]
fn a_bang_not_immediately_followed_by_an_ident_is_still_plain_text() {
    // Adjacency-guarded the same way `@NAME` is (`at_cue`'s own doc): a
    // gap between `!` and the name means this is ordinary exclamation-mark
    // prose, not a dispatch attempt.
    let src = "flow f() {\n  ! Wait, listen.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::BANG_DISPATCH));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::CONTENT_LINE));
}

#[test]
fn an_escaped_bang_composes_with_bang_dispatch_and_stays_plain_text() {
    // §8d.6's line-start escape (`\!`, issue #1744/#1978) must still win
    // over the sigil this issue adds — composition, not a collision.
    let src = "flow f() {\n  \\!radio still just prose.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(
        !has_node_kind(&p.syntax(), SyntaxKind::BANG_DISPATCH),
        "an escaped `\\!` must never open a BANG_DISPATCH"
    );
    assert!(has_node_kind(&p.syntax(), SyntaxKind::ESCAPE));
}

#[test]
fn a_lone_at_in_prose_is_still_plain_text() {
    // `SyntaxKind::AT`'s standing promise: the cue sigil is tight, so a
    // detached `@` never claims a line.
    let src = "flow f() {\n  @ home tomorrow\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::CUE));
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::COMPACT_CUE));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::CONTENT_LINE));
}

#[test]
fn an_annotation_line_is_not_a_cue() {
    // `@[` is its own token; the two channels cannot collide.
    let src = "@[effects(pure)]\nflow f() {\n  Hi.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::CUE));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::ANNOTATION_LINE));
}

// ── Parentheticals and the chain rule ────────────────────────────────

#[test]
fn a_parenthetical_after_a_cue_is_a_delivery_line() {
    let src = "@VENDOR\n(hushed)\nYou shouldn't be here.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let paren = ast::Parenthetical::cast(first_node(&p.syntax(), SyntaxKind::PARENTHETICAL))
        .expect("PARENTHETICAL");
    assert_eq!(paren.text(), "hushed");
}

#[test]
fn a_parenthetical_after_that_cues_dialogue_is_still_a_delivery_line() {
    // The inventory's chain rule: "(...) line, chain: after cue or
    // dialogue" - the chain survives the dialogue lines under a cue.
    let src = "@VENDOR\nYou shouldn't be here.\n(muttering)\nNot after dark.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
}

#[test]
fn a_blank_line_breaks_the_chain_so_a_label_line_stays_a_label() {
    // `brink_ir::dialect`: "blank lines always break a chain".
    let src = "@VENDOR\nYou shouldn't be here.\n\n(loop_back)\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::LABEL));
}

#[test]
fn a_bare_label_line_outside_any_chain_is_untouched() {
    // The shipped G-1 spelling, exactly as
    // `tests/tier1-brink-respell/labeled-mid-flow-gather` writes it.
    let src = "flow f() {\n  (look_around)\n  You look around.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::LABEL));
}

#[test]
fn a_parenthetical_must_fill_its_whole_line() {
    // In a live chain, `(label) text` is still G-1's labeled content line
    // - only a `(...)` that fills the line is a delivery.
    let src = "@VENDOR\n(spot_here) I trudge on.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::LABEL));
}

#[test]
fn a_braced_body_starts_a_fresh_chain() {
    // A cue outside a nested body must not make a `(label)` line inside
    // it a parenthetical.
    let src = "@VENDOR\nflow inner() {\n  (loop_back)\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::LABEL));
}

#[test]
fn a_scene_heading_breaks_the_chain() {
    let src = "@VENDOR\nHello.\nINT. A [a]\n(loop_back)\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(!has_node_kind(&p.syntax(), SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&p.syntax(), SyntaxKind::LABEL));
}

#[test]
fn a_multi_word_parenthetical_carries_trailing_tags() {
    let src = "@VENDOR\n(under his breath) #beat\nHello.\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let paren = first_node(&p.syntax(), SyntaxKind::PARENTHETICAL);
    assert_eq!(tag_texts(&paren), vec!["#beat".to_owned()]);
    assert_eq!(
        ast::Parenthetical::cast(paren)
            .expect("PARENTHETICAL")
            .text(),
        "under his breath"
    );
}

// ── Tags on declarations (§8b.4) ─────────────────────────────────────

#[test]
fn a_flow_header_carries_trailing_tags_before_its_body() {
    let src = "flow market(gold) #act1 #tense {\n  Hi.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let decl =
        ast::FlowDecl::cast(first_node(&p.syntax(), SyntaxKind::FLOW_DECL)).expect("FLOW_DECL");
    assert_eq!(
        decl.tags()
            .map(|t| t.to_string().trim().to_owned())
            .collect::<Vec<_>>(),
        vec!["#act1".to_owned(), "#tense".to_owned()]
    );
    assert!(
        decl.body().is_some(),
        "the tag text must not swallow the body brace"
    );
}

#[test]
fn a_paren_less_flow_header_carries_tags_too() {
    let src = "flow market #act1 {\n  Hi.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let decl =
        ast::FlowDecl::cast(first_node(&p.syntax(), SyntaxKind::FLOW_DECL)).expect("FLOW_DECL");
    assert_eq!(decl.tags().count(), 1);
    assert!(decl.body().is_some());
}

#[test]
fn header_tags_do_not_swallow_a_body_dialect_selector() {
    let src = "flow market #act1 ~{\n  let x = 1;\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    assert!(has_node_kind(&p.syntax(), SyntaxKind::STMT_BLOCK));
}

#[test]
fn a_flow_word_starting_a_prose_line_is_still_prose() {
    // Finding #5's firewall, re-checked after widening `at_flow_decl` to
    // accept a `#` third token.
    let src = "flow f() {\n  flow gently #1 and the river bends.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let inner = p
        .syntax()
        .descendants()
        .filter(|n| n.kind() == SyntaxKind::FLOW_DECL)
        .count();
    assert_eq!(inner, 1, "the prose line must not parse as a second flow");
}

#[test]
fn a_prose_line_with_an_interpolation_brace_is_still_prose() {
    // Review finding on #1715: a bare "any `{` on the line" lookahead
    // mistook a prose line's own interpolation brace for a declaration
    // body opener. `header_tags_precede_a_body` must require the `{` to
    // be the *last* non-trivia token on the header line.
    //
    // Tightened for #1728: this used to stop short of `assert_lossless` +
    // zero-errors because falling through to prose meant the line's `#1`
    // became an ordinary content-line tag, and `content::tag()`'s
    // free-text scan unconditionally stopped at the first literal `}` it
    // met — including the interpolation's own closer — fooling the
    // enclosing flow's own closing brace. #1728 fixed that scan (it now
    // tracks brace depth so a balanced `}` never terminates the tag
    // early), so this line must now parse completely clean.
    let src = "flow f() {\n  flow gently #1 and {gold} coins.\n  The river bends.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let inner = p
        .syntax()
        .descendants()
        .filter(|n| n.kind() == SyntaxKind::FLOW_DECL)
        .count();
    assert_eq!(inner, 1, "the prose line must not parse as a second flow");
}

#[test]
fn a_prose_line_with_an_alternation_brace_is_still_prose() {
    // Same firewall, alternation form of the same review finding. See the
    // interpolation test above — tightened for #1728 the same way.
    let src = "flow f() {\n  flow gently #1 and {river|stream} bends.\n}\n";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());
    let inner = p
        .syntax()
        .descendants()
        .filter(|n| n.kind() == SyntaxKind::FLOW_DECL)
        .count();
    assert_eq!(inner, 1, "the prose line must not parse as a second flow");
}

// ── The spec's own complement-pass page ──────────────────────────────

#[test]
fn the_complement_pass_page_parses_cleanly() {
    // docs/prose-dialect-spec.md 8d, "the complement-pass page", trimmed
    // to the shapes this slice owns (no markup spans, no `!name`
    // annotation elements - those are #1716/#1719).
    let src = "\
INT. MARKET SQUARE - NIGHT [market] #act1

The square is empty. A single lantern gutters against the dark.

@VENDOR #(v.o.)
(hushed)
You shouldn't be here after dark. The gates closed an hour ago.

@KID: Says who?

@VENDOR: The curfew, kid. That.

{?
  * \"I was just leaving.\"[] I muttered, backing away.
  * [Slip into the alley] -> alley_escape
}
The bell tolls again.

-> alley_escape

EXT. COLD ALLEY - CONTINUOUS [alley_escape]

Cold brick. Distant bells.
-> DONE
";
    let p = assert_lossless(src);
    assert!(p.errors().is_empty(), "errors: {:?}", p.errors());

    let root = p.syntax();
    assert_eq!(
        root.children()
            .filter(|n| n.kind() == SyntaxKind::SCENE_STITCH)
            .count(),
        2
    );
    assert!(has_node_kind(&root, SyntaxKind::CUE));
    assert!(has_node_kind(&root, SyntaxKind::COMPACT_CUE));
    assert!(has_node_kind(&root, SyntaxKind::PARENTHETICAL));
    assert!(has_node_kind(&root, SyntaxKind::SCENE_SLUG));
    assert!(has_node_kind(&root, SyntaxKind::CHOICE_POINT));
}