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
//! Per-container bytecode emission.
use brink_format::{ChoiceFlags, Opcode, SequenceKind};
use brink_ir::lir;
use crate::{CodegenError, ContainerEmitter, LoopCtx};
impl ContainerEmitter<'_> {
/// Emit every statement in `stmts`, recording one
/// [`crate::debug_info::RawDebugEntry`] per statement first whenever
/// `self.debug_entries` is `Some` (a no-op otherwise — see
/// [`Self::record_debug_entry`]). Used for **every** body walk in this
/// container's bytecode — the container's own top-level body (via
/// [`Self::emit_body_top_level`], which additionally flags the
/// prologue-end entry) *and* every nested body (`Conditional`/
/// `Sequence` branch bodies, `LogicWhile` body/post) reached through
/// [`Self::emit_stmt`] below — so a statement inside a branch or loop
/// gets an entry the same way a top-level one does (#3219 review: this
/// used to be true only at the top level). `prologue_end` is always
/// `false` here; §2.4's prologue-end marker is a per-container concept,
/// decided once by [`Self::emit_body_top_level`], not re-derived per
/// nesting level.
pub(super) fn emit_body(&mut self, stmts: &[lir::Stmt]) {
for stmt in stmts {
self.record_debug_entry(stmt, false);
self.emit_stmt(stmt);
}
}
/// D6 (`docs/debugger-spec.md` §2.2/§2.4): the container's own top-level
/// body walk — the only call site that may set `PROLOGUE_END` on a
/// recorded entry. `prologue_end_index` names which statement in
/// `stmts` (by position) is the landing point past this container's
/// prologue bytecode; `walk_container` computes it before calling this
/// (see its own doc): `Some(0)` in the common case (no leading
/// `ChoiceOutput`, so the first statement proper *is* the landing
/// point), `Some(1)` when `stmts[0]` is a choice-target body's leading
/// `ChoiceOutput` (itself prologue bytecode per §2.4 — the landing point
/// is the statement *after* it, not the `ChoiceOutput` itself), or
/// `None` when there is no statement to flag at all (empty body, or a
/// choice-target body containing only the `ChoiceOutput`) — in which
/// case `walk_container` pushes its own synthetic coverage entry after
/// this returns.
pub(super) fn emit_body_top_level(
&mut self,
stmts: &[lir::Stmt],
prologue_end_index: Option<usize>,
) {
for (i, stmt) in stmts.iter().enumerate() {
self.record_debug_entry(stmt, Some(i) == prologue_end_index);
self.emit_stmt(stmt);
}
}
#[expect(
clippy::too_many_lines,
reason = "one match arm per LIR Stmt variant; splitting would obscure the dispatch"
)]
fn emit_stmt(&mut self, stmt: &lir::Stmt) {
match &stmt.kind {
lir::StmtKind::EmitContent(content) => self.emit_content(content),
lir::StmtKind::EmitLine(emission) => self.emit_recognized_line(emission),
lir::StmtKind::EmitLineVariants(v) => self.emit_line_variants(v),
lir::StmtKind::EvalLine(emission) => self.emit_eval_line(emission),
lir::StmtKind::ChoiceOutput {
content, emission, ..
} => {
if let Some(em) = emission {
// Recognized output — emit as a line table entry.
self.emit_recognized_line(em);
} else {
// Fallback: emit content parts + tags inline.
self.emit_content(content);
}
}
lir::StmtKind::Divert(divert) => self.emit_divert(divert),
lir::StmtKind::TunnelCall(tunnel) => {
for target in &tunnel.targets {
for arg in &target.args {
self.emit_call_arg(arg);
}
match &target.target {
lir::DivertTarget::Address(id) => {
self.emit(Opcode::TunnelCall(*id));
}
lir::DivertTarget::Variable(id) => {
self.emit(Opcode::GetGlobal(*id));
self.emit(Opcode::TunnelCallVariable);
}
lir::DivertTarget::VariableTemp(slot, _) => {
self.emit(Opcode::GetTemp(*slot));
self.emit(Opcode::TunnelCallVariable);
}
lir::DivertTarget::Done => self.emit(Opcode::Done),
lir::DivertTarget::End => self.emit(Opcode::End),
}
}
}
lir::StmtKind::ThreadStart(thread) => {
for arg in &thread.args {
self.emit_call_arg(arg);
}
match &thread.target {
lir::DivertTarget::Address(id) => {
self.emit(Opcode::ThreadCall(*id));
}
lir::DivertTarget::Variable(id) => {
self.emit(Opcode::GetGlobal(*id));
self.emit(Opcode::GotoVariable);
}
lir::DivertTarget::VariableTemp(slot, _) => {
self.emit(Opcode::GetTemp(*slot));
self.emit(Opcode::GotoVariable);
}
lir::DivertTarget::Done => self.emit(Opcode::Done),
lir::DivertTarget::End => self.emit(Opcode::End),
}
}
lir::StmtKind::DeclareTemp {
slot,
value,
synthetic,
..
} => {
match value {
// #3395: a hoisted interpolation keeps display-position
// semantics — a direct call's printed output is captured
// into the value (the same composition a call in a slot
// gets), so `{$liftN}` later emits it where the original
// `{f()}` stood instead of the text landing ahead of the
// line. An authored `~ temp x = f()` is NOT composed:
// ink emits that call's text into the output stream
// immediately, and so does this.
Some(expr) if *synthetic => self.emit_slot_expr(expr),
Some(expr) => self.emit_expr(expr, false),
None => self.emit(Opcode::PushNull),
}
self.emit(Opcode::DeclareTemp(*slot));
}
lir::StmtKind::Assign { target, op, value } => {
self.emit_assign(target, *op, value);
}
lir::StmtKind::Return {
value,
is_tunnel,
args,
} => {
for arg in args {
self.emit_call_arg(arg);
}
if let Some(e) = value {
self.emit_expr(e, false);
} else {
self.emit(Opcode::PushNull);
}
if *is_tunnel {
self.emit(Opcode::TunnelReturn);
} else {
self.emit(Opcode::Return);
}
}
lir::StmtKind::ChoiceSet(cs) => self.emit_choice_set(cs),
lir::StmtKind::Conditional(cond) => self.emit_conditional(cond),
lir::StmtKind::Sequence(seq) => self.emit_sequence(seq),
lir::StmtKind::EnterContainer(id) => {
self.emit(Opcode::EnterContainer(*id));
}
lir::StmtKind::ExprStmt(expr) => {
self.emit_expr(expr, false);
self.emit(Opcode::Pop);
}
lir::StmtKind::EndOfLine => {
self.emit(Opcode::EmitNewline);
}
lir::StmtKind::LogicWhile(w) => self.emit_logic_while(w),
lir::StmtKind::LogicBreak => {
// Patched to land just after the whole loop once it's fully
// emitted (`emit_logic_while`). LIR lowering (E057,
// `brink-ir::lir::lower::blocks`) rejects `break` outside
// any loop and never emits this statement in that case — it
// is a non-suppressible LIR-lowering-time compile error, not
// a suppressible analysis diagnostic, so a well-formed
// `Program` never contains an unguarded `LogicBreak`. Trust
// that invariant the same way every other statement in this
// file trusts LIR is well-formed (no other arm here
// defensively re-checks its input) — see #577 review, which
// replaced a silent `Nop` degradation with a real, upstream
// error path (E057).
//
// That upstream guarantee is enforced by a *different*
// compiler stage, though, and codegen has no way to verify
// it structurally beyond this checkpoint — "safe today only
// by construction" (#586 review). If a future or
// refactored LIR producer (or, as here, a hand-assembled
// `Program` in a test) ever hands codegen a `LogicBreak`
// with an empty `loop_stack` anyway, there is no patch
// target for the jump this statement would otherwise emit:
// silently falling through to `Opcode::Jump(0)` would
// corrupt the bytecode with a jump to the start of the
// container, indistinguishable from a valid jump. Fail
// loudly instead — and skip emitting the dangling jump
// placeholder entirely, so no unpatched opcode ever lands
// in the output.
if self.loop_stack.is_empty() {
self.errors.push(CodegenError::new(
"codegen: `break` (LogicBreak) reached codegen outside any loop \
context — LIR lowering (E057) should have rejected this before it \
reached codegen; refusing to emit an unpatched jump (#586)",
));
} else {
let site = self.emit_jump_placeholder(Opcode::Jump(0));
if let Some(ctx) = self.loop_stack.last_mut() {
ctx.break_patches.push(site);
}
}
}
lir::StmtKind::AttachElement(expr) => {
self.emit_expr(expr, false);
self.emit(Opcode::AttachElement);
}
lir::StmtKind::EndElementRun => {
self.emit(Opcode::EndElementRun);
}
lir::StmtKind::LogicContinue => {
// See `LogicBreak` above — identical reasoning, `continue`'s
// own jump target.
if self.loop_stack.is_empty() {
self.errors.push(CodegenError::new(
"codegen: `continue` (LogicContinue) reached codegen outside any loop \
context — LIR lowering (E057) should have rejected this before it \
reached codegen; refusing to emit an unpatched jump (#586)",
));
} else {
let site = self.emit_jump_placeholder(Opcode::Jump(0));
if let Some(ctx) = self.loop_stack.last_mut() {
ctx.continue_patches.push(site);
}
}
}
}
}
/// Compile a `while`/desugared-`for` loop to a flat backward-jump loop
/// in the same container's bytecode — no child container, since block
/// bodies never contain choices/gathers that would need one.
///
/// ```text
/// loop_start: <condition>
/// JumpIfFalse loop_end ; jf_exit
/// <body> ; break -> loop_end, continue -> post_start
/// post_start: <post> ; empty for a plain `while`
/// Jump loop_start
/// loop_end:
/// ```
#[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
fn emit_logic_while(&mut self, w: &lir::LogicWhile) {
let loop_start = self.bytecode.len();
self.emit_expr(&w.condition, false);
let jf_exit = self.emit_jump_placeholder(Opcode::JumpIfFalse(0));
self.loop_stack.push(LoopCtx {
break_patches: Vec::new(),
continue_patches: Vec::new(),
});
self.emit_body(&w.body);
let LoopCtx {
break_patches,
continue_patches,
} = self.loop_stack.pop().unwrap_or(LoopCtx {
break_patches: Vec::new(),
continue_patches: Vec::new(),
});
// `continue` lands here, right before `post` — for a plain `while`
// (`post` empty) that's exactly the backward jump below, i.e.
// "re-check the condition"; for a desugared `for`, that's the index
// increment, so `continue` still advances the loop instead of
// spinning forever.
for site in continue_patches {
self.patch_jump(site);
}
self.emit_body(&w.post);
// Backward jump to re-check the condition.
let relative = loop_start as i32 - (self.bytecode.len() as i32 + 5);
self.emit(Opcode::Jump(relative));
// `loop_end`: both the false-condition exit and every `break` land here.
self.patch_jump(jf_exit);
for site in break_patches {
self.patch_jump(site);
}
}
fn emit_divert(&mut self, divert: &lir::Divert) {
match &divert.target {
lir::DivertTarget::Address(id) => {
if divert.args.is_empty() {
self.emit(Opcode::Goto(*id));
} else {
for arg in &divert.args {
self.emit_call_arg(arg);
}
self.emit(Opcode::Goto(*id));
}
}
lir::DivertTarget::Variable(id) => {
for arg in &divert.args {
self.emit_call_arg(arg);
}
self.emit(Opcode::GetGlobal(*id));
self.emit(Opcode::GotoVariable);
}
lir::DivertTarget::VariableTemp(slot, _) => {
for arg in &divert.args {
self.emit_call_arg(arg);
}
self.emit(Opcode::GetTemp(*slot));
self.emit(Opcode::GotoVariable);
}
lir::DivertTarget::Done => self.emit(Opcode::Done),
lir::DivertTarget::End => self.emit(Opcode::End),
}
}
fn emit_assign(
&mut self,
target: &lir::AssignTarget,
op: brink_ir::AssignOp,
value: &lir::Expr,
) {
match op {
brink_ir::AssignOp::Set => {
self.emit_expr(value, false);
}
brink_ir::AssignOp::Add => {
match target {
lir::AssignTarget::Global(id) => self.emit(Opcode::GetGlobal(*id)),
lir::AssignTarget::Temp(slot, _) => self.emit(Opcode::GetTemp(*slot)),
}
self.emit_expr(value, false);
self.emit(Opcode::Add);
}
brink_ir::AssignOp::Sub => {
match target {
lir::AssignTarget::Global(id) => self.emit(Opcode::GetGlobal(*id)),
lir::AssignTarget::Temp(slot, _) => self.emit(Opcode::GetTemp(*slot)),
}
self.emit_expr(value, false);
self.emit(Opcode::Subtract);
}
}
match target {
lir::AssignTarget::Global(id) => self.emit(Opcode::SetGlobal(*id)),
lir::AssignTarget::Temp(slot, _) => self.emit(Opcode::SetTemp(*slot)),
}
}
fn emit_choice_set(&mut self, cs: &lir::ChoiceSet) {
for choice in &cs.choices {
self.emit_choice(choice);
}
// Yield to present pending choices. Without this, execution falls
// through to whatever follows the choice set in the same container
// (e.g., a gather's `goto end`), terminating the story before the
// VM can present choices.
//
// Uses `Yield` (not `Done`) so `did_safe_exit` is NOT set — if
// no choices are pending, the story ran out of content.
//
// Inside a conditional branch, the yield is deferred to the outer
// gather/container — emitting it here would block flow to the gather.
if !self.in_conditional_branch {
self.emit(Opcode::Yield);
}
}
fn emit_choice(&mut self, choice: &lir::Choice) {
let has_start = choice.start_content.is_some();
let has_choice_only = choice.choice_only_content.is_some();
let display = combine_choice_content(
choice.start_content.as_ref(),
choice.choice_only_content.as_ref(),
);
let flags = ChoiceFlags {
has_condition: choice.condition.is_some(),
has_start_content: has_start,
has_choice_only_content: has_choice_only,
once_only: !choice.is_sticky,
is_invisible_default: choice.is_fallback,
};
// All evaluation BEFORE BeginChoice.
// Push order: display first, condition second. The runtime pops
// condition first (from top), then display.
// 1. Display text (combined start + choice_only) — pushed first.
// Tags must be emitted INSIDE the display eval so the runtime
// routes them to the choice (via fragment tags or current_tags),
// not to the output line. Whitespace runs stay verbatim here
// (issue #3508, `in_choice_display`); the start text's OUTPUT
// copy is a separate content statement and collapses as usual.
self.in_choice_display = true;
if let Some(ref emission) = choice.display_emission {
// Recognized display — fragment with tags inside.
self.emit_fragment_recognized_line_with_tags(emission, &choice.tags);
} else if let Some(ref display) = display {
// Unrecognized display — string eval with tags inside the capture.
self.emit(Opcode::BeginStringEval);
self.emit_choice_content(display);
self.emit_tags(&display.tags);
self.emit_tags(&choice.tags);
self.emit(Opcode::EndStringEval);
} else if !choice.tags.is_empty() {
// No display content but tags exist — wrap in string eval so
// the capture context routes them to current_tags.
self.emit(Opcode::BeginStringEval);
self.emit_tags(&choice.tags);
self.emit(Opcode::EndStringEval);
}
self.in_choice_display = false;
// 2. Condition — pushed second (on top for runtime to pop first)
if let Some(ref cond) = choice.condition {
self.emit_expr(cond, false);
}
// 3. BeginChoice pops condition + display from stack
self.emit(Opcode::BeginChoice(flags, choice.target));
self.emit(Opcode::EndChoice);
}
pub(super) fn emit_conditional(&mut self, cond: &lir::Conditional) {
let is_switch = matches!(&cond.kind, lir::CondKind::Switch(_));
// For switch: push the switch expression once; each branch will
// Duplicate + Equal against it.
if let lir::CondKind::Switch(ref expr) = cond.kind {
self.emit_expr(expr, false);
}
// Collect jump-to-end patch sites for each branch.
let mut end_jumps: Vec<usize> = Vec::new();
for (i, branch) in cond.branches.iter().enumerate() {
let is_last = i == cond.branches.len() - 1;
if let Some(ref condition) = branch.condition {
if is_switch {
// Switch: duplicate switch value, push case value, compare.
self.emit(Opcode::Duplicate);
self.emit_expr(condition, false);
self.emit(Opcode::Equal);
} else {
self.emit_expr(condition, false);
}
// Placeholder JumpIfFalse — will be patched to skip this branch body.
let patch_site = self.emit_jump_placeholder(Opcode::JumpIfFalse(0));
if is_switch {
// Pop the switch value inside the taken branch (it was
// duplicated, so one copy remains on the stack).
self.emit(Opcode::Pop);
}
let prev = self.in_conditional_branch;
self.in_conditional_branch = true;
self.emit_body(&branch.body);
self.in_conditional_branch = prev;
if !is_last || is_switch {
// Jump to end of entire conditional.
// For switch: the last conditional branch must also jump
// past the cleanup Pop emitted for "no branch taken".
let end_site = self.emit_jump_placeholder(Opcode::Jump(0));
end_jumps.push(end_site);
}
// Patch the JumpIfFalse to land here (after body + optional Jump)
self.patch_jump(patch_site);
} else {
// Else branch — no condition, just emit body.
if is_switch {
// Pop the switch value before the else body.
self.emit(Opcode::Pop);
}
let prev = self.in_conditional_branch;
self.in_conditional_branch = true;
self.emit_body(&branch.body);
self.in_conditional_branch = prev;
}
}
// If no branch was taken (and there's no else), pop the switch value.
if is_switch && !cond.branches.iter().any(|b| b.condition.is_none()) {
self.emit(Opcode::Pop);
}
// Patch all end-of-branch jumps to land here
for site in end_jumps {
self.patch_jump(site);
}
}
/// Push the pre-increment visit count that selects this sequence's
/// branch: the enclosing wrapper's own (`CurrentVisitCount` — entering
/// the wrapper already counted the view), or, for a lift clone that
/// counts on its original (#3401), `TouchVisit` on that container —
/// the variant path's mechanism (`emit_line_variants`), so every clone
/// and every claimed line advance ONE state.
fn emit_sequence_count(&mut self, counter: Option<brink_format::DefinitionId>) {
match counter {
Some(c) => {
self.emit(Opcode::PushDivertTarget(c));
self.emit(Opcode::TouchVisit);
}
None => self.emit(Opcode::CurrentVisitCount),
}
}
/// Turn `seq_count, num_elements` on the stack into a shuffle index,
/// seeded from the wrapper's own `path_hash` (`Sequence(Shuffle)`) or,
/// for a clone counting on its original, from THAT container's
/// (`ShuffleIndexOf`) — the same seed the original site uses, so the
/// clones draw one permutation.
fn emit_shuffle_index(&mut self, counter: Option<brink_format::DefinitionId>) {
match counter {
Some(c) => {
self.emit(Opcode::PushDivertTarget(c));
self.emit(Opcode::ShuffleIndexOf);
}
None => self.emit(Opcode::Sequence(SequenceKind::Shuffle, 0)),
}
}
#[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
pub(super) fn emit_sequence(&mut self, seq: &lir::Sequence) {
let count = seq.branches.len();
let is_shuffle = seq.kind.contains(brink_ir::SequenceType::SHUFFLE);
let mut exhaustion_skip: Option<usize> = None;
if is_shuffle {
let is_once = seq.kind.contains(brink_ir::SequenceType::ONCE);
let is_stopping = seq.kind.contains(brink_ir::SequenceType::STOPPING);
if is_once {
// shuffle once: clamp visit count to N, skip all branches when exhausted
self.emit_sequence_count(seq.counter);
self.emit(Opcode::PushInt(count as i32));
self.emit(Opcode::Min);
self.emit(Opcode::Duplicate);
self.emit(Opcode::PushInt(count as i32));
self.emit(Opcode::Equal);
self.emit(Opcode::Not);
let site = self.emit_jump_placeholder(Opcode::JumpIfFalse(0));
// Not exhausted: do shuffle
self.emit(Opcode::PushInt(count as i32));
self.emit_shuffle_index(seq.counter);
exhaustion_skip = Some(site);
} else if is_stopping {
// shuffle stopping: clamp to N-1, skip shuffle when exhausted (pin to last)
// When exhausted: clamped value (N-1) stays on stack → matches last branch
// When not exhausted: shuffle among first N-1 branches only
self.emit_sequence_count(seq.counter);
self.emit(Opcode::PushInt(count as i32 - 1));
self.emit(Opcode::Min);
self.emit(Opcode::Duplicate);
self.emit(Opcode::PushInt(count as i32 - 1));
self.emit(Opcode::Equal);
self.emit(Opcode::Not);
let site = self.emit_jump_placeholder(Opcode::JumpIfFalse(0));
// Not exhausted: shuffle among first N-1 branches using clamped value as seq_count
self.emit(Opcode::PushInt(count as i32 - 1));
self.emit_shuffle_index(seq.counter);
// Patch exhaustion jump to land here (right before branch switch)
self.patch_jump(site);
} else {
// Plain shuffle or cycle shuffle
self.emit_sequence_count(seq.counter);
self.emit(Opcode::PushInt(count as i32));
self.emit_shuffle_index(seq.counter);
}
} else {
// Non-shuffle: use CurrentVisitCount + math to compute branch index.
self.emit_sequence_count(seq.counter);
if seq.kind.contains(brink_ir::SequenceType::CYCLE) {
// cycle: index = visit_count % count
self.emit(Opcode::PushInt(count as i32));
self.emit(Opcode::Modulo);
} else if seq.kind.contains(brink_ir::SequenceType::ONCE) {
// once: index = min(visit_count, count) — when index == count, no branch taken
self.emit(Opcode::PushInt(count as i32));
self.emit(Opcode::Min);
} else {
// stopping (default): index = min(visit_count, count - 1)
self.emit(Opcode::PushInt(count as i32 - 1));
self.emit(Opcode::Min);
}
}
// Switch pattern: for each branch, Duplicate/PushInt(i)/Equal/JumpIfFalse
let mut end_jumps: Vec<usize> = Vec::new();
let mut skip_sites: Vec<usize> = Vec::new();
for (i, branch) in seq.branches.iter().enumerate() {
// Patch previous skip to land here
if let Some(site) = skip_sites.pop() {
self.patch_jump(site);
}
self.emit(Opcode::Duplicate);
self.emit(Opcode::PushInt(i as i32));
self.emit(Opcode::Equal);
let skip_site = self.emit_jump_placeholder(Opcode::JumpIfFalse(0));
// Pop the duplicated index value
self.emit(Opcode::Pop);
self.emit_body(branch);
// Jump to the Nop at end (skip remaining branches)
let end_site = self.emit_jump_placeholder(Opcode::Jump(0));
end_jumps.push(end_site);
skip_sites.push(skip_site);
}
// Patch last skip — no match (once-only exhausted, or shuffle overflow)
if let Some(site) = skip_sites.pop() {
self.patch_jump(site);
}
// Patch shuffle-once exhaustion skip to land here (at Pop, skipping all branches)
if let Some(site) = exhaustion_skip {
self.patch_jump(site);
}
// Pop unmatched index
self.emit(Opcode::Pop);
// Landing target for all taken branches
self.emit(Opcode::Nop);
for site in end_jumps {
self.patch_jump(site);
}
}
}
/// Reconstruct combined content from two optional parts (e.g. start + bracket).
fn combine_choice_content(
a: Option<&lir::Content>,
b: Option<&lir::Content>,
) -> Option<lir::Content> {
match (a, b) {
(None, None) => None,
(Some(content), None) | (None, Some(content)) => Some(content.clone()),
(Some(a_content), Some(b_content)) => {
let mut parts = a_content.parts.clone();
parts.extend(b_content.parts.clone());
let mut tags = a_content.tags.clone();
tags.extend(b_content.tags.clone());
// The cover of both regions' locations (review finding, #3202)
// — not `a`'s alone. `emit_content_parts` stamps this one
// location on *every* fragment it emits, including `b`'s
// fragments once combined here; "`a`'s location wins" made a
// `b`-only fragment (e.g. bracket/inner text with no `a`
// counterpart) carry a range that doesn't even contain its own
// text. The union is honest for both: it may be wider than a
// single fragment's own span, but it always contains it.
let source_location = union_source_location(
a_content.source_location.as_ref(),
b_content.source_location.as_ref(),
);
Some(lir::Content {
parts,
tags,
source_location,
})
}
}
}
/// Cover two optional source locations — the smallest range containing
/// both, when they name the same file. One-sided inputs pass through
/// unchanged; differing files (should not arise for two regions of the same
/// choice) fall back to whichever side is present, preferring `a`, since
/// there is no single range that could honestly cover both.
fn union_source_location(
a: Option<&brink_format::SourceLocation>,
b: Option<&brink_format::SourceLocation>,
) -> Option<brink_format::SourceLocation> {
match (a, b) {
(None, None) => None,
(Some(loc), None) | (None, Some(loc)) => Some(loc.clone()),
(Some(a), Some(b)) if a.file == b.file => Some(brink_format::SourceLocation {
file: a.file.clone(),
range_start: a.range_start.min(b.range_start),
range_end: a.range_end.max(b.range_end),
}),
(Some(a), Some(_)) => Some(a.clone()),
}
}